Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce02bbc35a | ||
|
|
0b9a9dab50 | ||
|
|
73973841c2 | ||
|
|
2f3be1d193 | ||
|
|
8e42906a7f | ||
|
|
dc70cafbb7 | ||
|
|
a28135cd51 | ||
|
|
4b9c01d52d | ||
|
|
3356602951 | ||
|
|
537217e70a | ||
|
|
ed05a1e723 | ||
|
|
da16ab0575 | ||
|
|
d9ca8f8d60 | ||
|
|
26cccd5c63 | ||
|
|
0b2813cd42 | ||
|
|
6cdab2fde1 | ||
|
|
6239780978 | ||
|
|
67cf3044f1 | ||
|
|
66be35c3d4 | ||
|
|
046cfe62b8 |
No files matched your search
@@ -23,11 +23,11 @@ jobs:
|
|||||||
|
|
||||||
- uses: Swatinem/rust-cache@v2.9.1
|
- uses: Swatinem/rust-cache@v2.9.1
|
||||||
|
|
||||||
# libmpv for pstr-player.
|
# libmpv for pstr-player, WebKitGTK for the CAPTCHA window.
|
||||||
- name: Install libmpv
|
- name: Install libmpv and WebKitGTK
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install -y libmpv-dev
|
sudo apt-get install -y libmpv-dev libwebkit2gtk-4.1-dev
|
||||||
|
|
||||||
- run: cargo fmt --all -- --check
|
- run: cargo fmt --all -- --check
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
name: iOS
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
workflow_call:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- 'ios/**'
|
||||||
|
- 'android/app/src/main/res/font/**'
|
||||||
|
- 'android/app/src/main/assets/licenses/**'
|
||||||
|
- 'crates/pstr-android/**'
|
||||||
|
- 'crates/pstr-core/**'
|
||||||
|
- 'crates/pstr-meta/**'
|
||||||
|
- 'crates/pstr-stream/**'
|
||||||
|
- 'Cargo.toml'
|
||||||
|
- 'Cargo.lock'
|
||||||
|
- 'scripts/build-ios.sh'
|
||||||
|
- '.github/workflows/ios.yml'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
env:
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Unit tests and unsigned .ipa
|
||||||
|
runs-on: macos-15
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
|
- id: toolchain
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
toolchain: '1.96'
|
||||||
|
targets: aarch64-apple-ios,aarch64-apple-ios-sim
|
||||||
|
|
||||||
|
- name: Restore the Rust target tree
|
||||||
|
uses: actions/cache@v6
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry
|
||||||
|
~/.cargo/git
|
||||||
|
target
|
||||||
|
key: rust-target-ios-${{ steps.toolchain.outputs.cachekey }}-${{ hashFiles('Cargo.lock') }}
|
||||||
|
restore-keys: rust-target-ios-${{ steps.toolchain.outputs.cachekey }}-
|
||||||
|
|
||||||
|
- name: Install XcodeGen
|
||||||
|
run: brew install xcodegen
|
||||||
|
|
||||||
|
- name: Bindings
|
||||||
|
run: scripts/build-ios.sh bindings
|
||||||
|
|
||||||
|
- name: Unit tests on the simulator
|
||||||
|
run: scripts/build-ios.sh test
|
||||||
|
|
||||||
|
# SideStore and AltStore re-sign whatever they install with the viewer's
|
||||||
|
# own Apple ID, so the release artifact is unsigned.
|
||||||
|
- name: Unsigned .ipa
|
||||||
|
run: |
|
||||||
|
scripts/build-ios.sh rust
|
||||||
|
scripts/build-ios.sh ipa
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
name: ios
|
||||||
|
path: dist/*.ipa
|
||||||
|
if-no-files-found: error
|
||||||
@@ -76,7 +76,7 @@ jobs:
|
|||||||
- name: Install build dependencies
|
- name: Install build dependencies
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install -y libmpv-dev rpm
|
sudo apt-get install -y libmpv-dev libwebkit2gtk-4.1-dev rpm
|
||||||
|
|
||||||
- name: Gate
|
- name: Gate
|
||||||
run: |
|
run: |
|
||||||
@@ -150,10 +150,17 @@ jobs:
|
|||||||
signed_release: true
|
signed_release: true
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|
||||||
|
# The unsigned .ipa: SideStore and AltStore re-sign it with the viewer's own
|
||||||
|
# Apple ID when they install it.
|
||||||
|
ios:
|
||||||
|
name: iOS
|
||||||
|
needs: verify
|
||||||
|
uses: ./.github/workflows/ios.yml
|
||||||
|
|
||||||
publish:
|
publish:
|
||||||
name: Publish
|
name: Publish
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
needs: [linux, windows, android]
|
needs: [linux, windows, android, ios]
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
@@ -172,6 +179,10 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
name: android
|
name: android
|
||||||
path: artifacts
|
path: artifacts
|
||||||
|
- uses: actions/download-artifact@v8
|
||||||
|
with:
|
||||||
|
name: ios
|
||||||
|
path: artifacts
|
||||||
|
|
||||||
- name: Checksums
|
- name: Checksums
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ members = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "1.1.1"
|
version = "1.4.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.7.2" }
|
proton-sdk = { version = "0.7.3" }
|
||||||
proton-drive-rs = { version = "0.7.2" }
|
proton-drive-rs = { version = "0.7.3" }
|
||||||
|
|
||||||
# Internal crates.
|
# Internal crates.
|
||||||
pstr-core = { path = "crates/pstr-core" }
|
pstr-core = { path = "crates/pstr-core" }
|
||||||
@@ -54,6 +54,8 @@ directories = "5"
|
|||||||
# Local dates for the history page. Already in the tree through the SDK.
|
# Local dates for the history page. Already in the tree through the SDK.
|
||||||
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
|
# The id a watch-history file is named by. Already in the tree through the SDK.
|
||||||
|
getrandom = "0.3"
|
||||||
# `bundled` so a Windows build needs no system SQLite.
|
# `bundled` so a Windows build needs no system SQLite.
|
||||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||||
# The share URL fragment and the custom password are both secrets; they go in
|
# The share URL fragment and the custom password are both secrets; they go in
|
||||||
@@ -103,6 +105,14 @@ dbus = "0.9"
|
|||||||
windows-sys = "0.61"
|
windows-sys = "0.61"
|
||||||
# Toasts on Windows. Linux notifications go over the `dbus` above.
|
# Toasts on Windows. Linux notifications go over the `dbus` above.
|
||||||
tauri-winrt-notification = "0.8"
|
tauri-winrt-notification = "0.8"
|
||||||
|
# The exe's own icon resource, which is what Explorer and a taskbar pin draw.
|
||||||
|
embed-resource = "3"
|
||||||
|
# Proton's CAPTCHA page, which reports success by posting a message to its
|
||||||
|
# host, so it needs a webview this app controls. Run in a child process of its
|
||||||
|
# own (`pstr-app::verify`): tao and eframe's winit cannot share an event loop.
|
||||||
|
# WebKitGTK on Linux, WebView2 on Windows, WKWebView on macOS.
|
||||||
|
wry = { version = "0.57", default-features = false, features = ["os-webview"] }
|
||||||
|
tao = { version = "0.37", default-features = false, features = ["rwh_06"] }
|
||||||
rpassword = "7"
|
rpassword = "7"
|
||||||
libloading = "0.8"
|
libloading = "0.8"
|
||||||
lru = "0.18"
|
lru = "0.18"
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ streaming implementation through UniFFI. It features a modern Material 3 interfa
|
|||||||
an immersive fullscreen player with auto-hiding controls, and offline downloads.
|
an immersive fullscreen player with auto-hiding controls, and offline downloads.
|
||||||
Its toolchain, emulator, signing, and release instructions are in [`docs/ANDROID.md`](docs/ANDROID.md).
|
Its toolchain, emulator, signing, and release instructions are in [`docs/ANDROID.md`](docs/ANDROID.md).
|
||||||
|
|
||||||
|
An iOS 17+ client under `ios/` is a SwiftUI port of the Android one over the same
|
||||||
|
Rust engine, built as an unsigned `.ipa` for SideStore or AltStore; see
|
||||||
|
[`docs/IOS.md`](docs/IOS.md).
|
||||||
|
|
||||||
```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
|
proton-stream 'https://drive.proton.me/urls/TOKEN#fragment' # open the Add form with a link
|
||||||
@@ -73,7 +77,7 @@ against the SDK's own range download.
|
|||||||
| UI | egui/eframe on glow; mpv renders into the same GL context. |
|
| UI | egui/eframe on glow; mpv renders into the same GL context. |
|
||||||
| Themes | The shipped near-black palette, Catppuccin Latte, Frappé, Macchiato and Mocha, and a Persona 5 one — black, white and one loud red, which is what the Red accent is there for — each with a choice of nine accents, drawn as a gradient unless you turn that off. Picked on the Settings page, applied without a restart. |
|
| Themes | The shipped near-black palette, Catppuccin Latte, Frappé, Macchiato and Mocha, and a Persona 5 one — black, white and one loud red, which is what the Red accent is there for — each with a choice of nine accents, drawn as a gradient unless you turn that off. Picked on the Settings page, applied without a restart. |
|
||||||
| Metadata | Filename parsing, with optional AniList/TMDB enrichment — **off by default**, because enabling it sends your library's titles to a third party. It brings posters, synopses, fanart and per-episode titles and synopses (for AniList, from ani.zip, which is sent the matched ids); a title the scorer refuses to guess about can be pinned by hand, and a pinned match is never overwritten. The library sorts by name, watch, add or air date, rating and popularity, folds a franchise into one tile, and shelves titles by director, studio and genre. See [docs/METADATA.md](docs/METADATA.md). |
|
| Metadata | Filename parsing, with optional AniList/TMDB enrichment — **off by default**, because enabling it sends your library's titles to a third party. It brings posters, synopses, fanart and per-episode titles and synopses (for AniList, from ani.zip, which is sent the matched ids); a title the scorer refuses to guess about can be pinned by hand, and a pinned match is never overwritten. The library sorts by name, watch, add or air date, rating and popularity, folds a franchise into one tile, and shelves titles by director, studio and genre. See [docs/METADATA.md](docs/METADATA.md). |
|
||||||
| Platforms | Linux and Windows; macOS packaging is written but has not been run on a Mac. A native Android 12+ client is available featuring a Material 3 UI and immersive fullscreen playback. |
|
| Platforms | Linux and Windows; macOS packaging is written but has not been run on a Mac. A native Android 12+ client is available featuring a Material 3 UI and immersive fullscreen playback, and an iOS 17+ port of it for sideloading. |
|
||||||
|
|
||||||
## Building
|
## Building
|
||||||
|
|
||||||
@@ -83,7 +87,9 @@ Rust 2024, MSRV 1.96.
|
|||||||
cargo build --release
|
cargo build --release
|
||||||
```
|
```
|
||||||
|
|
||||||
`pstr-player` needs libmpv development headers (`mpv` ≥ 2.0). On Windows those
|
`pstr-player` needs libmpv development headers (`mpv` ≥ 2.0), and on Linux
|
||||||
|
`pstr-app` needs WebKitGTK 4.1's, for the window Proton's CAPTCHA is shown in.
|
||||||
|
On Windows the libmpv ones
|
||||||
come from the separate **mpv-dev** archive rather than mpv's player build, and
|
come from the separate **mpv-dev** archive rather than mpv's player build, and
|
||||||
`libmpv-2.dll` ships next to the executable. The Proton SDK crates come from
|
`libmpv-2.dll` ships next to the executable. The Proton SDK crates come from
|
||||||
crates.io; nothing here builds against a sibling checkout.
|
crates.io; nothing here builds against a sibling checkout.
|
||||||
@@ -97,6 +103,9 @@ Android builds use JDK 17, Android SDK 36/NDK 28, Gradle 8.13, `cargo-ndk`, and
|
|||||||
UniFFI. Run `bash scripts/build-android.sh debug` after installing that toolchain;
|
UniFFI. Run `bash scripts/build-android.sh debug` after installing that toolchain;
|
||||||
see [`docs/ANDROID.md`](docs/ANDROID.md) for exact setup and release signing.
|
see [`docs/ANDROID.md`](docs/ANDROID.md) for exact setup and release signing.
|
||||||
|
|
||||||
|
iOS builds need macOS with Xcode, XcodeGen and the `aarch64-apple-ios` Rust
|
||||||
|
target: `scripts/build-ios.sh all` writes `dist/proton-stream-<version>-ios.ipa`.
|
||||||
|
|
||||||
## Privacy
|
## Privacy
|
||||||
|
|
||||||
- The share URL fragment **is** the decryption password. It and any custom
|
- The share URL fragment **is** the decryption password. It and any custom
|
||||||
@@ -119,9 +128,10 @@ see [`docs/ANDROID.md`](docs/ANDROID.md) for exact setup and release signing.
|
|||||||
|
|
||||||
## 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 and
|
||||||
application and its combined native distribution are GPL-3.0-or-later; see
|
iOS applications and their combined native distributions are GPL-3.0-or-later; see
|
||||||
[`android/LICENSE.md`](android/LICENSE.md) and the bundled-component notices.
|
[`android/LICENSE.md`](android/LICENSE.md), [`ios/LICENSE.md`](ios/LICENSE.md) and
|
||||||
|
the bundled-component notices.
|
||||||
The desktop application embeds the Inter typeface, under the SIL Open Font
|
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)),
|
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.
|
and the Phosphor icon font, under MIT.
|
||||||
@@ -35,7 +35,6 @@
|
|||||||
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"
|
||||||
android:supportsPictureInPicture="true"
|
|
||||||
android:resizeableActivity="true">
|
android:resizeableActivity="true">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|||||||
@@ -1,53 +1,37 @@
|
|||||||
package io.narl.protonstream
|
package io.narl.protonstream
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.app.PendingIntent
|
|
||||||
import android.app.PictureInPictureParams
|
|
||||||
import android.app.RemoteAction
|
|
||||||
import android.content.BroadcastReceiver
|
|
||||||
import android.content.ComponentName
|
import android.content.ComponentName
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.IntentFilter
|
|
||||||
import android.content.ServiceConnection
|
import android.content.ServiceConnection
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.content.res.Configuration
|
|
||||||
import android.graphics.Rect
|
|
||||||
import android.graphics.drawable.Icon
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
import android.util.Rational
|
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.snapshotFlow
|
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import androidx.core.graphics.drawable.toDrawable
|
import androidx.core.graphics.drawable.toDrawable
|
||||||
import androidx.core.view.WindowCompat
|
import androidx.core.view.WindowCompat
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import io.narl.protonstream.native.NativeRuntime
|
import io.narl.protonstream.native.NativeRuntime
|
||||||
import io.narl.protonstream.playback.MpvPlaybackState
|
|
||||||
import io.narl.protonstream.playback.NativeMpvHost
|
import io.narl.protonstream.playback.NativeMpvHost
|
||||||
import io.narl.protonstream.playback.PlaybackService
|
import io.narl.protonstream.playback.PlaybackService
|
||||||
import io.narl.protonstream.settings.SettingsStore
|
import io.narl.protonstream.settings.SettingsStore
|
||||||
|
import io.narl.protonstream.sync.WatchSyncWorker
|
||||||
import io.narl.protonstream.ui.ProtonStreamApp
|
import io.narl.protonstream.ui.ProtonStreamApp
|
||||||
import io.narl.protonstream.ui.theme.AppearanceState
|
import io.narl.protonstream.ui.theme.AppearanceState
|
||||||
import io.narl.protonstream.ui.theme.ProtonStreamTheme
|
import io.narl.protonstream.ui.theme.ProtonStreamTheme
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.filterNotNull
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
|
||||||
import kotlinx.coroutines.flow.flowOf
|
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import uniffi.pstr_android.PaletteRecord
|
import uniffi.pstr_android.PaletteRecord
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
private val playerHost = mutableStateOf<NativeMpvHost?>(null)
|
private val playerHost = mutableStateOf<NativeMpvHost?>(null)
|
||||||
private val inPictureInPicture = mutableStateOf(false)
|
|
||||||
|
|
||||||
/** A share link handed over by a browser or the share sheet, until the Add form takes it. */
|
/** A share link handed over by a browser or the share sheet, until the Add form takes it. */
|
||||||
private val incomingShareLink = mutableStateOf<String?>(null)
|
private val incomingShareLink = mutableStateOf<String?>(null)
|
||||||
@@ -68,30 +52,10 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The transport behind the buttons Android draws over the PiP window.
|
|
||||||
*
|
|
||||||
* A broadcast rather than a service intent: PiP is one of the states in
|
|
||||||
* which a background service start is refused, and the receiver is
|
|
||||||
* registered for as long as the activity that owns the window exists.
|
|
||||||
*/
|
|
||||||
private val pictureInPictureControls = object : BroadcastReceiver() {
|
|
||||||
override fun onReceive(context: Context?, intent: Intent?) {
|
|
||||||
val host = playerHost.value ?: return
|
|
||||||
when (intent?.getStringExtra(EXTRA_CONTROL)) {
|
|
||||||
CONTROL_PLAY -> host.setPaused(false)
|
|
||||||
CONTROL_PAUSE -> host.setPaused(true)
|
|
||||||
CONTROL_NEXT -> host.onSkipNext?.invoke()
|
|
||||||
CONTROL_PREVIOUS -> host.onSkipPrevious?.invoke()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val notificationPermission = registerForActivityResult(
|
private val notificationPermission = registerForActivityResult(
|
||||||
ActivityResultContracts.RequestPermission(),
|
ActivityResultContracts.RequestPermission(),
|
||||||
) { /* Downloads remain usable; Android suppresses their notifications if denied. */ }
|
) { /* Downloads remain usable; Android suppresses their notifications if denied. */ }
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
// Before anything draws. The palette decides the window's own
|
// Before anything draws. The palette decides the window's own
|
||||||
@@ -115,34 +79,16 @@ class MainActivity : ComponentActivity() {
|
|||||||
playbackConnection,
|
playbackConnection,
|
||||||
Context.BIND_AUTO_CREATE,
|
Context.BIND_AUTO_CREATE,
|
||||||
)
|
)
|
||||||
ContextCompat.registerReceiver(
|
|
||||||
this,
|
|
||||||
pictureInPictureControls,
|
|
||||||
IntentFilter(ACTION_PIP_CONTROL),
|
|
||||||
ContextCompat.RECEIVER_NOT_EXPORTED,
|
|
||||||
)
|
|
||||||
if (
|
if (
|
||||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||||
checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
|
checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
|
||||||
) {
|
) {
|
||||||
notificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
|
notificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||||
}
|
}
|
||||||
// Auto-enter has to be armed *before* the gesture that would use it:
|
|
||||||
// building the params inside onUserLeaveHint is too late for the swipe
|
|
||||||
// home that gesture navigation sends, which never calls it. Re-register
|
|
||||||
// whenever the picture's shape or the transport's does.
|
|
||||||
lifecycleScope.launch {
|
|
||||||
snapshotFlow { playerHost.value }
|
|
||||||
.flatMapLatest { host -> host?.state ?: flowOf(null) }
|
|
||||||
.map { state -> PictureInPictureShape.of(state) }
|
|
||||||
.distinctUntilChanged()
|
|
||||||
.collect { shape -> runCatching { setPictureInPictureParams(shape.params()) } }
|
|
||||||
}
|
|
||||||
setContent {
|
setContent {
|
||||||
ProtonStreamTheme {
|
ProtonStreamTheme {
|
||||||
ProtonStreamApp(
|
ProtonStreamApp(
|
||||||
playerHost.value,
|
playerHost.value,
|
||||||
inPictureInPicture.value,
|
|
||||||
shareLink = incomingShareLink.value,
|
shareLink = incomingShareLink.value,
|
||||||
onShareLinkTaken = { incomingShareLink.value = null },
|
onShareLinkTaken = { incomingShareLink.value = null },
|
||||||
)
|
)
|
||||||
@@ -177,27 +123,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
intent.shareLink()?.let { incomingShareLink.value = it }
|
intent.shareLink()?.let { incomingShareLink.value = it }
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onUserLeaveHint() {
|
|
||||||
super.onUserLeaveHint()
|
|
||||||
// The backstop for launchers that still send this instead of arming
|
|
||||||
// auto-enter. Entering twice is not an error; not entering at all is.
|
|
||||||
if (playerHost.value?.hasActivePlayback == true && !isInPictureInPictureMode) {
|
|
||||||
runCatching {
|
|
||||||
enterPictureInPictureMode(PictureInPictureShape.of(playerHost.value?.state?.value).params())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, newConfig: Configuration) {
|
|
||||||
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
|
|
||||||
// The window is now a thumbnail with its own controls. Anything the
|
|
||||||
// player would draw over it — the transport, the title bar, the skip
|
|
||||||
// offer — is unreadable at that size and covers the picture.
|
|
||||||
inPictureInPicture.value = isInPictureInPictureMode
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
runCatching { unregisterReceiver(pictureInPictureControls) }
|
|
||||||
if (bound) {
|
if (bound) {
|
||||||
unbindService(playbackConnection)
|
unbindService(playbackConnection)
|
||||||
bound = false
|
bound = false
|
||||||
@@ -207,89 +133,14 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
override fun onStop() {
|
override fun onStop() {
|
||||||
super.onStop()
|
super.onStop()
|
||||||
|
// Where the viewer stopped is what their other devices want next, and
|
||||||
|
// a process in the background may not live to the next timed sync.
|
||||||
|
if (!isChangingConfigurations) WatchSyncWorker.enqueue(this)
|
||||||
if (
|
if (
|
||||||
!isChangingConfigurations &&
|
!isChangingConfigurations &&
|
||||||
!isInPictureInPictureMode &&
|
|
||||||
!SettingsStore(this).backgroundAudio
|
!SettingsStore(this).backgroundAudio
|
||||||
) {
|
) {
|
||||||
playerHost.value?.stop()
|
playerHost.value?.stop()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Everything the PiP window's shape depends on, and nothing that polls. */
|
|
||||||
private data class PictureInPictureShape(
|
|
||||||
val active: Boolean,
|
|
||||||
val paused: Boolean,
|
|
||||||
val width: Int,
|
|
||||||
val height: Int,
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
fun of(state: MpvPlaybackState?): PictureInPictureShape = PictureInPictureShape(
|
|
||||||
active = state != null && state.duration > 0.0 && !state.ended,
|
|
||||||
paused = state?.paused != false,
|
|
||||||
width = state?.videoWidth?.toInt() ?: 0,
|
|
||||||
height = state?.videoHeight?.toInt() ?: 0,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun PictureInPictureShape.params(): PictureInPictureParams =
|
|
||||||
PictureInPictureParams.Builder()
|
|
||||||
.setAspectRatio(aspectRatio(width, height))
|
|
||||||
.setAutoEnterEnabled(active)
|
|
||||||
.setSeamlessResizeEnabled(false)
|
|
||||||
// The player owns the whole window, so the picture's rectangle is
|
|
||||||
// the window's. Without it the transition into PiP cross-fades from
|
|
||||||
// a screenshot instead of shrinking the video that is already there.
|
|
||||||
.setSourceRectHint(windowBounds())
|
|
||||||
.setActions(
|
|
||||||
listOf(
|
|
||||||
remoteAction(android.R.drawable.ic_media_previous, "Previous episode", CONTROL_PREVIOUS, 11),
|
|
||||||
if (paused) remoteAction(android.R.drawable.ic_media_play, "Play", CONTROL_PLAY, 12)
|
|
||||||
else remoteAction(android.R.drawable.ic_media_pause, "Pause", CONTROL_PAUSE, 13),
|
|
||||||
remoteAction(android.R.drawable.ic_media_next, "Next episode", CONTROL_NEXT, 14),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
private fun windowBounds(): Rect = window.decorView.let { Rect(0, 0, it.width, it.height) }
|
|
||||||
|
|
||||||
private fun remoteAction(icon: Int, label: String, control: String, requestCode: Int): RemoteAction =
|
|
||||||
RemoteAction(
|
|
||||||
Icon.createWithResource(this, icon),
|
|
||||||
label,
|
|
||||||
label,
|
|
||||||
PendingIntent.getBroadcast(
|
|
||||||
this,
|
|
||||||
requestCode,
|
|
||||||
Intent(ACTION_PIP_CONTROL).setPackage(packageName).putExtra(EXTRA_CONTROL, control),
|
|
||||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
const val ACTION_PIP_CONTROL = "io.narl.protonstream.PIP_CONTROL"
|
|
||||||
const val EXTRA_CONTROL = "control"
|
|
||||||
const val CONTROL_PLAY = "play"
|
|
||||||
const val CONTROL_PAUSE = "pause"
|
|
||||||
const val CONTROL_NEXT = "next"
|
|
||||||
const val CONTROL_PREVIOUS = "previous"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Android refuses a window narrower than 1:2.39 or taller than 2.39:1,
|
|
||||||
* and refuses the request outright rather than clamping it — so a
|
|
||||||
* 2.40:1 scope release would take PiP away entirely.
|
|
||||||
*/
|
|
||||||
fun aspectRatio(width: Int, height: Int): Rational {
|
|
||||||
if (width <= 0 || height <= 0) return Rational(16, 9)
|
|
||||||
val ratio = width.toDouble() / height
|
|
||||||
return when {
|
|
||||||
ratio > MAX_RATIO -> Rational(239, 100)
|
|
||||||
ratio < 1 / MAX_RATIO -> Rational(100, 239)
|
|
||||||
else -> Rational(width, height)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const val MAX_RATIO = 2.39
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -13,6 +13,8 @@ data class RetainedDownload(
|
|||||||
val total: Long = 0,
|
val total: Long = 0,
|
||||||
val status: String = STATUS_QUEUED,
|
val status: String = STATUS_QUEUED,
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
|
/** Recent transfer rate while running; 0 when unknown or not running. */
|
||||||
|
val bytesPerSecond: Long = 0,
|
||||||
) {
|
) {
|
||||||
val key: String get() = "$shareId\u001f$linkId"
|
val key: String get() = "$shareId\u001f$linkId"
|
||||||
|
|
||||||
@@ -99,6 +101,7 @@ class DownloadStateStore(context: Context) {
|
|||||||
.put("total", record.total)
|
.put("total", record.total)
|
||||||
.put("status", record.status)
|
.put("status", record.status)
|
||||||
.put("error", record.error)
|
.put("error", record.error)
|
||||||
|
.put("rate", record.bytesPerSecond)
|
||||||
.toString()
|
.toString()
|
||||||
|
|
||||||
private fun decode(encoded: String): RetainedDownload {
|
private fun decode(encoded: String): RetainedDownload {
|
||||||
@@ -112,6 +115,7 @@ class DownloadStateStore(context: Context) {
|
|||||||
total = json.optLong("total"),
|
total = json.optLong("total"),
|
||||||
status = json.optString("status", RetainedDownload.STATUS_QUEUED),
|
status = json.optString("status", RetainedDownload.STATUS_QUEUED),
|
||||||
error = json.optString("error").takeIf(String::isNotBlank),
|
error = json.optString("error").takeIf(String::isNotBlank),
|
||||||
|
bytesPerSecond = json.optLong("rate"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,20 +25,24 @@ class OfflineDownloadWorker(
|
|||||||
) : CoroutineWorker(appContext, parameters) {
|
) : CoroutineWorker(appContext, parameters) {
|
||||||
override suspend fun doWork(): Result {
|
override suspend fun doWork(): Result {
|
||||||
createNotificationChannel()
|
createNotificationChannel()
|
||||||
setForeground(createForegroundInfo(0))
|
setForeground(createForegroundInfo(null))
|
||||||
val shareId = inputData.getString(KEY_SHARE_ID) ?: return Result.failure(error("missing share"))
|
val shareId = inputData.getString(KEY_SHARE_ID) ?: return Result.failure(error("missing share"))
|
||||||
val volumeId = inputData.getString(KEY_VOLUME_ID) ?: return Result.failure(error("missing volume"))
|
val volumeId = inputData.getString(KEY_VOLUME_ID) ?: return Result.failure(error("missing volume"))
|
||||||
val linkId = inputData.getString(KEY_LINK_ID) ?: return Result.failure(error("missing link"))
|
val linkId = inputData.getString(KEY_LINK_ID) ?: return Result.failure(error("missing link"))
|
||||||
val label = inputData.getString(KEY_LABEL) ?: linkId
|
val label = inputData.getString(KEY_LABEL) ?: linkId
|
||||||
val store = DownloadStateStore(applicationContext)
|
val store = DownloadStateStore(applicationContext)
|
||||||
|
// Queued, not running: the engine lets two downloads transfer at once
|
||||||
|
// and holds the rest back, and only the first progress report means
|
||||||
|
// this one has a slot.
|
||||||
var retained = store.get(shareId, linkId)
|
var retained = store.get(shareId, linkId)
|
||||||
?: RetainedDownload(shareId, volumeId, linkId, label)
|
?: RetainedDownload(shareId, volumeId, linkId, label)
|
||||||
retained = retained.copy(status = RetainedDownload.STATUS_RUNNING, error = null)
|
retained = retained.copy(status = RetainedDownload.STATUS_QUEUED, error = null, bytesPerSecond = 0)
|
||||||
store.put(retained)
|
store.put(retained)
|
||||||
|
|
||||||
val observer = object : DownloadObserver {
|
val observer = object : DownloadObserver {
|
||||||
private var reportedAt = 0L
|
private var reportedAt = 0L
|
||||||
private var reportedPercent = -1
|
private var reportedPercent = -1
|
||||||
|
private val rate = TransferRate()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Called once per 4 MiB block — a dozen times a second on a fast
|
* Called once per 4 MiB block — a dozen times a second on a fast
|
||||||
@@ -51,6 +55,7 @@ class OfflineDownloadWorker(
|
|||||||
val percent = if (total == 0UL) 0 else ((downloaded * 100UL) / total).toInt()
|
val percent = if (total == 0UL) 0 else ((downloaded * 100UL) / total).toInt()
|
||||||
val now = SystemClock.elapsedRealtime()
|
val now = SystemClock.elapsedRealtime()
|
||||||
val complete = total > 0UL && downloaded >= total
|
val complete = total > 0UL && downloaded >= total
|
||||||
|
val bytesPerSecond = rate.sample(downloaded.toLong(), now)
|
||||||
if (!complete && percent == reportedPercent && now - reportedAt < PROGRESS_INTERVAL_MS) {
|
if (!complete && percent == reportedPercent && now - reportedAt < PROGRESS_INTERVAL_MS) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -66,6 +71,7 @@ class OfflineDownloadWorker(
|
|||||||
current.copy(
|
current.copy(
|
||||||
downloaded = downloaded.toLong(),
|
downloaded = downloaded.toLong(),
|
||||||
total = total.toLong(),
|
total = total.toLong(),
|
||||||
|
bytesPerSecond = bytesPerSecond,
|
||||||
status = when (current.status) {
|
status = when (current.status) {
|
||||||
RetainedDownload.STATUS_PAUSED,
|
RetainedDownload.STATUS_PAUSED,
|
||||||
RetainedDownload.STATUS_CANCELLED,
|
RetainedDownload.STATUS_CANCELLED,
|
||||||
@@ -114,11 +120,14 @@ class OfflineDownloadWorker(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createForegroundInfo(progress: Int): ForegroundInfo {
|
/** [progress] is a percentage, or null while waiting for a download slot. */
|
||||||
|
private fun createForegroundInfo(progress: Int?): ForegroundInfo {
|
||||||
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
||||||
.setSmallIcon(io.narl.protonstream.R.drawable.ic_launcher_foreground)
|
.setSmallIcon(io.narl.protonstream.R.drawable.ic_launcher_foreground)
|
||||||
.setContentTitle("Making episode available offline")
|
.setContentTitle(
|
||||||
.setProgress(100, progress, progress == 0)
|
if (progress == null) "Waiting to download" else "Making episode available offline",
|
||||||
|
)
|
||||||
|
.setProgress(100, progress ?: 0, progress == null || progress == 0)
|
||||||
.setOngoing(true)
|
.setOngoing(true)
|
||||||
.build()
|
.build()
|
||||||
return ForegroundInfo(
|
return ForegroundInfo(
|
||||||
@@ -146,5 +155,35 @@ class OfflineDownloadWorker(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bytes per second over roughly the last few seconds.
|
||||||
|
*
|
||||||
|
* Progress arrives once per 4 MiB block, so on a slow link the bar moves in
|
||||||
|
* steps seconds apart; a rate beside it is what tells a slow download from a
|
||||||
|
* stuck one. Smoothed, because one block that took a little longer than the
|
||||||
|
* last would otherwise halve the figure for a moment.
|
||||||
|
*/
|
||||||
|
internal class TransferRate {
|
||||||
|
private var lastBytes = -1L
|
||||||
|
private var lastAt = 0L
|
||||||
|
private var smoothed = 0.0
|
||||||
|
|
||||||
|
/** Record [bytes] transferred at [now] (milliseconds) and return the rate. */
|
||||||
|
fun sample(bytes: Long, now: Long): Long {
|
||||||
|
if (lastBytes < 0 || bytes < lastBytes) {
|
||||||
|
lastBytes = bytes
|
||||||
|
lastAt = now
|
||||||
|
return smoothed.toLong()
|
||||||
|
}
|
||||||
|
val elapsed = now - lastAt
|
||||||
|
if (elapsed <= 0) return smoothed.toLong()
|
||||||
|
val instant = (bytes - lastBytes) * 1000.0 / elapsed
|
||||||
|
smoothed = if (smoothed > 0.0) 0.7 * smoothed + 0.3 * instant else instant
|
||||||
|
lastBytes = bytes
|
||||||
|
lastAt = now
|
||||||
|
return smoothed.toLong()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
internal fun stableNotificationId(workId: UUID): Int =
|
internal fun stableNotificationId(workId: UUID): Int =
|
||||||
(workId.hashCode() and Int.MAX_VALUE).coerceAtLeast(1)
|
(workId.hashCode() and Int.MAX_VALUE).coerceAtLeast(1)
|
||||||
@@ -85,9 +85,9 @@ data class MpvPlaybackState(
|
|||||||
/**
|
/**
|
||||||
* What is on screen, for the surfaces that are not the player.
|
* What is on screen, for the surfaces that are not the player.
|
||||||
*
|
*
|
||||||
* The media session, its notification and Picture-in-Picture all have to name
|
* The media session and its notification have to name the episode, and
|
||||||
* the episode, and none of them can reach the composition that knows it. The
|
* neither can reach the composition that knows it. The player publishes this
|
||||||
* player publishes this once per episode and they read it.
|
* once per episode and they read it.
|
||||||
*/
|
*/
|
||||||
data class NowPlaying(
|
data class NowPlaying(
|
||||||
val media: String,
|
val media: String,
|
||||||
@@ -134,8 +134,8 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
|
|||||||
/**
|
/**
|
||||||
* Where a transport outside the player sends previous/next.
|
* Where a transport outside the player sends previous/next.
|
||||||
*
|
*
|
||||||
* The player owns which episode is open, so the notification, the lock
|
* The player owns which episode is open, so the notification and the lock
|
||||||
* screen and Picture-in-Picture cannot walk the playlist themselves. Both
|
* screen cannot walk the playlist themselves. Both
|
||||||
* run on the main thread, which is where the player's state lives.
|
* run on the main thread, which is where the player's state lives.
|
||||||
*/
|
*/
|
||||||
var onSkipPrevious: (() -> Unit)? = null
|
var onSkipPrevious: (() -> Unit)? = null
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ import kotlinx.coroutines.flow.first
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withTimeoutOrNull
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
|
|
||||||
/** Owns libmpv so audio survives Activity recreation, rotation and PiP. */
|
/** Owns libmpv so audio survives Activity recreation and rotation. */
|
||||||
class PlaybackService : Service() {
|
class PlaybackService : Service() {
|
||||||
inner class PlaybackBinder : Binder() {
|
inner class PlaybackBinder : Binder() {
|
||||||
val host: NativeMpvHost? get() = this@PlaybackService.host
|
val host: NativeMpvHost? get() = this@PlaybackService.host
|
||||||
|
|||||||
@@ -131,18 +131,14 @@ private val VOLUME_SLIDER_MIN_WIDTH = 600.dp
|
|||||||
/**
|
/**
|
||||||
* The player, as its own screen on the activity's own window.
|
* The player, as its own screen on the activity's own window.
|
||||||
*
|
*
|
||||||
* Not a dialog: a dialog window has its own insets, cannot be told to draw into
|
* Not a dialog: a dialog window has its own insets and cannot be told to draw
|
||||||
* the display cutout, and hands Picture-in-Picture the wrong window. Owning the
|
* into the display cutout. Owning the activity window is what makes
|
||||||
* activity window is what makes "fullscreen" mean the whole panel, notch
|
* "fullscreen" mean the whole panel, notch included.
|
||||||
* included.
|
|
||||||
*
|
*
|
||||||
* [episodes] is the title's episodes in display order and [index] the one that is
|
* [episodes] is the title's episodes in display order and [index] the one that is
|
||||||
* open, which is what previous/next and autoplay walk. The index is hoisted so
|
* open, which is what previous/next and autoplay walk. The index is hoisted so
|
||||||
* that it survives process recreation with the rest of the navigation state —
|
* that it survives process recreation with the rest of the navigation state —
|
||||||
* this screen is destroyed and rebuilt by a dark-mode toggle like any other.
|
* this screen is destroyed and rebuilt by a dark-mode toggle like any other.
|
||||||
*
|
|
||||||
* In [inPictureInPicture] the window is a thumbnail with the system's own
|
|
||||||
* controls over it, so this screen draws the picture and nothing else.
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun PlayerScreen(
|
fun PlayerScreen(
|
||||||
@@ -150,7 +146,6 @@ fun PlayerScreen(
|
|||||||
episodes: List<EpisodeRecord>,
|
episodes: List<EpisodeRecord>,
|
||||||
index: Int,
|
index: Int,
|
||||||
host: LibmpvHost?,
|
host: LibmpvHost?,
|
||||||
inPictureInPicture: Boolean,
|
|
||||||
minimized: Boolean,
|
minimized: Boolean,
|
||||||
onIndexChange: (Int) -> Unit,
|
onIndexChange: (Int) -> Unit,
|
||||||
onSaveProgress: (EpisodeRecord, Double, Double, Boolean) -> Unit,
|
onSaveProgress: (EpisodeRecord, Double, Double, Boolean) -> Unit,
|
||||||
@@ -294,9 +289,8 @@ fun PlayerScreen(
|
|||||||
onIndexChange(target)
|
onIndexChange(target)
|
||||||
}
|
}
|
||||||
|
|
||||||
// What the media notification, the lock screen and Picture-in-Picture show.
|
// What the media notification and the lock screen show. Neither can reach
|
||||||
// None of them can reach this composition, and all three have to name the
|
// this composition, and both have to name the episode rather than the app.
|
||||||
// episode rather than the app.
|
|
||||||
LaunchedEffect(key, index, episodes.size, title.key) {
|
LaunchedEffect(key, index, episodes.size, title.key) {
|
||||||
nativeHost.publish(
|
nativeHost.publish(
|
||||||
NowPlaying(
|
NowPlaying(
|
||||||
@@ -339,7 +333,12 @@ fun PlayerScreen(
|
|||||||
val problem by nativeHost.problem.collectAsState()
|
val problem by nativeHost.problem.collectAsState()
|
||||||
LaunchedEffect(problem) { problem?.let { playbackError = it } }
|
LaunchedEffect(problem) { problem?.let { playbackError = it } }
|
||||||
|
|
||||||
val offer = remember(chapters, key, (state.position * 4).roundToInt()) {
|
// Whether mpv's readings are this episode's. Not while the next one opens:
|
||||||
|
// the last one's credits play on until it is loaded, and read against the
|
||||||
|
// new index they offered the episode after the next as up next.
|
||||||
|
val playingThis = state.media == key
|
||||||
|
val offer = remember(chapters, key, playingThis, (state.position * 4).roundToInt()) {
|
||||||
|
if (!playingThis) return@remember null
|
||||||
runCatching { skipOffer(chapters, state.position) }.getOrNull()
|
runCatching { skipOffer(chapters, state.position) }.getOrNull()
|
||||||
}
|
}
|
||||||
// Auto-skip is offered once per chapter: seeking back into an opening on
|
// Auto-skip is offered once per chapter: seeking back into an opening on
|
||||||
@@ -359,12 +358,6 @@ fun PlayerScreen(
|
|||||||
showControls = false
|
showControls = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Everything this screen draws over the picture is sized for a phone panel
|
|
||||||
// and unreadable in a thumbnail, and Android supplies its own transport
|
|
||||||
// there. Leaving PiP must find the controls up rather than mid-timeout.
|
|
||||||
val overlays = !inPictureInPicture
|
|
||||||
LaunchedEffect(inPictureInPicture) { if (!inPictureInPicture) showControls = true }
|
|
||||||
|
|
||||||
if (minimized) return
|
if (minimized) return
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
@@ -374,7 +367,6 @@ fun PlayerScreen(
|
|||||||
.clickable(
|
.clickable(
|
||||||
interactionSource = remember { MutableInteractionSource() },
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
indication = null,
|
indication = null,
|
||||||
enabled = overlays,
|
|
||||||
onClick = { showControls = !showControls },
|
onClick = { showControls = !showControls },
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
@@ -393,7 +385,7 @@ fun PlayerScreen(
|
|||||||
)
|
)
|
||||||
|
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = overlays && showControls,
|
visible = showControls,
|
||||||
modifier = Modifier.align(Alignment.TopStart),
|
modifier = Modifier.align(Alignment.TopStart),
|
||||||
enter = fadeIn(),
|
enter = fadeIn(),
|
||||||
exit = fadeOut(),
|
exit = fadeOut(),
|
||||||
@@ -450,7 +442,7 @@ fun PlayerScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = overlays && showControls,
|
visible = showControls,
|
||||||
modifier = Modifier.align(Alignment.BottomCenter),
|
modifier = Modifier.align(Alignment.BottomCenter),
|
||||||
enter = fadeIn(),
|
enter = fadeIn(),
|
||||||
exit = fadeOut(),
|
exit = fadeOut(),
|
||||||
@@ -476,7 +468,7 @@ fun PlayerScreen(
|
|||||||
// Drawn whether or not the rest of the controls are up: an opening lasts
|
// Drawn whether or not the rest of the controls are up: an opening lasts
|
||||||
// ninety seconds, and a button you have to wake with a tap is one nobody
|
// ninety seconds, and a button you have to wake with a tap is one nobody
|
||||||
// reaches in time.
|
// reaches in time.
|
||||||
offer?.takeIf { overlays && !autoSkip }?.let { skip ->
|
offer?.takeIf { !autoSkip }?.let { skip ->
|
||||||
TonalButton(
|
TonalButton(
|
||||||
onClick = { nativeHost.seek(skip.target) },
|
onClick = { nativeHost.seek(skip.target) },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -488,7 +480,7 @@ fun PlayerScreen(
|
|||||||
|
|
||||||
UpNextCard(
|
UpNextCard(
|
||||||
next = next,
|
next = next,
|
||||||
visible = overlays && autoplay && !state.paused &&
|
visible = autoplay && !state.paused && playingThis &&
|
||||||
chapters.creditsStart?.let { state.position >= it } == true,
|
chapters.creditsStart?.let { state.position >= it } == true,
|
||||||
onPlayNow = { advance() },
|
onPlayNow = { advance() },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package io.narl.protonstream.sync
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.work.BackoffPolicy
|
||||||
|
import androidx.work.Constraints
|
||||||
|
import androidx.work.CoroutineWorker
|
||||||
|
import androidx.work.ExistingWorkPolicy
|
||||||
|
import androidx.work.NetworkType
|
||||||
|
import androidx.work.OneTimeWorkRequestBuilder
|
||||||
|
import androidx.work.WorkManager
|
||||||
|
import androidx.work.WorkerParameters
|
||||||
|
import io.narl.protonstream.native.NativeRuntime
|
||||||
|
import uniffi.pstr_android.AccountState
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One watch-history sync after the app leaves the screen.
|
||||||
|
*
|
||||||
|
* The model's timer only runs while the app is alive, and Android is free to
|
||||||
|
* kill a process in the background without notice — so the position the
|
||||||
|
* viewer stopped at would wait for the next launch to reach their other
|
||||||
|
* devices. Handed to WorkManager instead, it goes as soon as there is a
|
||||||
|
* network, even if the process is gone by then.
|
||||||
|
*/
|
||||||
|
class WatchSyncWorker(
|
||||||
|
appContext: Context,
|
||||||
|
parameters: WorkerParameters,
|
||||||
|
) : CoroutineWorker(appContext, parameters) {
|
||||||
|
override suspend fun doWork(): Result {
|
||||||
|
val engine = NativeRuntime.engine()
|
||||||
|
return runCatching { engine.syncWatchHistory() }.fold(
|
||||||
|
onSuccess = { Result.success() },
|
||||||
|
onFailure = {
|
||||||
|
// Signed out by the engine — the session ended — is not
|
||||||
|
// something a retry fixes; a dropped connection is.
|
||||||
|
val signedOut = runCatching { engine.accountState() }.getOrNull() == AccountState.SignedOut
|
||||||
|
if (signedOut || runAttemptCount >= MAX_ATTEMPTS) Result.failure() else Result.retry()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val WORK_NAME = "watch-history-sync"
|
||||||
|
private const val MAX_ATTEMPTS = 3
|
||||||
|
|
||||||
|
/** Replacing: one pending sync covers every position saved before it runs. */
|
||||||
|
fun enqueue(context: Context) {
|
||||||
|
val request = OneTimeWorkRequestBuilder<WatchSyncWorker>()
|
||||||
|
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
|
||||||
|
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
WorkManager.getInstance(context.applicationContext)
|
||||||
|
.enqueueUniqueWork(WORK_NAME, ExistingWorkPolicy.REPLACE, request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,507 @@
|
|||||||
|
package io.narl.protonstream.ui
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.webkit.JavascriptInterface
|
||||||
|
import android.webkit.WebResourceRequest
|
||||||
|
import android.webkit.WebView
|
||||||
|
import android.webkit.WebViewClient
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.AccountCircle
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.Computer
|
||||||
|
import androidx.compose.material.icons.filled.Folder
|
||||||
|
import androidx.compose.material.icons.filled.FolderShared
|
||||||
|
import androidx.compose.material.icons.filled.InsertDriveFile
|
||||||
|
import androidx.compose.material.icons.filled.Logout
|
||||||
|
import androidx.compose.material.icons.filled.Movie
|
||||||
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
|
import androidx.compose.material.icons.filled.Storage
|
||||||
|
import androidx.compose.material.icons.filled.Sync
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.ListItem
|
||||||
|
import androidx.compose.material3.ListItemDefaults
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
|
import androidx.compose.ui.window.DialogProperties
|
||||||
|
import androidx.compose.ui.window.SecureFlagPolicy
|
||||||
|
import io.narl.protonstream.native.NativeRuntime
|
||||||
|
import io.narl.protonstream.ui.theme.AccentButton
|
||||||
|
import io.narl.protonstream.ui.theme.TonalButton
|
||||||
|
import uniffi.pstr_android.AccountState
|
||||||
|
import uniffi.pstr_android.DriveEntryRecord
|
||||||
|
import uniffi.pstr_android.DrivePlaceRecord
|
||||||
|
import uniffi.pstr_android.PlaceType
|
||||||
|
import uniffi.pstr_android.verificationToken
|
||||||
|
|
||||||
|
/** What the account card and its dialogs can ask the model for. */
|
||||||
|
internal data class AccountActions(
|
||||||
|
val signIn: (String, String, String?) -> Unit,
|
||||||
|
val submitSecondFactor: (String) -> Unit,
|
||||||
|
val submitMailboxPassword: (String) -> Unit,
|
||||||
|
val cancelSignIn: () -> Unit,
|
||||||
|
val dismissVerification: () -> Unit,
|
||||||
|
val signOut: () -> Unit,
|
||||||
|
val syncNow: () -> Unit,
|
||||||
|
val addFolder: (String, String, String) -> Unit,
|
||||||
|
val onError: (Throwable) -> Unit,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Proton account, at the top of the shares page: why one would sign in,
|
||||||
|
* or who is signed in and how watch-history sync is doing.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun AccountCard(
|
||||||
|
account: AccountUiState,
|
||||||
|
actions: AccountActions,
|
||||||
|
onSignIn: () -> Unit,
|
||||||
|
onBrowse: () -> Unit,
|
||||||
|
) {
|
||||||
|
val state = account.state ?: return
|
||||||
|
Card(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) {
|
||||||
|
if (state is AccountState.SignedIn) {
|
||||||
|
var menu by remember { mutableStateOf(false) }
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text(state.username, fontWeight = FontWeight.SemiBold) },
|
||||||
|
supportingContent = { Text(syncLine(account, System.currentTimeMillis())) },
|
||||||
|
leadingContent = {
|
||||||
|
Icon(Icons.Default.AccountCircle, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
|
||||||
|
},
|
||||||
|
trailingContent = {
|
||||||
|
Box {
|
||||||
|
IconButton(onClick = { menu = true }) {
|
||||||
|
Icon(Icons.Default.MoreVert, contentDescription = "Account actions")
|
||||||
|
}
|
||||||
|
DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Sync now") },
|
||||||
|
leadingIcon = { Icon(Icons.Default.Sync, contentDescription = null) },
|
||||||
|
onClick = { menu = false; actions.syncNow() },
|
||||||
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Sign out") },
|
||||||
|
leadingIcon = { Icon(Icons.Default.Logout, contentDescription = null) },
|
||||||
|
onClick = { menu = false; actions.signOut() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
|
)
|
||||||
|
TonalButton(
|
||||||
|
onClick = onBrowse,
|
||||||
|
modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 16.dp),
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Folder, contentDescription = null)
|
||||||
|
Text("Add from your Drive", Modifier.padding(start = 8.dp))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Text("Proton account", style = MaterialTheme.typography.titleMedium)
|
||||||
|
Text(
|
||||||
|
"Optional. Signed in, your shares, settings and watch history are kept in your own " +
|
||||||
|
"Drive, so every device shows the same library and resumes where another left off, " +
|
||||||
|
"and folders of your Drive can join the library.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
TonalButton(onClick = onSignIn) { Text("Sign in") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Synced 3 min ago · 2 from other devices", or why it did not. */
|
||||||
|
internal fun syncLine(account: AccountUiState, now: Long): String {
|
||||||
|
account.syncError?.let { return "Did not sync: $it" }
|
||||||
|
val at = account.syncedAt ?: return "Library, settings and watch history sync through your Drive"
|
||||||
|
val minutes = ((now - at).coerceAtLeast(0) / 60_000).toInt()
|
||||||
|
val ago = when {
|
||||||
|
minutes < 1 -> "just now"
|
||||||
|
minutes < 60 -> "$minutes min ago"
|
||||||
|
else -> "${minutes / 60} h ago"
|
||||||
|
}
|
||||||
|
val applied = if (account.applied > 0) " · ${account.applied} from other devices" else ""
|
||||||
|
return "Synced $ago$applied"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signing in, one step at a time: address and password, then a second-factor
|
||||||
|
* code or the mailbox password if the account has them, with Proton's CAPTCHA
|
||||||
|
* over the top when it asks for one.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun SignInDialog(account: AccountUiState, actions: AccountActions, onDismiss: () -> Unit) {
|
||||||
|
var username by remember { mutableStateOf("") }
|
||||||
|
var password by remember { mutableStateOf("") }
|
||||||
|
var code by remember { mutableStateOf("") }
|
||||||
|
var mailbox by remember { mutableStateOf("") }
|
||||||
|
val state = account.state
|
||||||
|
LaunchedEffect(state) {
|
||||||
|
if (state is AccountState.SignedIn) onDismiss()
|
||||||
|
}
|
||||||
|
val dismiss = {
|
||||||
|
if (state == AccountState.SecondFactor || state == AccountState.MailboxPassword) actions.cancelSignIn()
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = dismiss,
|
||||||
|
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
|
||||||
|
title = { Text("Sign in to Proton") },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
when (state) {
|
||||||
|
AccountState.SecondFactor -> {
|
||||||
|
OutlinedTextField(
|
||||||
|
code,
|
||||||
|
{ code = it },
|
||||||
|
label = { Text("Code from your authenticator app") },
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
|
||||||
|
)
|
||||||
|
Text(SECURITY_KEY_NOTE, style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
AccountState.MailboxPassword -> {
|
||||||
|
Text(
|
||||||
|
"This account has a separate mailbox password. It unlocks your files.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
PasswordField(mailbox, { mailbox = it }, "Mailbox password")
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
OutlinedTextField(
|
||||||
|
username,
|
||||||
|
{ username = it },
|
||||||
|
label = { Text("Address") },
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
|
||||||
|
)
|
||||||
|
PasswordField(password, { password = it }, "Password")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
account.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||||
|
if (account.busy) CircularProgressIndicator(Modifier.padding(top = 4.dp))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
when (state) {
|
||||||
|
AccountState.SecondFactor -> AccentButton(
|
||||||
|
onClick = { actions.submitSecondFactor(code.trim()) },
|
||||||
|
enabled = code.isNotBlank() && !account.busy,
|
||||||
|
) { Text("Continue") }
|
||||||
|
AccountState.MailboxPassword -> AccentButton(
|
||||||
|
onClick = { actions.submitMailboxPassword(mailbox) },
|
||||||
|
enabled = mailbox.isNotEmpty() && !account.busy,
|
||||||
|
) { Text("Unlock") }
|
||||||
|
else -> AccentButton(
|
||||||
|
onClick = { actions.signIn(username.trim(), password, null) },
|
||||||
|
enabled = username.isNotBlank() && password.isNotEmpty() && !account.busy,
|
||||||
|
) { Text("Sign in") }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = { TonalButton(onClick = dismiss) { Text("Cancel") } },
|
||||||
|
)
|
||||||
|
account.verificationUrl?.let { url ->
|
||||||
|
VerificationDialog(
|
||||||
|
url = url,
|
||||||
|
onToken = { token -> actions.signIn(username.trim(), password, token) },
|
||||||
|
onDismiss = actions.dismissVerification,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PasswordField(value: String, onChange: (String) -> Unit, label: String) = OutlinedTextField(
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
label = { Text(label) },
|
||||||
|
singleLine = true,
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proton's hosted CAPTCHA, which it puts in front of a sign-in it does not
|
||||||
|
* recognise — a new network, a VPN exit.
|
||||||
|
*
|
||||||
|
* In a WebView rather than the browser because the page reports success by
|
||||||
|
* posting a message to its host, not by redirecting: a browser has nowhere to
|
||||||
|
* post it back to. The page targets `window.parent`, which for a top-level
|
||||||
|
* page is the page itself, so a listener on its own window hears it.
|
||||||
|
*/
|
||||||
|
@SuppressLint("SetJavaScriptEnabled") // The verification page is a script; it does not run without.
|
||||||
|
@Composable
|
||||||
|
private fun VerificationDialog(url: String, onToken: (String) -> Unit, onDismiss: () -> Unit) {
|
||||||
|
Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false)) {
|
||||||
|
Card(Modifier.fillMaxWidth().padding(16.dp).height(560.dp)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
"Confirm you are human",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
modifier = Modifier.weight(1f).padding(start = 16.dp),
|
||||||
|
)
|
||||||
|
IconButton(onClick = onDismiss) { Icon(Icons.Default.Close, contentDescription = "Cancel") }
|
||||||
|
}
|
||||||
|
AndroidView(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
factory = { context ->
|
||||||
|
val main = Handler(Looper.getMainLooper())
|
||||||
|
var answered = false
|
||||||
|
WebView(context).apply {
|
||||||
|
settings.javaScriptEnabled = true
|
||||||
|
settings.domStorageEnabled = true
|
||||||
|
addJavascriptInterface(
|
||||||
|
object {
|
||||||
|
@JavascriptInterface
|
||||||
|
fun post(message: String) {
|
||||||
|
// Every message the page posts lands here,
|
||||||
|
// most of them its own chatter.
|
||||||
|
val token = verificationToken(message) ?: return
|
||||||
|
main.post {
|
||||||
|
if (!answered) {
|
||||||
|
answered = true
|
||||||
|
onToken(token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pstrVerification",
|
||||||
|
)
|
||||||
|
webViewClient = object : WebViewClient() {
|
||||||
|
override fun onPageStarted(view: WebView, url: String?, favicon: android.graphics.Bitmap?) {
|
||||||
|
view.evaluateJavascript(VERIFICATION_BRIDGE, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPageFinished(view: WebView, url: String?) {
|
||||||
|
view.evaluateJavascript(VERIFICATION_BRIDGE, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The bridge above is reachable by whatever the
|
||||||
|
// frame shows, so the frame stays on Proton.
|
||||||
|
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest) =
|
||||||
|
!isProtonPage(request.url.scheme, request.url.host)
|
||||||
|
}
|
||||||
|
loadUrl(url)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Security keys need WebAuthn, which the sign-in here does not speak. An account
|
||||||
|
* with nothing else is refused before this form (`AccountStore::sign_in`).
|
||||||
|
*/
|
||||||
|
private const val SECURITY_KEY_NOTE =
|
||||||
|
"Security keys are not supported here — use the code from your authenticator app."
|
||||||
|
|
||||||
|
/** Installed once per page, at start and again at finish in case the first ran too early. */
|
||||||
|
private const val VERIFICATION_BRIDGE = """
|
||||||
|
if (!window.__pstrVerification) {
|
||||||
|
window.__pstrVerification = true;
|
||||||
|
window.addEventListener('message', function (event) {
|
||||||
|
try {
|
||||||
|
pstrVerification.post(typeof event.data === 'string' ? event.data : JSON.stringify(event.data));
|
||||||
|
} catch (e) {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
/** Whether a page is Proton's own, over HTTPS. */
|
||||||
|
internal fun isProtonPage(scheme: String?, host: String?): Boolean =
|
||||||
|
scheme == "https" && host != null && (host == "proton.me" || host.endsWith(".proton.me"))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walk the account's Drive and add a folder of it to the library.
|
||||||
|
*
|
||||||
|
* Full screen, because a Drive is deep and a dialog's height is a few rows of
|
||||||
|
* it. Listings are asked for here rather than through the model: they are
|
||||||
|
* this dialog's alone, and gone when it closes.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
internal fun DriveBrowserDialog(
|
||||||
|
actions: AccountActions,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val trail = remember { mutableStateListOf<Pair<String, DrivePlaceRecord>>() }
|
||||||
|
var places by remember { mutableStateOf<List<DrivePlaceRecord>?>(null) }
|
||||||
|
var entries by remember { mutableStateOf<List<DriveEntryRecord>?>(null) }
|
||||||
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
|
val current = trail.lastOrNull()?.second
|
||||||
|
|
||||||
|
LaunchedEffect(current) {
|
||||||
|
error = null
|
||||||
|
if (current == null) {
|
||||||
|
if (places == null) {
|
||||||
|
runCatching { NativeRuntime.engine().drivePlaces() }
|
||||||
|
.onSuccess { places = it }
|
||||||
|
.onFailure { error = it.message }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
entries = null
|
||||||
|
runCatching { NativeRuntime.engine().driveFolder(current.volumeId, current.linkId) }
|
||||||
|
.onSuccess { entries = it }
|
||||||
|
.onFailure { error = it.message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false)) {
|
||||||
|
BackHandler(enabled = trail.isNotEmpty()) { trail.removeAt(trail.lastIndex) }
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
trail.lastOrNull()?.first ?: "Your Drive",
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = { if (trail.isEmpty()) onDismiss() else trail.removeAt(trail.lastIndex) }) {
|
||||||
|
Icon(
|
||||||
|
if (trail.isEmpty()) Icons.Default.Close else Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = if (trail.isEmpty()) "Close" else "Up a folder",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
bottomBar = {
|
||||||
|
// A folder already in the library is refused by the engine,
|
||||||
|
// with a message saying so.
|
||||||
|
val folder = current ?: return@Scaffold
|
||||||
|
Row(Modifier.fillMaxWidth().padding(16.dp), horizontalArrangement = Arrangement.End) {
|
||||||
|
AccentButton(
|
||||||
|
onClick = {
|
||||||
|
actions.addFolder(trail.last().first, folder.volumeId, folder.linkId)
|
||||||
|
onDismiss()
|
||||||
|
},
|
||||||
|
) { Text("Add this folder") }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) { padding ->
|
||||||
|
Box(Modifier.fillMaxSize().padding(padding)) {
|
||||||
|
val loading = error == null && (if (current == null) places == null else entries == null)
|
||||||
|
when {
|
||||||
|
error != null -> EmptyState("Could not list this", error.orEmpty())
|
||||||
|
loading -> CircularProgressIndicator(Modifier.align(Alignment.Center))
|
||||||
|
current == null -> LazyColumn(contentPadding = PaddingValues(vertical = 8.dp)) {
|
||||||
|
items(places.orEmpty(), key = { "${it.volumeId}/${it.linkId}" }) { place ->
|
||||||
|
DriveRow(
|
||||||
|
icon = when (place.kind) {
|
||||||
|
PlaceType.MY_FILES -> Icons.Default.Storage
|
||||||
|
PlaceType.DEVICE -> Icons.Default.Computer
|
||||||
|
PlaceType.SHARED_WITH_ME -> Icons.Default.FolderShared
|
||||||
|
},
|
||||||
|
name = place.name,
|
||||||
|
detail = null,
|
||||||
|
onClick = { trail.add(place.name to place) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entries.orEmpty().isEmpty() -> EmptyState("Empty folder", "Nothing to add from here.")
|
||||||
|
else -> LazyColumn(contentPadding = PaddingValues(vertical = 8.dp)) {
|
||||||
|
items(entries.orEmpty(), key = { "${it.volumeId}/${it.linkId}" }) { entry ->
|
||||||
|
DriveRow(
|
||||||
|
icon = when {
|
||||||
|
entry.isFolder -> Icons.Default.Folder
|
||||||
|
entry.isVideo -> Icons.Default.Movie
|
||||||
|
else -> Icons.Default.InsertDriveFile
|
||||||
|
},
|
||||||
|
name = entry.name,
|
||||||
|
detail = entry.size?.let(::formatBytes),
|
||||||
|
onClick = if (entry.isFolder) {
|
||||||
|
{
|
||||||
|
trail.add(
|
||||||
|
entry.name to DrivePlaceRecord(
|
||||||
|
PlaceType.MY_FILES,
|
||||||
|
entry.name,
|
||||||
|
entry.volumeId,
|
||||||
|
entry.linkId,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DriveRow(
|
||||||
|
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||||
|
name: String,
|
||||||
|
detail: String?,
|
||||||
|
onClick: (() -> Unit)?,
|
||||||
|
) {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text(name, maxLines = 2, overflow = TextOverflow.Ellipsis) },
|
||||||
|
supportingContent = detail?.let { { Text(it) } },
|
||||||
|
leadingContent = {
|
||||||
|
Icon(
|
||||||
|
icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = if (onClick != null) {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
|
modifier = if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ import kotlinx.coroutines.flow.callbackFlow
|
|||||||
import kotlinx.coroutines.channels.awaitClose
|
import kotlinx.coroutines.channels.awaitClose
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import io.narl.protonstream.native.NativeRuntime
|
import io.narl.protonstream.native.NativeRuntime
|
||||||
|
import io.narl.protonstream.ui.theme.AppearanceState
|
||||||
import uniffi.pstr_android.ArrangementRecord
|
import uniffi.pstr_android.ArrangementRecord
|
||||||
import uniffi.pstr_android.LibrarySort
|
import uniffi.pstr_android.LibrarySort
|
||||||
import uniffi.pstr_android.ShareRecord
|
import uniffi.pstr_android.ShareRecord
|
||||||
@@ -36,6 +37,11 @@ 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
|
import uniffi.pstr_android.WatchStateRecord
|
||||||
|
import uniffi.pstr_android.AccountState
|
||||||
|
import uniffi.pstr_android.SignInOutcome
|
||||||
|
import uniffi.pstr_android.SyncRecord
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
|
||||||
data class AppUiState(
|
data class AppUiState(
|
||||||
val loading: Boolean = true,
|
val loading: Boolean = true,
|
||||||
@@ -60,8 +66,33 @@ 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 is running in the background right now, said in a banner for as
|
||||||
|
* long as it runs: an add, a crawl, a match, a sync. Without it a
|
||||||
|
* minute-long crawl looked like a tap that did nothing.
|
||||||
|
*/
|
||||||
|
val activity: String? = null,
|
||||||
/** What Undo on [message] puts back, when the message offers one. */
|
/** What Undo on [message] puts back, when the message offers one. */
|
||||||
val undo: List<WatchSnapshot>? = null,
|
val undo: List<WatchSnapshot>? = null,
|
||||||
|
val account: AccountUiState = AccountUiState(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The Proton account: where a sign-in stands, and how sync is doing. */
|
||||||
|
data class AccountUiState(
|
||||||
|
/** Null until the bridge has said. */
|
||||||
|
val state: AccountState? = null,
|
||||||
|
/** A sign-in step is out and not answered yet. */
|
||||||
|
val busy: Boolean = false,
|
||||||
|
/** Why the last sign-in step was refused. */
|
||||||
|
val error: String? = null,
|
||||||
|
/** The CAPTCHA page Proton wants solved before it accepts the sign-in. */
|
||||||
|
val verificationUrl: String? = null,
|
||||||
|
/** When watch history last synced, in epoch milliseconds. */
|
||||||
|
val syncedAt: Long? = null,
|
||||||
|
/** Positions the last sync took from other devices. */
|
||||||
|
val applied: Int = 0,
|
||||||
|
/** Why the last sync failed, until one succeeds. */
|
||||||
|
val syncError: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -76,6 +107,15 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
|||||||
private val mutableState = MutableStateFlow(AppUiState())
|
private val mutableState = MutableStateFlow(AppUiState())
|
||||||
private val searchQuery = MutableStateFlow("")
|
private val searchQuery = MutableStateFlow("")
|
||||||
val state: StateFlow<AppUiState> = mutableState.asStateFlow()
|
val state: StateFlow<AppUiState> = mutableState.asStateFlow()
|
||||||
|
/** Launch and coming back both ask; one crawl answers. */
|
||||||
|
private var catchingUp = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every banner line whose work is still running, oldest first. Touched on
|
||||||
|
* the main thread only, as everything in [viewModelScope] is.
|
||||||
|
*/
|
||||||
|
private val activities = mutableListOf<Pair<Long, String>>()
|
||||||
|
private var nextActivity = 0L
|
||||||
|
|
||||||
init {
|
init {
|
||||||
if (!NativeRuntime.tlsReady) {
|
if (!NativeRuntime.tlsReady) {
|
||||||
@@ -84,6 +124,22 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
reload()
|
reload()
|
||||||
|
catchUp()
|
||||||
|
viewModelScope.launch {
|
||||||
|
val account = runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().accountState() } }
|
||||||
|
.getOrDefault(AccountState.SignedOut)
|
||||||
|
updateAccount { it.copy(state = account) }
|
||||||
|
// While the app is alive, on top of after sign-in and whenever the
|
||||||
|
// player closes. Cheap when nothing changed: one folder listing,
|
||||||
|
// and no upload.
|
||||||
|
while (isActive) {
|
||||||
|
syncWatchHistory()
|
||||||
|
// A sync WatchSyncWorker ran while the app was away may have
|
||||||
|
// brought shares this one has not crawled.
|
||||||
|
catchUp()
|
||||||
|
delay(SYNC_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
searchQuery.debounce(300).distinctUntilChanged().collect { reloadLibrary(it) }
|
searchQuery.debounce(300).distinctUntilChanged().collect { reloadLibrary(it) }
|
||||||
}
|
}
|
||||||
@@ -109,14 +165,51 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
|||||||
|
|
||||||
fun refresh() {
|
fun refresh() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
mutableState.update { it.copy(refreshing = true, message = null) }
|
busy("Refreshing the library…") {
|
||||||
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().crawl(null) } }
|
mutableState.update { it.copy(message = null) }
|
||||||
.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
|
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().crawl(null) } }
|
||||||
mutableState.update { it.copy(refreshing = false) }
|
.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
|
||||||
reload()
|
}
|
||||||
|
reloadNow()
|
||||||
|
matchAfterCrawl()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Look up whatever a crawl brought that has no answer yet: a new show, or
|
||||||
|
* a new episode its stored guide cannot name. When everything already has
|
||||||
|
* one the bridge asks nothing, so this is cheap after every crawl.
|
||||||
|
*/
|
||||||
|
private suspend fun matchAfterCrawl() {
|
||||||
|
val settings = mutableState.value.metadataSettings
|
||||||
|
if (settings.enabled && settings.ready) match(force = false, quiet = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Say [what] in the activity banner while [work] runs.
|
||||||
|
*
|
||||||
|
* The banner shows the newest line still running. Work overlaps — a sync
|
||||||
|
* starts during a crawl and ends after it — so each line is removed by
|
||||||
|
* itself rather than by putting back whatever was showing before, which
|
||||||
|
* once brought back a finished crawl's line and spun it forever.
|
||||||
|
*/
|
||||||
|
private suspend fun busy(what: String, work: suspend () -> Unit) {
|
||||||
|
val token = nextActivity++
|
||||||
|
activities += token to what
|
||||||
|
showActivity()
|
||||||
|
try {
|
||||||
|
work()
|
||||||
|
} finally {
|
||||||
|
activities.removeAll { it.first == token }
|
||||||
|
showActivity()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showActivity() {
|
||||||
|
val newest = activities.lastOrNull()?.second
|
||||||
|
mutableState.update { it.copy(activity = newest, refreshing = newest != null) }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recrawl one share rather than the whole library.
|
* Recrawl one share rather than the whole library.
|
||||||
*
|
*
|
||||||
@@ -127,11 +220,14 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
|||||||
*/
|
*/
|
||||||
fun refreshShare(id: String) {
|
fun refreshShare(id: String) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
mutableState.update { it.copy(refreshing = true, message = null) }
|
val name = mutableState.value.shares.firstOrNull { it.id == id }?.name ?: "the share"
|
||||||
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().crawl(id) } }
|
busy("Reading $name…") {
|
||||||
.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
|
mutableState.update { it.copy(message = null) }
|
||||||
mutableState.update { it.copy(refreshing = false) }
|
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().crawl(id) } }
|
||||||
reload()
|
.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
|
||||||
|
}
|
||||||
|
reloadNow()
|
||||||
|
matchAfterCrawl()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,26 +240,90 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
|||||||
* changed.
|
* changed.
|
||||||
*/
|
*/
|
||||||
fun matchTitles(force: Boolean) {
|
fun matchTitles(force: Boolean) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch { match(force) }
|
||||||
mutableState.update { it.copy(refreshing = true, message = null) }
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* With [quiet], a run nobody asked for says nothing when it found nothing
|
||||||
|
* to do.
|
||||||
|
*/
|
||||||
|
private suspend fun match(force: Boolean, quiet: Boolean = false) {
|
||||||
|
busy("Looking up titles…") {
|
||||||
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().matchTitles(force) } }
|
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().matchTitles(force) } }
|
||||||
.onSuccess { summary -> mutableState.update { it.copy(message = describe(summary)) } }
|
.onSuccess { summary ->
|
||||||
|
if (!quiet || !summary.isEmpty()) {
|
||||||
|
mutableState.update { it.copy(message = describe(summary)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
|
.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
|
||||||
mutableState.update { it.copy(refreshing = false) }
|
}
|
||||||
reload()
|
reloadNow()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun MatchSummary.isEmpty() = matched == 0u && unmatched == 0u && failed == 0u && episodes == 0u
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a link, and return why it was refused, if it was.
|
||||||
|
*
|
||||||
|
* The link is opened before anything is stored, so the form can stay up
|
||||||
|
* with the reason under it and a retry is a retry — not "already in the
|
||||||
|
* library" for a share that never opened. Only the new share is crawled:
|
||||||
|
* another share that fails to open must not make this one look broken.
|
||||||
|
*/
|
||||||
|
suspend fun addShare(name: String, url: String, password: String?): String? {
|
||||||
|
val share = runCatching {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
NativeRuntime.engine().addShare(name, url, password?.takeIf(String::isNotBlank))
|
||||||
|
}
|
||||||
|
}.getOrElse { return it.message ?: "That link could not be added" }
|
||||||
|
reload()
|
||||||
|
viewModelScope.launch { crawlAdded(listOf(share.id), announce = true) }
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crawl shares that are new here — added on this device or by sync — then
|
||||||
|
* match them when enrichment is on.
|
||||||
|
*/
|
||||||
|
private suspend fun crawlAdded(ids: List<String>, announce: Boolean) {
|
||||||
|
if (ids.isEmpty()) return
|
||||||
|
val before = mutableState.value.titles.map { it.key }.toSet()
|
||||||
|
val failed = mutableListOf<String>()
|
||||||
|
for (id in ids) {
|
||||||
|
val name = mutableState.value.shares.firstOrNull { it.id == id }?.name ?: "the new share"
|
||||||
|
busy("Reading $name…") {
|
||||||
|
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().crawl(id) } }
|
||||||
|
.onFailure { failed += "$name: ${it.message}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reloadNow()
|
||||||
|
if (failed.isNotEmpty()) {
|
||||||
|
mutableState.update { it.copy(message = failed.joinToString("\n")) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
matchAfterCrawl()
|
||||||
|
if (announce) {
|
||||||
|
val added = (mutableState.value.titles.map { it.key }.toSet() - before).size
|
||||||
|
mutableState.update { it.copy(message = if (added == 1) "1 title added" else "$added titles added") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun addShare(name: String, url: String, password: String?) {
|
/**
|
||||||
|
* Crawl shares the catalog has nothing of: brought by a sync that ran in
|
||||||
|
* [io.narl.protonstream.sync.WatchSyncWorker], or an add whose crawl never
|
||||||
|
* finished.
|
||||||
|
*/
|
||||||
|
fun catchUp() {
|
||||||
|
if (catchingUp) return
|
||||||
|
catchingUp = true
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
runCatching {
|
try {
|
||||||
withContext(Dispatchers.IO) {
|
val ids = runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().uncrawledShares() } }
|
||||||
val engine = NativeRuntime.engine()
|
.getOrDefault(emptyList())
|
||||||
engine.addShare(name, url, password?.takeIf(String::isNotBlank))
|
crawlAdded(ids, announce = false)
|
||||||
engine.crawl(null)
|
} finally {
|
||||||
}
|
catchingUp = false
|
||||||
}.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
|
}
|
||||||
reload()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,7 +345,8 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
|||||||
engine.crawl(id)
|
engine.crawl(id)
|
||||||
}
|
}
|
||||||
}.onFailure(::reportError)
|
}.onFailure(::reportError)
|
||||||
reload()
|
reloadNow()
|
||||||
|
matchAfterCrawl()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,6 +373,171 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
|||||||
|
|
||||||
fun dismissMessage() = mutableState.update { it.copy(message = null, undo = null) }
|
fun dismissMessage() = mutableState.update { it.copy(message = null, undo = null) }
|
||||||
|
|
||||||
|
private fun updateAccount(change: (AccountUiState) -> AccountUiState) =
|
||||||
|
mutableState.update { it.copy(account = change(it.account)) }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start signing in, or start again with [verificationToken] once the
|
||||||
|
* CAPTCHA from a first try is solved.
|
||||||
|
*/
|
||||||
|
fun signIn(username: String, password: String, verificationToken: String? = null) =
|
||||||
|
signInStep { engine -> engine.signIn(username, password, verificationToken) }
|
||||||
|
|
||||||
|
fun submitSecondFactor(code: String) = signInStep { engine -> engine.submitSecondFactor(code) }
|
||||||
|
|
||||||
|
fun submitMailboxPassword(password: String) =
|
||||||
|
signInStep(keepStepOnFailure = true) { engine -> engine.submitMailboxPassword(password) }
|
||||||
|
|
||||||
|
fun cancelSignIn() {
|
||||||
|
updateAccount { AccountUiState(state = AccountState.SignedOut) }
|
||||||
|
viewModelScope.launch { runCatching { NativeRuntime.engine().cancelSignIn() } }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The CAPTCHA page closed without a token. */
|
||||||
|
fun dismissVerification() = updateAccount { it.copy(verificationUrl = null, busy = false) }
|
||||||
|
|
||||||
|
private fun signInStep(
|
||||||
|
keepStepOnFailure: Boolean = false,
|
||||||
|
step: suspend (uniffi.pstr_android.AndroidEngine) -> SignInOutcome,
|
||||||
|
) {
|
||||||
|
updateAccount { it.copy(busy = true, error = null, verificationUrl = null) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
runCatching { withContext(Dispatchers.IO) { step(NativeRuntime.engine()) } }
|
||||||
|
.onSuccess { outcome ->
|
||||||
|
when (outcome) {
|
||||||
|
is SignInOutcome.Done -> {
|
||||||
|
updateAccount {
|
||||||
|
AccountUiState(state = AccountState.SignedIn(outcome.username))
|
||||||
|
}
|
||||||
|
mutableState.update { it.copy(message = "Signed in as ${outcome.username}") }
|
||||||
|
syncWatchHistory()
|
||||||
|
reload()
|
||||||
|
}
|
||||||
|
SignInOutcome.SecondFactor ->
|
||||||
|
updateAccount { it.copy(state = AccountState.SecondFactor, busy = false) }
|
||||||
|
SignInOutcome.MailboxPassword ->
|
||||||
|
updateAccount { it.copy(state = AccountState.MailboxPassword, busy = false) }
|
||||||
|
is SignInOutcome.HumanVerification ->
|
||||||
|
updateAccount { it.copy(verificationUrl = outcome.url) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onFailure { error ->
|
||||||
|
updateAccount {
|
||||||
|
it.copy(
|
||||||
|
busy = false,
|
||||||
|
error = error.message ?: "Sign-in failed",
|
||||||
|
// A refused code spends the half-made session.
|
||||||
|
state = if (keepStepOnFailure) it.state else AccountState.SignedOut,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun signOut() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().signOut() } }
|
||||||
|
.onFailure(::reportError)
|
||||||
|
updateAccount { AccountUiState(state = AccountState.SignedOut) }
|
||||||
|
reload()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sync now, from a tap: says so when it is done. */
|
||||||
|
fun syncNow() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
if (syncWatchHistory()) {
|
||||||
|
val applied = mutableState.value.account.applied
|
||||||
|
mutableState.update {
|
||||||
|
it.copy(
|
||||||
|
message = if (applied > 0) {
|
||||||
|
"Watch history synced, $applied from other devices"
|
||||||
|
} else {
|
||||||
|
"Watch history is up to date"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pull other devices' positions in and push this one's out. Quiet: it runs
|
||||||
|
* on a timer, and a failure is shown on the account card rather than as a
|
||||||
|
* snackbar every five minutes of an offline evening. Returns whether it
|
||||||
|
* synced.
|
||||||
|
*/
|
||||||
|
private suspend fun syncWatchHistory(): Boolean {
|
||||||
|
if (mutableState.value.account.state !is AccountState.SignedIn) return false
|
||||||
|
return runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().syncWatchHistory() } }
|
||||||
|
.fold(
|
||||||
|
onSuccess = { report ->
|
||||||
|
if (report == null) return false
|
||||||
|
updateAccount {
|
||||||
|
it.copy(
|
||||||
|
syncedAt = System.currentTimeMillis(),
|
||||||
|
applied = report.applied.toInt(),
|
||||||
|
syncError = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
takeSynced(report)
|
||||||
|
true
|
||||||
|
},
|
||||||
|
onFailure = { error ->
|
||||||
|
val state = runCatching { NativeRuntime.engine().accountState() }.getOrNull()
|
||||||
|
updateAccount { it.copy(state = state ?: it.state, syncError = error.message) }
|
||||||
|
// Signed out by the engine: the session ended, and that is
|
||||||
|
// worth interrupting for, unlike a dropped connection.
|
||||||
|
if (state == AccountState.SignedOut) reportError(error)
|
||||||
|
false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What another device changed: its shares crawled or dropped here, its
|
||||||
|
* settings repainted.
|
||||||
|
*/
|
||||||
|
private suspend fun takeSynced(report: SyncRecord) {
|
||||||
|
if (report.sharesRemoved.isNotEmpty()) {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val downloads = DownloadStateStore(appContext)
|
||||||
|
report.sharesRemoved.forEach { id ->
|
||||||
|
workManager.cancelAllWorkByTag(DownloadCoordinator.shareTag(id)).result.get()
|
||||||
|
downloads.removeShare(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (report.settingsChanged) AppearanceState.reload()
|
||||||
|
if (report.applied > 0u || report.titles > 0u || report.settingsChanged || report.sharesRemoved.isNotEmpty()) {
|
||||||
|
reloadNow()
|
||||||
|
}
|
||||||
|
if (report.sharesAdded.isNotEmpty()) {
|
||||||
|
crawlAdded(report.sharesAdded, announce = false)
|
||||||
|
val count = report.sharesAdded.size
|
||||||
|
mutableState.update {
|
||||||
|
it.copy(message = if (count == 1) "A share arrived from another device" else "$count shares arrived from other devices")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** After the player closes: where it stopped is what another device wants next. */
|
||||||
|
fun playerClosed() {
|
||||||
|
reload()
|
||||||
|
viewModelScope.launch { syncWatchHistory() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addAccountFolder(name: String, volumeId: String, linkId: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
runCatching {
|
||||||
|
withContext(Dispatchers.IO) { NativeRuntime.engine().addAccountFolder(name, volumeId, linkId) }
|
||||||
|
}.onSuccess { share ->
|
||||||
|
reload()
|
||||||
|
crawlAdded(listOf(share.id), announce = true)
|
||||||
|
}.onFailure(::reportError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every episode of a title watched or unwatched, with an Undo: a whole
|
* 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
|
* title's positions are what this throws away, and a mis-tap on a tile's
|
||||||
@@ -310,22 +636,27 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
|||||||
mutableState.update { it.copy(message = error.message ?: "Unexpected error") }
|
mutableState.update { it.copy(message = error.message ?: "Unexpected error") }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun saveMetadataSettings(enabled: Boolean, provider: MetadataProvider, language: String, apiKey: String) {
|
/**
|
||||||
viewModelScope.launch {
|
* Store the enrichment settings, and return why not, if they were not.
|
||||||
runCatching {
|
*
|
||||||
withContext(Dispatchers.IO) {
|
* The page shows the new settings as soon as they are stored; the match
|
||||||
val engine = NativeRuntime.engine()
|
* run that follows can take minutes and says so in the activity banner.
|
||||||
if (provider == MetadataProvider.TMDB && apiKey.isNotBlank()) {
|
*/
|
||||||
engine.setMetadataApiKey(provider, apiKey)
|
suspend fun saveMetadataSettings(enabled: Boolean, provider: MetadataProvider, language: String, apiKey: String): String? {
|
||||||
}
|
val stored = runCatching {
|
||||||
engine.setMetadataSettings(MetadataSettingsRecord(enabled, provider, language, true))
|
withContext(Dispatchers.IO) {
|
||||||
if (enabled) engine.matchTitles(false) else null
|
val engine = NativeRuntime.engine()
|
||||||
|
if (provider == MetadataProvider.TMDB && apiKey.isNotBlank()) {
|
||||||
|
engine.setMetadataApiKey(provider, apiKey)
|
||||||
}
|
}
|
||||||
}.onSuccess { summary ->
|
engine.setMetadataSettings(MetadataSettingsRecord(enabled, provider, language, true))
|
||||||
summary?.let { mutableState.update { state -> state.copy(message = describe(it)) } }
|
engine.metadataSettings()
|
||||||
}.onFailure(::reportError)
|
}
|
||||||
reload()
|
}.getOrElse { return it.message ?: "The settings could not be saved" }
|
||||||
}
|
mutableState.update { it.copy(metadataSettings = stored) }
|
||||||
|
reloadNow()
|
||||||
|
if (enabled) viewModelScope.launch { match(force = false) }
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
fun reloadAfterMetadataChange() = reload()
|
fun reloadAfterMetadataChange() = reload()
|
||||||
@@ -452,40 +783,43 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun reload() {
|
private fun reload() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch { reloadNow() }
|
||||||
runCatching {
|
}
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
val engine = NativeRuntime.engine()
|
/** [reload], for a caller that goes on to read what it loaded. */
|
||||||
// Here rather than inside `library()`, which is read-only
|
private suspend fun reloadNow() {
|
||||||
// and cached: a full reload is the one place that has
|
runCatching {
|
||||||
// already paid for a walk of the offline files.
|
withContext(Dispatchers.IO) {
|
||||||
engine.pruneOfflineFiles()
|
val engine = NativeRuntime.engine()
|
||||||
val current = mutableState.value
|
// Here rather than inside `library()`, which is read-only
|
||||||
val query = current.query.takeIf(String::isNotBlank)
|
// and cached: a full reload is the one place that has
|
||||||
Reloaded(
|
// already paid for a walk of the offline files.
|
||||||
engine.shares(),
|
engine.pruneOfflineFiles()
|
||||||
engine.library(query),
|
val current = mutableState.value
|
||||||
engine.arrangement(query, current.sort, current.grouped),
|
val query = current.query.takeIf(String::isNotBlank)
|
||||||
engine.offlineFiles(),
|
Reloaded(
|
||||||
engine.metadataSettings(),
|
engine.shares(),
|
||||||
engine.storageUsage(),
|
engine.library(query),
|
||||||
)
|
engine.arrangement(query, current.sort, current.grouped),
|
||||||
}
|
engine.offlineFiles(),
|
||||||
}.onSuccess { result ->
|
engine.metadataSettings(),
|
||||||
mutableState.update { it.copy(
|
engine.storageUsage(),
|
||||||
loading = false,
|
)
|
||||||
shares = result.shares,
|
}
|
||||||
titles = result.titles,
|
}.onSuccess { result ->
|
||||||
arrangement = result.arrangement,
|
mutableState.update { it.copy(
|
||||||
offline = result.offline,
|
loading = false,
|
||||||
metadataSettings = result.metadataSettings,
|
shares = result.shares,
|
||||||
storage = result.storage,
|
titles = result.titles,
|
||||||
) }
|
arrangement = result.arrangement,
|
||||||
}
|
offline = result.offline,
|
||||||
.onFailure { error ->
|
metadataSettings = result.metadataSettings,
|
||||||
mutableState.update { it.copy(loading = false, message = error.message) }
|
storage = result.storage,
|
||||||
}
|
) }
|
||||||
}
|
}
|
||||||
|
.onFailure { error ->
|
||||||
|
mutableState.update { it.copy(loading = false, message = error.message) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun reloadLibrary(query: String) {
|
private suspend fun reloadLibrary(query: String) {
|
||||||
@@ -501,6 +835,10 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
|||||||
}.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
|
}.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val SYNC_INTERVAL_MS = 5 * 60 * 1000L
|
||||||
|
}
|
||||||
|
|
||||||
class Factory(private val context: Context, private val workManager: WorkManager) : ViewModelProvider.Factory {
|
class Factory(private val context: Context, private val workManager: WorkManager) : ViewModelProvider.Factory {
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
override fun <T : ViewModel> create(modelClass: Class<T>): T = AppViewModel(context, workManager) as T
|
override fun <T : ViewModel> create(modelClass: Class<T>): T = AppViewModel(context, workManager) as T
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import androidx.compose.runtime.staticCompositionLocalOf
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
|
import io.narl.protonstream.download.RetainedDownload
|
||||||
import uniffi.pstr_android.EpisodeRecord
|
import uniffi.pstr_android.EpisodeRecord
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -120,6 +121,20 @@ internal fun formatBytes(bytes: ULong): String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One line under a download that has not finished: "Running · 120.0 MiB of
|
||||||
|
* 1.2 GiB · 2.4 MiB/s", or "Queued" while it waits for a slot.
|
||||||
|
*/
|
||||||
|
internal fun downloadStatusLine(download: RetainedDownload): String = listOfNotNull(
|
||||||
|
download.status.replaceFirstChar { it.uppercase() },
|
||||||
|
download.total.takeIf { it > 0L && download.downloaded > 0L }?.let {
|
||||||
|
"${formatBytes(download.downloaded.toULong())} of ${formatBytes(it.toULong())}"
|
||||||
|
},
|
||||||
|
download.bytesPerSecond
|
||||||
|
.takeIf { it > 0L && download.status == RetainedDownload.STATUS_RUNNING }
|
||||||
|
?.let { "${formatBytes(it.toULong())}/s" },
|
||||||
|
).joinToString(" · ")
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What an episode row is headed with: "1. Mother and Children".
|
* What an episode row is headed with: "1. Mother and Children".
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -204,14 +204,7 @@ private fun PartialRow(
|
|||||||
headlineContent = { Text(download.label, maxLines = 2, overflow = TextOverflow.Ellipsis) },
|
headlineContent = { Text(download.label, maxLines = 2, overflow = TextOverflow.Ellipsis) },
|
||||||
supportingContent = {
|
supportingContent = {
|
||||||
Column {
|
Column {
|
||||||
Text(
|
Text(downloadStatusLine(download))
|
||||||
listOfNotNull(
|
|
||||||
download.status.replaceFirstChar { it.uppercase() },
|
|
||||||
download.total.takeIf { it > 0L }?.let {
|
|
||||||
"${formatBytes(download.downloaded.toULong())} of ${formatBytes(it.toULong())}"
|
|
||||||
},
|
|
||||||
).joinToString(" · "),
|
|
||||||
)
|
|
||||||
if (download.total > 0L) {
|
if (download.total > 0L) {
|
||||||
AccentProgress(
|
AccentProgress(
|
||||||
progress = { (download.downloaded.toFloat() / download.total).coerceIn(0f, 1f) },
|
progress = { (download.downloaded.toFloat() / download.total).coerceIn(0f, 1f) },
|
||||||
|
|||||||
@@ -1,5 +1,16 @@
|
|||||||
package io.narl.protonstream.ui
|
package io.narl.protonstream.ui
|
||||||
|
|
||||||
|
import androidx.compose.ui.semantics.LiveRegionMode
|
||||||
|
import androidx.compose.ui.semantics.liveRegion
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.animation.slideOutVertically
|
||||||
|
import androidx.compose.animation.slideInVertically
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.animation.AnimatedContent
|
import androidx.compose.animation.AnimatedContent
|
||||||
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||||
@@ -98,7 +109,6 @@ private enum class Destination(val label: String, val icon: ImageVector) {
|
|||||||
@Composable
|
@Composable
|
||||||
fun ProtonStreamApp(
|
fun ProtonStreamApp(
|
||||||
playerHost: LibmpvHost? = null,
|
playerHost: LibmpvHost? = null,
|
||||||
inPictureInPicture: Boolean = false,
|
|
||||||
shareLink: String? = null,
|
shareLink: String? = null,
|
||||||
onShareLinkTaken: () -> Unit = {},
|
onShareLinkTaken: () -> Unit = {},
|
||||||
model: AppViewModel = viewModel(
|
model: AppViewModel = viewModel(
|
||||||
@@ -270,6 +280,18 @@ fun ProtonStreamApp(
|
|||||||
shareLink,
|
shareLink,
|
||||||
onShareLinkTaken,
|
onShareLinkTaken,
|
||||||
body,
|
body,
|
||||||
|
account = state.account,
|
||||||
|
accountActions = AccountActions(
|
||||||
|
signIn = model::signIn,
|
||||||
|
submitSecondFactor = model::submitSecondFactor,
|
||||||
|
submitMailboxPassword = model::submitMailboxPassword,
|
||||||
|
cancelSignIn = model::cancelSignIn,
|
||||||
|
dismissVerification = model::dismissVerification,
|
||||||
|
signOut = model::signOut,
|
||||||
|
syncNow = model::syncNow,
|
||||||
|
addFolder = model::addAccountFolder,
|
||||||
|
onError = model::reportError,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
Destination.History -> HistoryScreen(
|
Destination.History -> HistoryScreen(
|
||||||
state,
|
state,
|
||||||
@@ -308,29 +330,34 @@ fun ProtonStreamApp(
|
|||||||
onClose = {
|
onClose = {
|
||||||
playingTitleKey = null
|
playingTitleKey = null
|
||||||
playerMinimized = false
|
playerMinimized = false
|
||||||
model.reloadAfterMetadataChange()
|
model.playerClosed()
|
||||||
},
|
},
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.BottomCenter)
|
.align(Alignment.BottomCenter)
|
||||||
.padding(bottom = padding.calculateBottomPadding() + 8.dp),
|
.padding(bottom = padding.calculateBottomPadding() + 8.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
ActivityBanner(
|
||||||
|
state.activity,
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.BottomCenter)
|
||||||
|
.padding(bottom = body.calculateBottomPadding() + 8.dp),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drawn over the scaffold rather than instead of it. The player owns the
|
// Drawn over the scaffold rather than instead of it. The player owns the
|
||||||
// whole window while it is up — it is a screen, not a dialog over one, which
|
// whole window while it is up — it is a screen, not a dialog over one, which
|
||||||
// is what lets it draw into the cutout and hand the right window to
|
// is what lets it draw into the cutout — but the page behind it stays
|
||||||
// Picture-in-Picture — but the page behind it stays composed, so leaving the
|
// composed, so leaving the player finds the title page where it was rather
|
||||||
// player finds the title page where it was rather than rebuilt from the top.
|
// than rebuilt from the top.
|
||||||
playing?.let { request ->
|
playing?.let { request ->
|
||||||
PlayerScreen(
|
PlayerScreen(
|
||||||
title = request.title,
|
title = request.title,
|
||||||
episodes = request.episodes,
|
episodes = request.episodes,
|
||||||
index = request.index,
|
index = request.index,
|
||||||
host = playerHost,
|
host = playerHost,
|
||||||
inPictureInPicture = inPictureInPicture,
|
|
||||||
minimized = playerMinimized,
|
minimized = playerMinimized,
|
||||||
onIndexChange = { playingIndex = it },
|
onIndexChange = { playingIndex = it },
|
||||||
onSaveProgress = model::saveProgress,
|
onSaveProgress = model::saveProgress,
|
||||||
@@ -338,7 +365,7 @@ fun ProtonStreamApp(
|
|||||||
onClose = {
|
onClose = {
|
||||||
playingTitleKey = null
|
playingTitleKey = null
|
||||||
playerMinimized = false
|
playerMinimized = false
|
||||||
model.reloadAfterMetadataChange()
|
model.playerClosed()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -388,3 +415,34 @@ private fun MiniTransport(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the app is busy with, for as long as it is: a spinner and one line.
|
||||||
|
* Not dismissible and not timed — it goes when the work does.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun ActivityBanner(activity: String?, modifier: Modifier = Modifier) {
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = activity != null,
|
||||||
|
modifier = modifier,
|
||||||
|
enter = fadeIn() + slideInVertically { it },
|
||||||
|
exit = fadeOut() + slideOutVertically { it },
|
||||||
|
) {
|
||||||
|
// Held through the exit, so the line does not blank before it fades.
|
||||||
|
var shown by remember { mutableStateOf(activity.orEmpty()) }
|
||||||
|
if (activity != null) shown = activity
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.padding(horizontal = 12.dp)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.shapes.small)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||||
|
.semantics { liveRegion = LiveRegionMode.Polite },
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp)
|
||||||
|
Text(shown, style = MaterialTheme.typography.bodyMedium, maxLines = 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -86,7 +86,8 @@ import io.narl.protonstream.ui.theme.AccentProgress
|
|||||||
@Composable
|
@Composable
|
||||||
internal fun SettingsScreen(
|
internal fun SettingsScreen(
|
||||||
state: AppUiState,
|
state: AppUiState,
|
||||||
onSaveMetadata: (Boolean, MetadataProvider, String, String) -> Unit,
|
/** Resolves to why the settings were not stored, or null once they are. */
|
||||||
|
onSaveMetadata: suspend (Boolean, MetadataProvider, String, String) -> String?,
|
||||||
onMatchAgain: () -> Unit,
|
onMatchAgain: () -> Unit,
|
||||||
onClearCache: () -> Unit,
|
onClearCache: () -> Unit,
|
||||||
onRemoveAllOffline: () -> Unit,
|
onRemoveAllOffline: () -> Unit,
|
||||||
@@ -327,10 +328,7 @@ internal fun SettingsScreen(
|
|||||||
MetadataSettingsDialog(
|
MetadataSettingsDialog(
|
||||||
current = state.metadataSettings,
|
current = state.metadataSettings,
|
||||||
onDismiss = { showMetadata = false },
|
onDismiss = { showMetadata = false },
|
||||||
onSave = { enabled, provider, language, key ->
|
onSave = onSaveMetadata,
|
||||||
onSaveMetadata(enabled, provider, language, key)
|
|
||||||
showMetadata = false
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -411,14 +409,18 @@ private fun SettingAction(
|
|||||||
private fun MetadataSettingsDialog(
|
private fun MetadataSettingsDialog(
|
||||||
current: uniffi.pstr_android.MetadataSettingsRecord,
|
current: uniffi.pstr_android.MetadataSettingsRecord,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onSave: (Boolean, MetadataProvider, String, String) -> Unit,
|
onSave: suspend (Boolean, MetadataProvider, String, String) -> String?,
|
||||||
) {
|
) {
|
||||||
var enabled by remember { mutableStateOf(current.enabled) }
|
var enabled by remember { mutableStateOf(current.enabled) }
|
||||||
var provider by remember { mutableStateOf(current.provider) }
|
var provider by remember { mutableStateOf(current.provider) }
|
||||||
var language by remember { mutableStateOf(current.language) }
|
var language by remember { mutableStateOf(current.language) }
|
||||||
var apiKey by remember { mutableStateOf("") }
|
var apiKey by remember { mutableStateOf("") }
|
||||||
|
var saving by remember { mutableStateOf(false) }
|
||||||
|
var refusal by remember { mutableStateOf<String?>(null) }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val needsKey = enabled && provider == MetadataProvider.TMDB && !current.ready && apiKey.isBlank()
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = { if (!saving) onDismiss() },
|
||||||
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
|
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
|
||||||
title = { Text("Metadata enrichment") },
|
title = { Text("Metadata enrichment") },
|
||||||
text = {
|
text = {
|
||||||
@@ -447,15 +449,25 @@ private fun MetadataSettingsDialog(
|
|||||||
singleLine = true,
|
singleLine = true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (needsKey) Text("Enter a TMDB API key to save.", color = MaterialTheme.colorScheme.error)
|
||||||
|
refusal?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
AccentButton(
|
AccentButton(
|
||||||
enabled = !enabled || provider != MetadataProvider.TMDB || current.ready || apiKey.isNotBlank(),
|
enabled = !needsKey && !saving,
|
||||||
onClick = { onSave(enabled, provider, language.ifBlank { "en" }, apiKey) },
|
onClick = {
|
||||||
) { Text("Save") }
|
saving = true
|
||||||
|
refusal = null
|
||||||
|
scope.launch {
|
||||||
|
refusal = onSave(enabled, provider, language.ifBlank { "en" }, apiKey)
|
||||||
|
saving = false
|
||||||
|
if (refusal == null) onDismiss()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) { Text(if (saving) "Saving…" else "Save") }
|
||||||
},
|
},
|
||||||
dismissButton = { TonalButton(onClick = onDismiss) { Text("Cancel") } },
|
dismissButton = { TonalButton(onClick = onDismiss, enabled = !saving) { Text("Cancel") } },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
package io.narl.protonstream.ui
|
package io.narl.protonstream.ui
|
||||||
|
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
@@ -32,6 +37,7 @@ import androidx.compose.ui.window.SecureFlagPolicy
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import io.narl.protonstream.ui.theme.AccentButton
|
import io.narl.protonstream.ui.theme.AccentButton
|
||||||
import io.narl.protonstream.ui.theme.TonalButton
|
import io.narl.protonstream.ui.theme.TonalButton
|
||||||
|
import uniffi.pstr_android.AccountState
|
||||||
import uniffi.pstr_android.ShareRecord
|
import uniffi.pstr_android.ShareRecord
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||||
@@ -49,23 +55,48 @@ import androidx.compose.ui.graphics.Color
|
|||||||
@Composable
|
@Composable
|
||||||
internal fun SharesScreen(
|
internal fun SharesScreen(
|
||||||
shares: List<ShareRecord>,
|
shares: List<ShareRecord>,
|
||||||
onAdd: (String, String, String?) -> Unit,
|
/** Resolves to why the link was refused, or null once it is added. */
|
||||||
|
onAdd: suspend (String, String, String?) -> String?,
|
||||||
onRepair: (String, String, String?) -> Unit,
|
onRepair: (String, String, String?) -> Unit,
|
||||||
onRefresh: (String) -> Unit,
|
onRefresh: (String) -> Unit,
|
||||||
onRemove: (String) -> Unit,
|
onRemove: (String) -> Unit,
|
||||||
incomingLink: String?,
|
incomingLink: String?,
|
||||||
onIncomingLinkTaken: () -> Unit,
|
onIncomingLinkTaken: () -> Unit,
|
||||||
padding: PaddingValues,
|
padding: PaddingValues,
|
||||||
|
account: AccountUiState = AccountUiState(),
|
||||||
|
accountActions: AccountActions? = null,
|
||||||
) {
|
) {
|
||||||
var showAdd by remember { mutableStateOf(false) }
|
var showAdd by remember { mutableStateOf(false) }
|
||||||
|
var signingIn by remember { mutableStateOf(false) }
|
||||||
|
var browsing by remember { mutableStateOf(false) }
|
||||||
var repairing by remember { mutableStateOf<ShareRecord?>(null) }
|
var repairing by remember { mutableStateOf<ShareRecord?>(null) }
|
||||||
var removing by remember { mutableStateOf<ShareRecord?>(null) }
|
var removing by remember { mutableStateOf<ShareRecord?>(null) }
|
||||||
TabPage("Shares", padding) { body ->
|
TabPage("Shares", padding) { body ->
|
||||||
Box(Modifier.fillMaxSize().padding(body)) {
|
Box(Modifier.fillMaxSize().padding(body)) {
|
||||||
if (shares.isEmpty()) {
|
if (shares.isEmpty() && account.state == null) {
|
||||||
EmptyState("No shares yet", "Add a Proton Drive public link to build your library.")
|
EmptyState("No shares yet", "Add a Proton Drive public link to build your library.")
|
||||||
} else {
|
} else {
|
||||||
LazyColumn(contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp)) {
|
LazyColumn(contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp)) {
|
||||||
|
if (accountActions != null) {
|
||||||
|
item(key = "account") {
|
||||||
|
AccountCard(
|
||||||
|
account,
|
||||||
|
accountActions,
|
||||||
|
onSignIn = { signingIn = true },
|
||||||
|
onBrowse = { browsing = true },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (shares.isEmpty()) {
|
||||||
|
item(key = "empty") {
|
||||||
|
Text(
|
||||||
|
"No shares yet. Add a public link, or a folder of your Drive once signed in.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
items(shares, key = { it.id }) { share ->
|
items(shares, key = { it.id }) { share ->
|
||||||
ShareRow(
|
ShareRow(
|
||||||
share = share,
|
share = share,
|
||||||
@@ -95,6 +126,14 @@ internal fun SharesScreen(
|
|||||||
onAdd = onAdd,
|
onAdd = onAdd,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (accountActions != null && (signingIn || account.state == AccountState.SecondFactor ||
|
||||||
|
account.state == AccountState.MailboxPassword)
|
||||||
|
) {
|
||||||
|
SignInDialog(account, accountActions, onDismiss = { signingIn = false })
|
||||||
|
}
|
||||||
|
if (accountActions != null && browsing) {
|
||||||
|
DriveBrowserDialog(accountActions, onDismiss = { browsing = false })
|
||||||
|
}
|
||||||
repairing?.let { share ->
|
repairing?.let { share ->
|
||||||
RepairShareDialog(
|
RepairShareDialog(
|
||||||
share = share,
|
share = share,
|
||||||
@@ -103,15 +142,15 @@ internal fun SharesScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
removing?.let { share ->
|
removing?.let { share ->
|
||||||
// Asked, because it cannot be undone: the link's secret, its offline
|
// Asked, because it cannot be undone: the link's secret and its
|
||||||
// files and its watch positions all go (`Catalog::remove_share`).
|
// offline files go. Watch positions stay (`Catalog::remove_share`).
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = { removing = null },
|
onDismissRequest = { removing = null },
|
||||||
title = { Text("Remove ${share.name}?") },
|
title = { Text("Remove ${share.name}?") },
|
||||||
text = {
|
text = {
|
||||||
Text(
|
Text(
|
||||||
"Its titles leave the library, and its offline episodes and watch " +
|
"Its titles leave the library and its offline episodes are deleted. " +
|
||||||
"progress are deleted. Adding the link again starts it fresh.",
|
"Watch progress is kept, so adding the link again picks up where you left off.",
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
@@ -139,7 +178,13 @@ private fun ShareRow(
|
|||||||
ListItem(
|
ListItem(
|
||||||
headlineContent = { Text(share.name, fontWeight = FontWeight.SemiBold) },
|
headlineContent = { Text(share.name, fontWeight = FontWeight.SemiBold) },
|
||||||
supportingContent = {
|
supportingContent = {
|
||||||
Text(if (share.hasCustomPassword) "Link and custom password, stored securely" else "Public link")
|
Text(
|
||||||
|
when {
|
||||||
|
share.fromAccount -> "Folder of your Drive"
|
||||||
|
share.hasCustomPassword -> "Link and custom password, stored securely"
|
||||||
|
else -> "Public link"
|
||||||
|
},
|
||||||
|
)
|
||||||
},
|
},
|
||||||
leadingContent = {
|
leadingContent = {
|
||||||
Icon(
|
Icon(
|
||||||
@@ -164,11 +209,13 @@ private fun ShareRow(
|
|||||||
// Not a remove: a share whose secret has become unreadable
|
// Not a remove: a share whose secret has become unreadable
|
||||||
// cannot be removed either, since removal deletes a secret
|
// cannot be removed either, since removal deletes a secret
|
||||||
// the store can no longer touch.
|
// the store can no longer touch.
|
||||||
DropdownMenuItem(
|
if (!share.fromAccount) {
|
||||||
text = { Text("Re-enter link") },
|
DropdownMenuItem(
|
||||||
leadingIcon = { Icon(Icons.Default.Key, contentDescription = null) },
|
text = { Text("Re-enter link") },
|
||||||
onClick = { menu = false; onRepair() },
|
leadingIcon = { Icon(Icons.Default.Key, contentDescription = null) },
|
||||||
)
|
onClick = { menu = false; onRepair() },
|
||||||
|
)
|
||||||
|
}
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text("Remove") },
|
text = { Text("Remove") },
|
||||||
leadingIcon = { Icon(Icons.Default.Delete, contentDescription = null) },
|
leadingIcon = { Icon(Icons.Default.Delete, contentDescription = null) },
|
||||||
@@ -236,17 +283,25 @@ private fun RepairShareDialog(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a link. The form stays up while the link is opened, and with the reason
|
||||||
|
* under it when the link is refused, so fixing a typo is a retry rather than
|
||||||
|
* starting over.
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun AddShareDialog(
|
private fun AddShareDialog(
|
||||||
initialUrl: String,
|
initialUrl: String,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onAdd: (String, String, String?) -> Unit,
|
onAdd: suspend (String, String, String?) -> String?,
|
||||||
) {
|
) {
|
||||||
var name by remember { mutableStateOf("") }
|
var name by remember { mutableStateOf("") }
|
||||||
var url by remember(initialUrl) { mutableStateOf(initialUrl) }
|
var url by remember(initialUrl) { mutableStateOf(initialUrl) }
|
||||||
var password by remember { mutableStateOf("") }
|
var password by remember { mutableStateOf("") }
|
||||||
|
var adding by remember { mutableStateOf(false) }
|
||||||
|
var refusal by remember { mutableStateOf<String?>(null) }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = { if (!adding) onDismiss() },
|
||||||
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
|
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
|
||||||
title = { Text("Add Proton Drive share") },
|
title = { Text("Add Proton Drive share") },
|
||||||
text = {
|
text = {
|
||||||
@@ -271,14 +326,30 @@ private fun AddShareDialog(
|
|||||||
visualTransformation = PasswordVisualTransformation(),
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||||
)
|
)
|
||||||
|
if (adding) {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp)
|
||||||
|
Text("Opening the link…")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
refusal?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
AccentButton(
|
AccentButton(
|
||||||
onClick = { onAdd(name.trim(), url.trim(), password); onDismiss() },
|
onClick = {
|
||||||
enabled = name.isNotBlank() && url.isNotBlank(),
|
adding = true
|
||||||
) { Text("Add") }
|
refusal = null
|
||||||
|
scope.launch {
|
||||||
|
refusal = onAdd(name.trim(), url.trim(), password)
|
||||||
|
adding = false
|
||||||
|
if (refusal == null) onDismiss()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = name.isNotBlank() && url.isNotBlank() && !adding,
|
||||||
|
) { Text(if (adding) "Adding…" else "Add") }
|
||||||
},
|
},
|
||||||
dismissButton = { TonalButton(onClick = onDismiss) { Text("Cancel") } },
|
dismissButton = { TonalButton(onClick = onDismiss, enabled = !adding) { Text("Cancel") } },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -636,8 +636,7 @@ private fun EpisodeRow(
|
|||||||
fill = solid(MaterialTheme.colorScheme.tertiary),
|
fill = solid(MaterialTheme.colorScheme.tertiary),
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
"${active.status.replaceFirstChar { it.uppercase() }} · " +
|
downloadStatusLine(active),
|
||||||
"${formatBytes(active.downloaded.toULong())} of ${formatBytes(active.total.toULong())}",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
@@ -654,6 +653,8 @@ internal fun ChangeMatchDialog(
|
|||||||
) {
|
) {
|
||||||
var term by remember(title.key) { mutableStateOf(title.canonicalName ?: title.name) }
|
var term by remember(title.key) { mutableStateOf(title.canonicalName ?: title.name) }
|
||||||
var searching by remember { mutableStateOf(false) }
|
var searching by remember { mutableStateOf(false) }
|
||||||
|
// Choosing fetches the entry's episodes too, which is a few requests.
|
||||||
|
var choosing by remember { mutableStateOf(false) }
|
||||||
var options by remember { mutableStateOf<List<MatchRecord>>(emptyList()) }
|
var options by remember { mutableStateOf<List<MatchRecord>>(emptyList()) }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
@@ -672,16 +673,26 @@ internal fun ChangeMatchDialog(
|
|||||||
searching = false
|
searching = false
|
||||||
}
|
}
|
||||||
}) { Text(if (searching) "Searching…" else "Search") }
|
}) { Text(if (searching) "Searching…" else "Search") }
|
||||||
|
if (choosing) {
|
||||||
|
Text(
|
||||||
|
"Fetching its episodes…",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
modifier = Modifier.padding(top = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
LazyColumn(Modifier.fillMaxWidth().heightIn(max = 300.dp)) {
|
LazyColumn(Modifier.fillMaxWidth().heightIn(max = 300.dp)) {
|
||||||
items(options, key = { "${it.provider}:${it.remoteId}" }) { option ->
|
items(options, key = { "${it.provider}:${it.remoteId}" }) { option ->
|
||||||
EdgedButton(
|
EdgedButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
|
choosing = true
|
||||||
scope.launch {
|
scope.launch {
|
||||||
runCatching {
|
runCatching {
|
||||||
withContext(Dispatchers.IO) { NativeRuntime.engine().chooseMatch(title.key, option) }
|
withContext(Dispatchers.IO) { NativeRuntime.engine().chooseMatch(title.key, option) }
|
||||||
}.onSuccess { onChanged() }.onFailure(onError)
|
}.onSuccess { onChanged() }.onFailure(onError)
|
||||||
|
choosing = false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
enabled = !choosing,
|
||||||
modifier = Modifier.fillMaxWidth().padding(top = 6.dp),
|
modifier = Modifier.fillMaxWidth().padding(top = 6.dp),
|
||||||
) {
|
) {
|
||||||
Column(Modifier.fillMaxWidth()) {
|
Column(Modifier.fillMaxWidth()) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package io.narl.protonstream.ui.theme
|
|||||||
|
|
||||||
import androidx.compose.foundation.Canvas
|
import androidx.compose.foundation.Canvas
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.RowScope
|
import androidx.compose.foundation.layout.RowScope
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
@@ -9,6 +10,7 @@ import androidx.compose.material3.Button
|
|||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.FilledTonalButton
|
import androidx.compose.material3.FilledTonalButton
|
||||||
|
import androidx.compose.material3.LocalMinimumInteractiveComponentSize
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.NavigationBarItemDefaults
|
import androidx.compose.material3.NavigationBarItemDefaults
|
||||||
import androidx.compose.material3.NavigationDrawerItemDefaults
|
import androidx.compose.material3.NavigationDrawerItemDefaults
|
||||||
@@ -18,14 +20,18 @@ import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteDefaul
|
|||||||
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteItemColors
|
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteItemColors
|
||||||
import androidx.compose.material3.SliderState
|
import androidx.compose.material3.SliderState
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.minimumInteractiveComponentSize
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.geometry.CornerRadius
|
import androidx.compose.ui.geometry.CornerRadius
|
||||||
import androidx.compose.ui.geometry.Size
|
import androidx.compose.ui.geometry.Size
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -68,17 +74,30 @@ internal fun AccentButton(
|
|||||||
content: @Composable RowScope.() -> Unit,
|
content: @Composable RowScope.() -> Unit,
|
||||||
) {
|
) {
|
||||||
val shape = MaterialTheme.shapes.small
|
val shape = MaterialTheme.shapes.small
|
||||||
Button(
|
// The button pads itself out to the 48dp minimum touch target, and a
|
||||||
onClick = onClick,
|
// background on its modifier fills that whole target — 8dp taller than
|
||||||
// Only while it is live: a disabled button keeps Material's flat
|
// the 40dp container every other button draws. So the padding is taken
|
||||||
// disabled container, because a greyed control that still wears the
|
// here, outside the ramp, and the button inside is told not to add its
|
||||||
// one strong colour in the window reads as pressable.
|
// own. The box hands its size down, so a `fillMaxWidth` still reaches it.
|
||||||
modifier = if (enabled) modifier.background(accentBrush(), shape) else modifier,
|
Box(
|
||||||
enabled = enabled,
|
modifier.minimumInteractiveComponentSize(),
|
||||||
shape = shape,
|
contentAlignment = Alignment.Center,
|
||||||
colors = ButtonDefaults.buttonColors(containerColor = Color.Transparent),
|
propagateMinConstraints = true,
|
||||||
content = content,
|
) {
|
||||||
)
|
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) {
|
||||||
|
Button(
|
||||||
|
onClick = onClick,
|
||||||
|
// Only while it is live: a disabled button keeps Material's flat
|
||||||
|
// disabled container, because a greyed control that still wears the
|
||||||
|
// one strong colour in the window reads as pressable.
|
||||||
|
modifier = if (enabled) Modifier.background(accentBrush(), shape) else Modifier,
|
||||||
|
enabled = enabled,
|
||||||
|
shape = shape,
|
||||||
|
colors = ButtonDefaults.buttonColors(containerColor = Color.Transparent),
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@@ -56,6 +56,12 @@ object AppearanceState {
|
|||||||
.onSuccess { current.value = it }
|
.onSuccess { current.value = it }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Read the stored choice again, after sync rewrote it. */
|
||||||
|
suspend fun reload() {
|
||||||
|
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().palette() } }
|
||||||
|
.onSuccess { current.value = it }
|
||||||
|
}
|
||||||
|
|
||||||
/** Repaint immediately, before the write that stores the choice lands. */
|
/** Repaint immediately, before the write that stores the choice lands. */
|
||||||
fun apply(palette: PaletteRecord) {
|
fun apply(palette: PaletteRecord) {
|
||||||
current.value = palette
|
current.value = palette
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package io.narl.protonstream.download
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class TransferRateTest {
|
||||||
|
@Test
|
||||||
|
fun `the first sample has no rate to report`() {
|
||||||
|
assertEquals(0L, TransferRate().sample(4L * 1024 * 1024, 1_000))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a steady transfer reports its rate`() {
|
||||||
|
val rate = TransferRate()
|
||||||
|
rate.sample(0, 0)
|
||||||
|
assertEquals(1_000_000L, rate.sample(1_000_000, 1_000))
|
||||||
|
assertEquals(1_000_000L, rate.sample(2_000_000, 2_000))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `one slow block moves the rate without halving it`() {
|
||||||
|
val rate = TransferRate()
|
||||||
|
rate.sample(0, 0)
|
||||||
|
rate.sample(1_000_000, 1_000)
|
||||||
|
val after = rate.sample(2_000_000, 3_000)
|
||||||
|
assertTrue(after in 600_000L..999_999L)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a resumed download starting below the last sample starts over`() {
|
||||||
|
val rate = TransferRate()
|
||||||
|
rate.sample(0, 0)
|
||||||
|
rate.sample(1_000_000, 1_000)
|
||||||
|
assertEquals(1_000_000L, rate.sample(0, 2_000))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package io.narl.protonstream.ui
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class AccountUiTest {
|
||||||
|
@Test
|
||||||
|
fun `the sync line says how long ago and what came in`() {
|
||||||
|
val minute = 60_000L
|
||||||
|
assertEquals("Library, settings and watch history sync through your Drive", syncLine(AccountUiState(), 0))
|
||||||
|
assertEquals("Synced just now", syncLine(AccountUiState(syncedAt = 0), 30_000))
|
||||||
|
assertEquals("Synced 5 min ago", syncLine(AccountUiState(syncedAt = 0), 5 * minute))
|
||||||
|
assertEquals(
|
||||||
|
"Synced 2 h ago · 3 from other devices",
|
||||||
|
syncLine(AccountUiState(syncedAt = 0, applied = 3), 120 * minute),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a failed sync is said in place of when it last worked`() {
|
||||||
|
assertEquals(
|
||||||
|
"Did not sync: offline",
|
||||||
|
syncLine(AccountUiState(syncedAt = 0, syncError = "offline"), 0),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the verification page may only navigate within Proton over https`() {
|
||||||
|
assertTrue(isProtonPage("https", "verify.proton.me"))
|
||||||
|
assertTrue(isProtonPage("https", "proton.me"))
|
||||||
|
assertFalse(isProtonPage("http", "verify.proton.me"))
|
||||||
|
assertFalse(isProtonPage("https", "proton.me.example.com"))
|
||||||
|
assertFalse(isProtonPage("https", "notproton.me"))
|
||||||
|
assertFalse(isProtonPage("https", null))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -137,22 +137,22 @@ class ScreenshotTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `the shares page`() = capture("shares-phone") {
|
fun `the shares page`() = capture("shares-phone") {
|
||||||
SharesScreen(LIBRARY.shares, { _, _, _ -> }, { _, _, _ -> }, {}, {}, null, {}, PaddingValues())
|
SharesScreen(LIBRARY.shares, { _, _, _ -> null }, { _, _, _ -> }, {}, {}, null, {}, PaddingValues())
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `the settings page`() = capture("settings-phone") {
|
fun `the settings page`() = capture("settings-phone") {
|
||||||
SettingsScreen(LIBRARY, { _, _, _, _ -> }, {}, {}, {}, PaddingValues(), version = "1.0.0")
|
SettingsScreen(LIBRARY, { _, _, _, _ -> null }, {}, {}, {}, PaddingValues(), version = "1.0.0")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `the playback settings`() = capture("settings-playback") {
|
fun `the playback settings`() = capture("settings-playback") {
|
||||||
SettingsScreen(LIBRARY, { _, _, _, _ -> }, {}, {}, {}, PaddingValues(), SettingsPage.Playback)
|
SettingsScreen(LIBRARY, { _, _, _, _ -> null }, {}, {}, {}, PaddingValues(), SettingsPage.Playback)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `the storage settings`() = capture("settings-storage") {
|
fun `the storage settings`() = capture("settings-storage") {
|
||||||
SettingsScreen(LIBRARY, { _, _, _, _ -> }, {}, {}, {}, PaddingValues(), SettingsPage.Storage)
|
SettingsScreen(LIBRARY, { _, _, _, _ -> null }, {}, {}, {}, PaddingValues(), SettingsPage.Storage)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 813 KiB After Width: | Height: | Size: 813 KiB |
|
Before Width: | Height: | Size: 769 KiB After Width: | Height: | Size: 768 KiB |
|
Before Width: | Height: | Size: 807 KiB After Width: | Height: | Size: 806 KiB |
|
Before Width: | Height: | Size: 638 KiB After Width: | Height: | Size: 631 KiB |
|
Before Width: | Height: | Size: 678 KiB After Width: | Height: | Size: 669 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
@@ -24,6 +24,7 @@ pstr-core.workspace = true
|
|||||||
pstr-meta.workspace = true
|
pstr-meta.workspace = true
|
||||||
pstr-stream.workspace = true
|
pstr-stream.workspace = true
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
|
futures.workspace = true
|
||||||
parking_lot.workspace = true
|
parking_lot.workspace = true
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
uniffi.workspace = true
|
uniffi.workspace = true
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use std::sync::atomic::{AtomicU64, Ordering};
|
|||||||
use std::sync::{Arc, OnceLock};
|
use std::sync::{Arc, OnceLock};
|
||||||
|
|
||||||
use parking_lot::Mutex;
|
use parking_lot::Mutex;
|
||||||
|
use pstr_core::account::{DriveEntry, PendingSignIn, PlaceKind, SignIn};
|
||||||
use pstr_core::appearance::{Accent, Appearance, Flavor, Palette};
|
use pstr_core::appearance::{Accent, Appearance, Flavor, Palette};
|
||||||
use pstr_core::browse::Sort;
|
use pstr_core::browse::Sort;
|
||||||
use pstr_core::catalog::{Catalog, OfflineFile, TitleTrackPrefs, WatchState, build_rows};
|
use pstr_core::catalog::{Catalog, OfflineFile, TitleTrackPrefs, WatchState, build_rows};
|
||||||
@@ -20,12 +21,17 @@ use pstr_core::metadata::{
|
|||||||
EpisodeGuide, MetadataConfig, MetadataRecord, ProviderId, TitleMetadata, TitleNames,
|
EpisodeGuide, MetadataConfig, MetadataRecord, ProviderId, TitleMetadata, TitleNames,
|
||||||
};
|
};
|
||||||
use pstr_core::prefs::PlaybackPrefs;
|
use pstr_core::prefs::PlaybackPrefs;
|
||||||
use pstr_core::proton_drive_rs::{ProtonDrivePublicLinkClient, ThumbnailType};
|
use pstr_core::proton_drive_rs::ThumbnailType;
|
||||||
|
use pstr_core::proton_sdk::api::HumanVerificationCredential;
|
||||||
use pstr_core::proton_sdk::ids::{LinkId, VolumeId};
|
use pstr_core::proton_sdk::ids::{LinkId, VolumeId};
|
||||||
use pstr_core::{SecretStore, ShareStore, SharedLibrary};
|
use pstr_core::shares::AccountFolder;
|
||||||
|
use pstr_core::sync::WatchSync;
|
||||||
|
use pstr_core::{
|
||||||
|
Account, AccountStore, SecretStore, Share, ShareClient, ShareStore, SharedLibrary,
|
||||||
|
};
|
||||||
use pstr_stream::{
|
use pstr_stream::{
|
||||||
BlockSource, DiskCacheConfig, FileBlocks, LibraryOpener, NodeUid, StreamConfig, StreamSource,
|
BlockSource, COPY_BLOCKS_IN_FLIGHT, DiskCacheConfig, FileBlocks, LibraryOpener, NodeUid,
|
||||||
VideoStream,
|
StreamConfig, StreamSource, VideoStream,
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
@@ -131,6 +137,112 @@ pub struct ShareRecord {
|
|||||||
pub id: String,
|
pub id: String,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub has_custom_password: bool,
|
pub has_custom_password: bool,
|
||||||
|
/// A folder of the signed-in account rather than a public link.
|
||||||
|
#[uniffi(default = false)]
|
||||||
|
pub from_account: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn share_record(share: Share) -> ShareRecord {
|
||||||
|
ShareRecord {
|
||||||
|
from_account: share.folder.is_some(),
|
||||||
|
id: share.id,
|
||||||
|
name: share.name,
|
||||||
|
has_custom_password: share.has_custom_password,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where the Proton account stands.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Enum)]
|
||||||
|
pub enum AccountState {
|
||||||
|
SignedOut,
|
||||||
|
SignedIn {
|
||||||
|
username: String,
|
||||||
|
},
|
||||||
|
/// A sign-in is waiting for a second-factor code.
|
||||||
|
SecondFactor,
|
||||||
|
/// A sign-in is waiting for the mailbox password.
|
||||||
|
MailboxPassword,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a sign-in step led to.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Enum)]
|
||||||
|
pub enum SignInOutcome {
|
||||||
|
Done {
|
||||||
|
username: String,
|
||||||
|
},
|
||||||
|
SecondFactor,
|
||||||
|
MailboxPassword,
|
||||||
|
/// Proton wants a CAPTCHA first. Show `url` in a WebView, read the token
|
||||||
|
/// out of its messages with [`verification_token`], and sign in again with
|
||||||
|
/// it.
|
||||||
|
HumanVerification {
|
||||||
|
url: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, uniffi::Record)]
|
||||||
|
pub struct SyncRecord {
|
||||||
|
/// Episodes whose position another device had newer.
|
||||||
|
pub applied: u32,
|
||||||
|
pub uploaded: bool,
|
||||||
|
/// Other installations whose history was read.
|
||||||
|
pub devices: u32,
|
||||||
|
/// Shares another device added or changed, configured here and not yet
|
||||||
|
/// crawled. Crawl each.
|
||||||
|
pub shares_added: Vec<String>,
|
||||||
|
/// Shares another device removed, gone here with their offline files.
|
||||||
|
/// Drop any download state kept for them.
|
||||||
|
pub shares_removed: Vec<String>,
|
||||||
|
/// Whether settings changed: repaint, and re-read the metadata settings.
|
||||||
|
pub settings_changed: bool,
|
||||||
|
/// Hand-picked matches and per-show track choices taken.
|
||||||
|
pub titles: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)]
|
||||||
|
pub enum PlaceType {
|
||||||
|
MyFiles,
|
||||||
|
Device,
|
||||||
|
SharedWithMe,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, uniffi::Record)]
|
||||||
|
pub struct DrivePlaceRecord {
|
||||||
|
pub kind: PlaceType,
|
||||||
|
pub name: String,
|
||||||
|
pub volume_id: String,
|
||||||
|
pub link_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, uniffi::Record)]
|
||||||
|
pub struct DriveEntryRecord {
|
||||||
|
pub name: String,
|
||||||
|
pub volume_id: String,
|
||||||
|
pub link_id: String,
|
||||||
|
pub is_folder: bool,
|
||||||
|
pub size: Option<u64>,
|
||||||
|
pub is_video: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn drive_entry_record(entry: DriveEntry) -> DriveEntryRecord {
|
||||||
|
DriveEntryRecord {
|
||||||
|
is_video: entry
|
||||||
|
.media_type
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|media| media.starts_with("video/")),
|
||||||
|
name: entry.name,
|
||||||
|
volume_id: entry.uid.volume_id.as_str().to_owned(),
|
||||||
|
link_id: entry.uid.link_id.as_str().to_owned(),
|
||||||
|
is_folder: entry.is_folder,
|
||||||
|
size: entry.size.and_then(|size| u64::try_from(size).ok()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The CAPTCHA token in a message the verification page posted, or `None` for
|
||||||
|
/// one of its other messages. See [`SignInOutcome::HumanVerification`].
|
||||||
|
#[uniffi::export]
|
||||||
|
pub fn verification_token(message: String) -> Option<String> {
|
||||||
|
pstr_core::account::verification_token(&message)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, uniffi::Enum)]
|
#[derive(Debug, Clone, uniffi::Enum)]
|
||||||
@@ -602,6 +714,22 @@ impl<T> Drop for AbortOnDrop<T> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Why a link could not be added, in words a viewer can act on.
|
||||||
|
///
|
||||||
|
/// A link that is already in the library and one that is not a link at all
|
||||||
|
/// say so themselves; anything else failed at the handshake, where a wrong
|
||||||
|
/// custom password and an expired link look the same from here.
|
||||||
|
fn add_share_failure(error: &pstr_core::Error) -> String {
|
||||||
|
match error {
|
||||||
|
pstr_core::Error::Config(reason) => reason.clone(),
|
||||||
|
pstr_core::Error::Drive(_) => format!(
|
||||||
|
"Could not open that link: {error}. Check that it is complete, \
|
||||||
|
still shared, and that the password is right."
|
||||||
|
),
|
||||||
|
other => other.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// `runtime.spawn(task)`, with the handle wrapped so the task is aborted if the
|
/// `runtime.spawn(task)`, with the handle wrapped so the task is aborted if the
|
||||||
/// caller stops waiting. Awaits to exactly what `JoinHandle` does.
|
/// caller stops waiting. Awaits to exactly what `JoinHandle` does.
|
||||||
fn spawned<T: Send + 'static>(
|
fn spawned<T: Send + 'static>(
|
||||||
@@ -811,7 +939,7 @@ pub struct AndroidEngine {
|
|||||||
/// Held across the open handshakes so concurrent callers share one build.
|
/// Held across the open handshakes so concurrent callers share one build.
|
||||||
connecting: tokio::sync::Mutex<()>,
|
connecting: tokio::sync::Mutex<()>,
|
||||||
/// Clients carried over from a retired connection, for the next build.
|
/// Clients carried over from a retired connection, for the next build.
|
||||||
reusable_clients: Mutex<Option<BTreeMap<String, ProtonDrivePublicLinkClient>>>,
|
reusable_clients: Mutex<Option<BTreeMap<String, ShareClient>>>,
|
||||||
/// The whole library as bridge records, behind the catalog's write counter.
|
/// The whole library as bridge records, behind the catalog's write counter.
|
||||||
library_cache: Mutex<Option<CachedLibrary>>,
|
library_cache: Mutex<Option<CachedLibrary>>,
|
||||||
/// Serializes offline publication with share removal cleanup.
|
/// Serializes offline publication with share removal cleanup.
|
||||||
@@ -830,8 +958,30 @@ pub struct AndroidEngine {
|
|||||||
/// The streaming cache's budget in bytes, for the next connection and,
|
/// The streaming cache's budget in bytes, for the next connection and,
|
||||||
/// through [`AndroidEngine::set_stream_cache_budget`], the open one.
|
/// through [`AndroidEngine::set_stream_cache_budget`], the open one.
|
||||||
cache_budget: AtomicU64,
|
cache_budget: AtomicU64,
|
||||||
|
accounts: AccountStore,
|
||||||
|
/// The signed-in Proton account, if any.
|
||||||
|
account: Mutex<Option<Account>>,
|
||||||
|
/// A sign-in waiting for a code or the mailbox password.
|
||||||
|
pending_sign_in: Mutex<Option<PendingSignIn>>,
|
||||||
|
/// `None` when this installation's sync identity could not be written.
|
||||||
|
watch_sync: Option<Arc<WatchSync>>,
|
||||||
|
/// How many offline downloads may transfer at once.
|
||||||
|
///
|
||||||
|
/// WorkManager starts every queued worker whose constraints are met, so
|
||||||
|
/// "Download season" on twelve episodes was twelve transfers splitting one
|
||||||
|
/// link: each crawled, and none reported progress for long enough to look
|
||||||
|
/// stuck. The rest wait here, reported as queued, and start as one ends.
|
||||||
|
download_permits: Arc<tokio::sync::Semaphore>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How many offline downloads transfer at once. See
|
||||||
|
/// [`AndroidEngine::download_permits`]; the desktop runs three, a phone link
|
||||||
|
/// is better served by two.
|
||||||
|
const OFFLINE_DOWNLOAD_CONCURRENCY: usize = 2;
|
||||||
|
|
||||||
|
/// How often a download waiting for a slot asks whether it was cancelled.
|
||||||
|
const DOWNLOAD_WAIT_POLL: std::time::Duration = std::time::Duration::from_millis(500);
|
||||||
|
|
||||||
/// How long a visitor session is assumed good for without a refresh.
|
/// How long a visitor session is assumed good for without a refresh.
|
||||||
///
|
///
|
||||||
/// A refresh costs one round trip. Not refreshing costs an authentication
|
/// A refresh costs one round trip. Not refreshing costs an authentication
|
||||||
@@ -854,6 +1004,17 @@ impl AndroidEngine {
|
|||||||
Arc::clone(&secret_store),
|
Arc::clone(&secret_store),
|
||||||
));
|
));
|
||||||
let catalog = Catalog::open(&dirs.catalog_db()).map_err(BridgeError::from_display)?;
|
let catalog = Catalog::open(&dirs.catalog_db()).map_err(BridgeError::from_display)?;
|
||||||
|
let accounts = AccountStore::new(Arc::clone(&secret_store));
|
||||||
|
// An unreadable session is a signed-out app, not one that will not
|
||||||
|
// start: the library is the point, and the account is an extra.
|
||||||
|
let account = accounts.resume().unwrap_or_else(|error| {
|
||||||
|
log::warn!("resume the Proton session: {error}");
|
||||||
|
None
|
||||||
|
});
|
||||||
|
let watch_sync = WatchSync::open(&dirs)
|
||||||
|
.inspect_err(|error| log::warn!("watch-history sync unavailable: {error}"))
|
||||||
|
.ok()
|
||||||
|
.map(Arc::new);
|
||||||
let runtime = tokio::runtime::Builder::new_multi_thread()
|
let runtime = tokio::runtime::Builder::new_multi_thread()
|
||||||
.enable_all()
|
.enable_all()
|
||||||
.build()
|
.build()
|
||||||
@@ -875,41 +1036,293 @@ impl AndroidEngine {
|
|||||||
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),
|
cache_budget: AtomicU64::new(DiskCacheConfig::DEFAULT_BUDGET_BYTES),
|
||||||
|
download_permits: Arc::new(tokio::sync::Semaphore::new(OFFLINE_DOWNLOAD_CONCURRENCY)),
|
||||||
|
accounts,
|
||||||
|
account: Mutex::new(account),
|
||||||
|
pending_sign_in: Mutex::new(None),
|
||||||
|
watch_sync,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn account_state(&self) -> AccountState {
|
||||||
|
if let Some(account) = self.account.lock().as_ref() {
|
||||||
|
return AccountState::SignedIn {
|
||||||
|
username: account.username().to_owned(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// A pending sign-in is only ever reached through `sign_in`, whose
|
||||||
|
// outcome already said which of the two it is waiting for; the
|
||||||
|
// distinction is not kept, and a screen rebuilt mid-sign-in starts it
|
||||||
|
// over.
|
||||||
|
AccountState::SignedOut
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start signing in. `verification_token` is the CAPTCHA answer, after a
|
||||||
|
/// first attempt came back [`SignInOutcome::HumanVerification`].
|
||||||
|
pub async fn sign_in(
|
||||||
|
self: Arc<Self>,
|
||||||
|
username: String,
|
||||||
|
password: String,
|
||||||
|
verification_token: Option<String>,
|
||||||
|
) -> Result<SignInOutcome, BridgeError> {
|
||||||
|
let runtime = Arc::clone(&self.runtime);
|
||||||
|
spawned(&runtime, async move {
|
||||||
|
let verification = verification_token.map(HumanVerificationCredential::captcha);
|
||||||
|
match self
|
||||||
|
.accounts
|
||||||
|
.sign_in(&username, &password, verification)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(step) => Ok(self.sign_in_step(step)),
|
||||||
|
Err(pstr_core::Error::HumanVerification(challenge)) => {
|
||||||
|
Ok(SignInOutcome::HumanVerification {
|
||||||
|
url: challenge.verification_url(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Err(error) => Err(BridgeError::from_display(error)),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(BridgeError::from_display)?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A wrong code ends the attempt: the half-made session is spent, and the
|
||||||
|
/// viewer signs in again from the start.
|
||||||
|
pub async fn submit_second_factor(
|
||||||
|
self: Arc<Self>,
|
||||||
|
code: String,
|
||||||
|
) -> Result<SignInOutcome, BridgeError> {
|
||||||
|
let runtime = Arc::clone(&self.runtime);
|
||||||
|
spawned(&runtime, async move {
|
||||||
|
let pending =
|
||||||
|
self.pending_sign_in
|
||||||
|
.lock()
|
||||||
|
.take()
|
||||||
|
.ok_or_else(|| BridgeError::Failure {
|
||||||
|
reason: "that sign-in expired; start again".to_owned(),
|
||||||
|
})?;
|
||||||
|
let step = pending
|
||||||
|
.second_factor(&code)
|
||||||
|
.await
|
||||||
|
.map_err(BridgeError::from_display)?;
|
||||||
|
Ok(self.sign_in_step(step))
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(BridgeError::from_display)?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A wrong mailbox password keeps the sign-in, so it can be typed again.
|
||||||
|
pub async fn submit_mailbox_password(
|
||||||
|
self: Arc<Self>,
|
||||||
|
password: String,
|
||||||
|
) -> Result<SignInOutcome, BridgeError> {
|
||||||
|
let runtime = Arc::clone(&self.runtime);
|
||||||
|
spawned(&runtime, async move {
|
||||||
|
let pending =
|
||||||
|
self.pending_sign_in
|
||||||
|
.lock()
|
||||||
|
.take()
|
||||||
|
.ok_or_else(|| BridgeError::Failure {
|
||||||
|
reason: "that sign-in expired; start again".to_owned(),
|
||||||
|
})?;
|
||||||
|
match pending.mailbox_password(&password).await {
|
||||||
|
Ok(account) => Ok(self.sign_in_step(SignIn::Done(account))),
|
||||||
|
Err(error) => {
|
||||||
|
*self.pending_sign_in.lock() = Some(pending);
|
||||||
|
Err(BridgeError::from_display(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(BridgeError::from_display)?
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cancel_sign_in(&self) {
|
||||||
|
self.pending_sign_in.lock().take();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sign out and forget the stored session. The library keeps everything;
|
||||||
|
/// the account's folders stop opening until the next sign-in.
|
||||||
|
pub async fn sign_out(self: Arc<Self>) -> Result<(), BridgeError> {
|
||||||
|
let runtime = Arc::clone(&self.runtime);
|
||||||
|
spawned(&runtime, async move {
|
||||||
|
let account = self.account.lock().take();
|
||||||
|
let result = self.accounts.sign_out(account.as_ref()).await;
|
||||||
|
self.signed_out();
|
||||||
|
result.map_err(BridgeError::from_display)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(BridgeError::from_display)?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sync watch history with the account's Drive. `None` when nobody is
|
||||||
|
/// signed in.
|
||||||
|
///
|
||||||
|
/// A session that has ended — revoked elsewhere, or idle too long — signs
|
||||||
|
/// the app out and fails with why, rather than failing the same way on
|
||||||
|
/// every later call.
|
||||||
|
pub async fn sync_watch_history(self: Arc<Self>) -> Result<Option<SyncRecord>, BridgeError> {
|
||||||
|
let runtime = Arc::clone(&self.runtime);
|
||||||
|
spawned(&runtime, async move {
|
||||||
|
let (Some(account), Some(sync)) =
|
||||||
|
(self.account.lock().clone(), self.watch_sync.clone())
|
||||||
|
else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
match sync.sync(&account, &self.catalog, &self.store).await {
|
||||||
|
Ok(report) => {
|
||||||
|
let profile = report.profile;
|
||||||
|
for id in profile.shares_added.iter().chain(&profile.shares_changed) {
|
||||||
|
self.invalidate_connection(id);
|
||||||
|
}
|
||||||
|
for id in &profile.shares_removed {
|
||||||
|
if let Err(error) = self.forget_synced_share(id) {
|
||||||
|
log::warn!("files of {id}, a share removed elsewhere: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Some(SyncRecord {
|
||||||
|
applied: u32::try_from(report.applied).unwrap_or(u32::MAX),
|
||||||
|
uploaded: report.uploaded,
|
||||||
|
devices: u32::try_from(report.devices).unwrap_or(u32::MAX),
|
||||||
|
shares_added: profile
|
||||||
|
.shares_added
|
||||||
|
.into_iter()
|
||||||
|
.chain(profile.shares_changed)
|
||||||
|
.collect(),
|
||||||
|
shares_removed: profile.shares_removed,
|
||||||
|
settings_changed: profile.settings,
|
||||||
|
titles: u32::try_from(profile.titles).unwrap_or(u32::MAX),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
Err(error) if error.is_signed_out() => {
|
||||||
|
let _ = self.accounts.sign_out(None).await;
|
||||||
|
self.signed_out();
|
||||||
|
Err(BridgeError::Failure {
|
||||||
|
reason: "Your Proton session ended. Sign in again to keep syncing."
|
||||||
|
.to_owned(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Err(error) => Err(BridgeError::from_display(error)),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(BridgeError::from_display)?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The account's top-level places: My Files, devices, shared with me.
|
||||||
|
pub async fn drive_places(self: Arc<Self>) -> Result<Vec<DrivePlaceRecord>, BridgeError> {
|
||||||
|
let runtime = Arc::clone(&self.runtime);
|
||||||
|
spawned(&runtime, async move {
|
||||||
|
let account = self.signed_in_account()?;
|
||||||
|
let places = account.places().await.map_err(BridgeError::from_display)?;
|
||||||
|
Ok(places
|
||||||
|
.into_iter()
|
||||||
|
.map(|place| DrivePlaceRecord {
|
||||||
|
kind: match place.kind {
|
||||||
|
PlaceKind::MyFiles => PlaceType::MyFiles,
|
||||||
|
PlaceKind::Device => PlaceType::Device,
|
||||||
|
PlaceKind::SharedWithMe => PlaceType::SharedWithMe,
|
||||||
|
},
|
||||||
|
name: place.name,
|
||||||
|
volume_id: place.uid.volume_id.as_str().to_owned(),
|
||||||
|
link_id: place.uid.link_id.as_str().to_owned(),
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(BridgeError::from_display)?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One folder of the account: folders first, then files.
|
||||||
|
pub async fn drive_folder(
|
||||||
|
self: Arc<Self>,
|
||||||
|
volume_id: String,
|
||||||
|
link_id: String,
|
||||||
|
) -> Result<Vec<DriveEntryRecord>, BridgeError> {
|
||||||
|
let runtime = Arc::clone(&self.runtime);
|
||||||
|
spawned(&runtime, async move {
|
||||||
|
let account = self.signed_in_account()?;
|
||||||
|
let entries = account
|
||||||
|
.folder_children(&node_uid(&volume_id, &link_id))
|
||||||
|
.await
|
||||||
|
.map_err(BridgeError::from_display)?;
|
||||||
|
Ok(entries.into_iter().map(drive_entry_record).collect())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(BridgeError::from_display)?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a folder of the account as a share. Crawl it next, as after
|
||||||
|
/// [`Self::add_share`].
|
||||||
|
pub fn add_account_folder(
|
||||||
|
&self,
|
||||||
|
name: String,
|
||||||
|
volume_id: String,
|
||||||
|
link_id: String,
|
||||||
|
) -> Result<ShareRecord, BridgeError> {
|
||||||
|
let share = self
|
||||||
|
.store
|
||||||
|
.add_folder(&name, AccountFolder { volume_id, link_id })
|
||||||
|
.map_err(BridgeError::from_display)?;
|
||||||
|
self.invalidate_connection(&share.id);
|
||||||
|
Ok(share_record(share))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configured shares with nothing in the catalog: brought in by sync,
|
||||||
|
/// possibly while the app was in the background, or added here and never
|
||||||
|
/// crawled to the end. Crawl each. An empty share is crawled every time,
|
||||||
|
/// which costs one listing.
|
||||||
|
pub fn uncrawled_shares(&self) -> Result<Vec<String>, BridgeError> {
|
||||||
|
let shares = self.store.list().map_err(BridgeError::from_display)?;
|
||||||
|
let catalog = self.catalog.lock();
|
||||||
|
let mut uncrawled = Vec::new();
|
||||||
|
for share in shares {
|
||||||
|
if catalog
|
||||||
|
.files(&share.id)
|
||||||
|
.map_err(BridgeError::from_display)?
|
||||||
|
.is_empty()
|
||||||
|
{
|
||||||
|
uncrawled.push(share.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(uncrawled)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn shares(&self) -> Result<Vec<ShareRecord>, BridgeError> {
|
pub fn shares(&self) -> Result<Vec<ShareRecord>, BridgeError> {
|
||||||
self.store
|
self.store
|
||||||
.list()
|
.list()
|
||||||
.map_err(BridgeError::from_display)
|
.map_err(BridgeError::from_display)
|
||||||
.map(|shares| {
|
.map(|shares| shares.into_iter().map(share_record).collect())
|
||||||
shares
|
|
||||||
.into_iter()
|
|
||||||
.map(|share| ShareRecord {
|
|
||||||
id: share.id,
|
|
||||||
name: share.name,
|
|
||||||
has_custom_password: share.has_custom_password,
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn add_share(
|
/// Add a public link, once it has opened with the password given. A link
|
||||||
&self,
|
/// that will not open is refused and leaves nothing behind. Crawl the
|
||||||
|
/// returned share next.
|
||||||
|
pub async fn add_share(
|
||||||
|
self: Arc<Self>,
|
||||||
name: String,
|
name: String,
|
||||||
url: String,
|
url: String,
|
||||||
custom_password: Option<String>,
|
custom_password: Option<String>,
|
||||||
) -> Result<ShareRecord, BridgeError> {
|
) -> Result<ShareRecord, BridgeError> {
|
||||||
let share = self
|
let runtime = Arc::clone(&self.runtime);
|
||||||
.store
|
spawned(&runtime, async move {
|
||||||
.add(&name, &url, custom_password.as_deref())
|
let (share, client) = self
|
||||||
.map_err(BridgeError::from_display)?;
|
.store
|
||||||
self.invalidate_connection(&share.id);
|
.add_verified(&name, &url, custom_password.as_deref())
|
||||||
Ok(ShareRecord {
|
.await
|
||||||
id: share.id,
|
.map_err(|error| BridgeError::Failure {
|
||||||
name: share.name,
|
reason: add_share_failure(&error),
|
||||||
has_custom_password: share.has_custom_password,
|
})?;
|
||||||
|
self.invalidate_connection(&share.id);
|
||||||
|
// The handshake that proved the link is the one the crawl uses.
|
||||||
|
self.reusable_clients
|
||||||
|
.lock()
|
||||||
|
.get_or_insert_with(Default::default)
|
||||||
|
.insert(share.id.clone(), client);
|
||||||
|
Ok(share_record(share))
|
||||||
})
|
})
|
||||||
|
.await
|
||||||
|
.map_err(BridgeError::from_display)?
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Re-supply the link behind a share whose stored secret is unreadable.
|
/// Re-supply the link behind a share whose stored secret is unreadable.
|
||||||
@@ -928,11 +1341,7 @@ impl AndroidEngine {
|
|||||||
.replace_secrets(&share_id, &url, custom_password.as_deref())
|
.replace_secrets(&share_id, &url, custom_password.as_deref())
|
||||||
.map_err(BridgeError::from_display)?;
|
.map_err(BridgeError::from_display)?;
|
||||||
self.invalidate_connection(&share.id);
|
self.invalidate_connection(&share.id);
|
||||||
Ok(ShareRecord {
|
Ok(share_record(share))
|
||||||
id: share.id,
|
|
||||||
name: share.name,
|
|
||||||
has_custom_password: share.has_custom_password,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn remove_share(&self, share_id: String) -> Result<(), BridgeError> {
|
pub fn remove_share(&self, share_id: String) -> Result<(), BridgeError> {
|
||||||
@@ -963,28 +1372,7 @@ impl AndroidEngine {
|
|||||||
// when secret cleanup fails, old authenticated clients and catalog
|
// when secret cleanup fails, old authenticated clients and catalog
|
||||||
// rows must not remain usable.
|
// rows must not remain usable.
|
||||||
self.invalidate_connection(&share_id);
|
self.invalidate_connection(&share_id);
|
||||||
|
self.forget_share_files(&share_id, offline, links)?;
|
||||||
for ((stored_share_id, link_id), file) in offline {
|
|
||||||
if stored_share_id == share_id {
|
|
||||||
let path = self
|
|
||||||
.dirs
|
|
||||||
.offline_file(&share_id, &link_id, &file.revision_id);
|
|
||||||
match std::fs::remove_file(&path) {
|
|
||||||
Ok(()) => {}
|
|
||||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
|
||||||
Err(error) => return Err(BridgeError::from_display(error)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for link_id in links {
|
|
||||||
let (partial, marker) = self.partial_paths(&share_id, &link_id);
|
|
||||||
let _ = std::fs::remove_file(partial);
|
|
||||||
let _ = std::fs::remove_file(marker);
|
|
||||||
}
|
|
||||||
self.catalog
|
|
||||||
.lock()
|
|
||||||
.remove_share(&share_id)
|
|
||||||
.map_err(BridgeError::from_display)?;
|
|
||||||
store_result.map_err(BridgeError::from_display)
|
store_result.map_err(BridgeError::from_display)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1163,19 +1551,47 @@ impl AndroidEngine {
|
|||||||
.map_err(BridgeError::from_display)?
|
.map_err(BridgeError::from_display)?
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn choose_match(&self, title_key: String, found: MatchRecord) -> Result<(), BridgeError> {
|
/// Pin a title to the entry the viewer picked, then fetch that entry's
|
||||||
let service = self.metadata_service()?;
|
/// episodes, backdrop and franchise chain — the same enrichment a match run
|
||||||
let metadata = title_metadata(found);
|
/// does, so the title page shows the new match's episodes as soon as this
|
||||||
if metadata.provider != service.provider() {
|
/// returns rather than after the next run.
|
||||||
return Err(BridgeError::Failure {
|
pub async fn choose_match(
|
||||||
reason: "match came from a different metadata provider".to_owned(),
|
self: Arc<Self>,
|
||||||
});
|
title_key: String,
|
||||||
}
|
found: MatchRecord,
|
||||||
self.title(&title_key)?;
|
) -> Result<(), BridgeError> {
|
||||||
self.catalog
|
let runtime = Arc::clone(&self.runtime);
|
||||||
.lock()
|
spawned(&runtime, async move {
|
||||||
.set_metadata(&service.chosen(title_key, metadata))
|
let service = self.metadata_service()?;
|
||||||
.map_err(BridgeError::from_display)
|
let metadata = title_metadata(found);
|
||||||
|
if metadata.provider != service.provider() {
|
||||||
|
return Err(BridgeError::Failure {
|
||||||
|
reason: "match came from a different metadata provider".to_owned(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let title = self.title(&title_key)?;
|
||||||
|
let outcome = service.choose(&title, metadata).await;
|
||||||
|
if let Some(record) = &outcome.record {
|
||||||
|
self.catalog
|
||||||
|
.lock()
|
||||||
|
.set_metadata(record)
|
||||||
|
.map_err(BridgeError::from_display)?;
|
||||||
|
}
|
||||||
|
if let Some(enrichment) = &outcome.enrichment {
|
||||||
|
let stored = self.catalog.lock().set_enrichment(
|
||||||
|
&title_key,
|
||||||
|
service.provider(),
|
||||||
|
now(),
|
||||||
|
enrichment,
|
||||||
|
);
|
||||||
|
if let Err(error) = stored {
|
||||||
|
log::warn!("store enrichment for {title_key}: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(BridgeError::from_display)?
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn forget_match(&self, title_key: String) -> Result<(), BridgeError> {
|
pub fn forget_match(&self, title_key: String) -> Result<(), BridgeError> {
|
||||||
@@ -1545,6 +1961,56 @@ impl AndroidEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl AndroidEngine {
|
impl AndroidEngine {
|
||||||
|
/// Delete a removed share's offline and partial files, then its catalog
|
||||||
|
/// rows. Watch state stays; see `Catalog::remove_share`.
|
||||||
|
fn forget_share_files(
|
||||||
|
&self,
|
||||||
|
share_id: &str,
|
||||||
|
offline: HashMap<(String, String), OfflineFile>,
|
||||||
|
links: Vec<String>,
|
||||||
|
) -> Result<(), BridgeError> {
|
||||||
|
for ((stored_share_id, link_id), file) in offline {
|
||||||
|
if stored_share_id == share_id {
|
||||||
|
let path = self
|
||||||
|
.dirs
|
||||||
|
.offline_file(share_id, &link_id, &file.revision_id);
|
||||||
|
match std::fs::remove_file(&path) {
|
||||||
|
Ok(()) => {}
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
||||||
|
Err(error) => return Err(BridgeError::from_display(error)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for link_id in links {
|
||||||
|
let (partial, marker) = self.partial_paths(share_id, &link_id);
|
||||||
|
let _ = std::fs::remove_file(partial);
|
||||||
|
let _ = std::fs::remove_file(marker);
|
||||||
|
}
|
||||||
|
self.catalog
|
||||||
|
.lock()
|
||||||
|
.remove_share(share_id)
|
||||||
|
.map_err(BridgeError::from_display)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`Self::forget_share_files`] for a share another device removed, which
|
||||||
|
/// sync has already taken out of the configuration.
|
||||||
|
fn forget_synced_share(&self, share_id: &str) -> Result<(), BridgeError> {
|
||||||
|
let _publication = self.share_publication.lock();
|
||||||
|
let catalog = self.catalog.lock();
|
||||||
|
let offline = catalog
|
||||||
|
.all_offline_files()
|
||||||
|
.map_err(BridgeError::from_display)?;
|
||||||
|
let links: Vec<String> = catalog
|
||||||
|
.files(share_id)
|
||||||
|
.map_err(BridgeError::from_display)?
|
||||||
|
.into_iter()
|
||||||
|
.map(|node| node.link_id)
|
||||||
|
.collect();
|
||||||
|
drop(catalog);
|
||||||
|
self.invalidate_connection(share_id);
|
||||||
|
self.forget_share_files(share_id, offline, links)
|
||||||
|
}
|
||||||
|
|
||||||
/// One enrichment pass over the whole library.
|
/// One enrichment pass over the whole library.
|
||||||
///
|
///
|
||||||
/// What to do per title is decided by `pstr_meta::service::plan`, the same
|
/// What to do per title is decided by `pstr_meta::service::plan`, the same
|
||||||
@@ -1754,6 +2220,46 @@ impl AndroidEngine {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn sign_in_step(&self, step: SignIn) -> SignInOutcome {
|
||||||
|
match step {
|
||||||
|
SignIn::Done(account) => {
|
||||||
|
let username = account.username().to_owned();
|
||||||
|
*self.account.lock() = Some(account);
|
||||||
|
if let Some(sync) = &self.watch_sync {
|
||||||
|
sync.reset();
|
||||||
|
}
|
||||||
|
// Folders of the account that failed to open are openable now.
|
||||||
|
self.invalidate_connection("");
|
||||||
|
SignInOutcome::Done { username }
|
||||||
|
}
|
||||||
|
SignIn::SecondFactor(pending) => {
|
||||||
|
*self.pending_sign_in.lock() = Some(pending);
|
||||||
|
SignInOutcome::SecondFactor
|
||||||
|
}
|
||||||
|
SignIn::MailboxPassword(pending) => {
|
||||||
|
*self.pending_sign_in.lock() = Some(pending);
|
||||||
|
SignInOutcome::MailboxPassword
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn signed_out(&self) {
|
||||||
|
self.account.lock().take();
|
||||||
|
if let Some(sync) = &self.watch_sync {
|
||||||
|
sync.reset();
|
||||||
|
}
|
||||||
|
self.invalidate_connection("");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn signed_in_account(&self) -> Result<Account, BridgeError> {
|
||||||
|
self.account
|
||||||
|
.lock()
|
||||||
|
.clone()
|
||||||
|
.ok_or_else(|| BridgeError::Failure {
|
||||||
|
reason: "not signed in to Proton".to_owned(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Retire the cached connection after a mutation of `share_id`.
|
/// Retire the cached connection after a mutation of `share_id`.
|
||||||
///
|
///
|
||||||
/// The cached `Connection` is dropped here rather than left to be noticed
|
/// The cached `Connection` is dropped here rather than left to be noticed
|
||||||
@@ -1867,9 +2373,11 @@ impl AndroidEngine {
|
|||||||
let generation = self.share_generation.load(Ordering::Acquire);
|
let generation = self.share_generation.load(Ordering::Acquire);
|
||||||
let reusable = self.reusable_clients.lock().take().unwrap_or_default();
|
let reusable = self.reusable_clients.lock().take().unwrap_or_default();
|
||||||
|
|
||||||
let (library, failures) = SharedLibrary::open_all_reusing(&self.store, reusable)
|
let account = self.account.lock().clone();
|
||||||
.await
|
let (library, failures) =
|
||||||
.map_err(BridgeError::from_display)?;
|
SharedLibrary::open_all_reusing(&self.store, reusable, account.as_ref())
|
||||||
|
.await
|
||||||
|
.map_err(BridgeError::from_display)?;
|
||||||
let open_failures: Vec<String> = failures
|
let open_failures: Vec<String> = failures
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(share, error)| format!("{}: {error}", share.name))
|
.map(|(share, error)| format!("{}: {error}", share.name))
|
||||||
@@ -1923,6 +2431,27 @@ impl AndroidEngine {
|
|||||||
share_id: &str,
|
share_id: &str,
|
||||||
uid: &NodeUid,
|
uid: &NodeUid,
|
||||||
) -> Result<VideoStream, BridgeError> {
|
) -> Result<VideoStream, BridgeError> {
|
||||||
|
self.open_reauthenticating(connection, share_id, uid, false)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`Self::open_from_source`], or with `copy` the stream an offline
|
||||||
|
/// download reads: no read-ahead, nothing added to the memory ring or the
|
||||||
|
/// disk cache. See [`StreamSource::open_for_copy`].
|
||||||
|
async fn open_reauthenticating(
|
||||||
|
&self,
|
||||||
|
connection: &Connection,
|
||||||
|
share_id: &str,
|
||||||
|
uid: &NodeUid,
|
||||||
|
copy: bool,
|
||||||
|
) -> Result<VideoStream, BridgeError> {
|
||||||
|
let open = || async {
|
||||||
|
if copy {
|
||||||
|
connection.source.open_for_copy(share_id, uid).await
|
||||||
|
} else {
|
||||||
|
connection.source.open(share_id, uid).await
|
||||||
|
}
|
||||||
|
};
|
||||||
let stale = self
|
let stale = self
|
||||||
.session_refreshed
|
.session_refreshed
|
||||||
.lock()
|
.lock()
|
||||||
@@ -1931,15 +2460,11 @@ impl AndroidEngine {
|
|||||||
if stale {
|
if stale {
|
||||||
self.refresh_session(connection, share_id).await;
|
self.refresh_session(connection, share_id).await;
|
||||||
}
|
}
|
||||||
match connection.source.open(share_id, uid).await {
|
match open().await {
|
||||||
Ok(stream) => Ok(stream),
|
Ok(stream) => Ok(stream),
|
||||||
Err(_) if !stale => {
|
Err(_) if !stale => {
|
||||||
self.refresh_session(connection, share_id).await;
|
self.refresh_session(connection, share_id).await;
|
||||||
connection
|
open().await.map_err(BridgeError::from_display)
|
||||||
.source
|
|
||||||
.open(share_id, uid)
|
|
||||||
.await
|
|
||||||
.map_err(BridgeError::from_display)
|
|
||||||
}
|
}
|
||||||
Err(error) => Err(BridgeError::from_display(error)),
|
Err(error) => Err(BridgeError::from_display(error)),
|
||||||
}
|
}
|
||||||
@@ -2071,9 +2596,26 @@ impl AndroidEngine {
|
|||||||
return Ok(record);
|
return Ok(record);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Nothing is reported until a slot is held, so the worker's record
|
||||||
|
// stays queued while it waits rather than claiming to be running.
|
||||||
|
let _permit = loop {
|
||||||
|
if cancelled(observer.as_ref()) {
|
||||||
|
return Err(BridgeError::Failure {
|
||||||
|
reason: "offline download cancelled".to_owned(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let permits = Arc::clone(&self.download_permits);
|
||||||
|
tokio::select! {
|
||||||
|
permit = permits.acquire_owned() => {
|
||||||
|
break permit.map_err(BridgeError::from_display)?;
|
||||||
|
}
|
||||||
|
() = tokio::time::sleep(DOWNLOAD_WAIT_POLL) => {}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let connection = self.connection().await?;
|
let connection = self.connection().await?;
|
||||||
let stream = self
|
let stream = self
|
||||||
.open_from_source(&connection, share_id, &node_uid(volume_id, link_id))
|
.open_reauthenticating(&connection, share_id, &node_uid(volume_id, link_id), true)
|
||||||
.await?;
|
.await?;
|
||||||
let revision_id = stream.revision_id().to_owned();
|
let revision_id = stream.revision_id().to_owned();
|
||||||
let block_sizes = stream.block_sizes().to_vec();
|
let block_sizes = stream.block_sizes().to_vec();
|
||||||
@@ -2125,7 +2667,13 @@ impl AndroidEngine {
|
|||||||
let mut output = prepare_partial_file(&temporary, offset).await?;
|
let mut output = prepare_partial_file(&temporary, offset).await?;
|
||||||
report(observer.as_ref(), offset, total);
|
report(observer.as_ref(), offset, total);
|
||||||
|
|
||||||
|
use futures::TryStreamExt as _;
|
||||||
use tokio::io::AsyncWriteExt;
|
use tokio::io::AsyncWriteExt;
|
||||||
|
// A few blocks in flight, written in order. One at a time the copy ran
|
||||||
|
// at one round trip per 4 MiB; through the playback stream it carried
|
||||||
|
// twelve blocks of read-ahead, so the first block — and with it the
|
||||||
|
// first progress report — waited on all twelve sharing the link.
|
||||||
|
let mut fetches = stream.blocks_in_order(block_index, COPY_BLOCKS_IN_FLIGHT);
|
||||||
while block_index < block_sizes.len() {
|
while block_index < block_sizes.len() {
|
||||||
if cancelled(observer.as_ref()) {
|
if cancelled(observer.as_ref()) {
|
||||||
output.sync_all().await.map_err(BridgeError::from_display)?;
|
output.sync_all().await.map_err(BridgeError::from_display)?;
|
||||||
@@ -2134,10 +2682,13 @@ impl AndroidEngine {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
let size = block_sizes[block_index];
|
let size = block_sizes[block_index];
|
||||||
let bytes = stream
|
let bytes = fetches
|
||||||
.read_range(offset, size)
|
.try_next()
|
||||||
.await
|
.await
|
||||||
.map_err(BridgeError::from_display)?;
|
.map_err(BridgeError::from_display)?
|
||||||
|
.ok_or_else(|| BridgeError::Failure {
|
||||||
|
reason: format!("offline block {block_index} never arrived"),
|
||||||
|
})?;
|
||||||
if bytes.len() as u64 != size {
|
if bytes.len() as u64 != size {
|
||||||
return Err(BridgeError::Failure {
|
return Err(BridgeError::Failure {
|
||||||
reason: format!(
|
reason: format!(
|
||||||
@@ -3324,13 +3875,13 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.expect("engine");
|
.expect("engine");
|
||||||
|
|
||||||
|
// Stored directly: `add_share` opens the link first, and a test has
|
||||||
|
// no network to open it over.
|
||||||
let share = engine
|
let share = engine
|
||||||
.add_share(
|
.store
|
||||||
"test".to_owned(),
|
.add("test", "https://drive.proton.me/urls/ABC123#s3cr3t", None)
|
||||||
"https://drive.proton.me/urls/ABC123#s3cr3t".to_owned(),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.expect("add share");
|
.expect("add share");
|
||||||
|
engine.invalidate_connection(&share.id);
|
||||||
assert_eq!(engine.share_generation.load(Ordering::Acquire), 1);
|
assert_eq!(engine.share_generation.load(Ordering::Acquire), 1);
|
||||||
assert!(
|
assert!(
|
||||||
engine
|
engine
|
||||||
@@ -3368,11 +3919,12 @@ mod tests {
|
|||||||
assert!(engine.remove_share(share.id.clone()).is_err());
|
assert!(engine.remove_share(share.id.clone()).is_err());
|
||||||
assert_eq!(engine.share_generation.load(Ordering::Acquire), 2);
|
assert_eq!(engine.share_generation.load(Ordering::Acquire), 2);
|
||||||
assert!(engine.shares().expect("shares").is_empty());
|
assert!(engine.shares().expect("shares").is_empty());
|
||||||
|
// Kept, as through a recrawl: sync would bring it back regardless.
|
||||||
assert!(
|
assert!(
|
||||||
engine
|
engine
|
||||||
.watch_state(share.id, "episode".to_owned())
|
.watch_state(share.id, "episode".to_owned())
|
||||||
.expect("watch state")
|
.expect("watch state")
|
||||||
.is_none()
|
.is_some()
|
||||||
);
|
);
|
||||||
|
|
||||||
drop(engine);
|
drop(engine);
|
||||||
|
|||||||
@@ -33,6 +33,11 @@ serde_json.workspace = true
|
|||||||
directories.workspace = true
|
directories.workspace = true
|
||||||
chrono.workspace = true
|
chrono.workspace = true
|
||||||
souvlaki.workspace = true
|
souvlaki.workspace = true
|
||||||
|
wry.workspace = true
|
||||||
|
tao.workspace = true
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
embed-resource.workspace = true
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
skrifa.workspace = true
|
skrifa.workspace = true
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
//! Compiles `proton-stream.rc` into the Windows exe; nothing on any other
|
||||||
|
//! target. `rc.exe` on an MSVC build, mingw's `windres` on the cross build
|
||||||
|
//! from Linux.
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
println!("cargo:rerun-if-changed=proton-stream.rc");
|
||||||
|
println!("cargo:rerun-if-changed=../../packaging/windows/proton-stream.ico");
|
||||||
|
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Only the GUI gets the icon; `pstr` is a console tool.
|
||||||
|
embed_resource::compile_for("proton-stream.rc", ["proton-stream"], embed_resource::NONE)
|
||||||
|
.manifest_optional()
|
||||||
|
.expect("compile the icon resource");
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
// The exe's icon. The window sets one at runtime too, but that only lasts as
|
||||||
|
// long as the window: Explorer, the Start menu and a taskbar pin read the icon
|
||||||
|
// out of the file, and without this one they fall back to the generic one.
|
||||||
|
1 ICON "../../packaging/windows/proton-stream.ico"
|
||||||
@@ -15,7 +15,8 @@ use pstr_core::library::Library;
|
|||||||
use pstr_core::metadata::{EpisodeGuide, MetadataConfig, MetadataRecord, TitleNames};
|
use pstr_core::metadata::{EpisodeGuide, MetadataConfig, MetadataRecord, TitleNames};
|
||||||
|
|
||||||
use crate::engine::{
|
use crate::engine::{
|
||||||
DownloadItem, DownloadKey, Engine, Event, ImageCache, describe_failures, watch_state,
|
AccountStatus, DownloadItem, DownloadKey, Engine, Event, ImageCache, describe_failures,
|
||||||
|
watch_state,
|
||||||
};
|
};
|
||||||
use crate::pacing::Pacer;
|
use crate::pacing::Pacer;
|
||||||
use crate::playback::{Playback, PlaybackTarget};
|
use crate::playback::{Playback, PlaybackTarget};
|
||||||
@@ -126,6 +127,25 @@ pub enum Action {
|
|||||||
url: String,
|
url: String,
|
||||||
password: Option<String>,
|
password: Option<String>,
|
||||||
},
|
},
|
||||||
|
/// Add a folder of the signed-in account as a share.
|
||||||
|
AddAccountFolder {
|
||||||
|
name: String,
|
||||||
|
uid: pstr_core::proton_sdk::ids::NodeUid,
|
||||||
|
},
|
||||||
|
SignIn {
|
||||||
|
username: String,
|
||||||
|
password: String,
|
||||||
|
},
|
||||||
|
SubmitSecondFactor(String),
|
||||||
|
SubmitMailboxPassword(String),
|
||||||
|
CancelSignIn,
|
||||||
|
SignOut,
|
||||||
|
/// Sync watch history with the account's Drive now.
|
||||||
|
SyncNow,
|
||||||
|
/// List the account's top-level places in the Drive browser.
|
||||||
|
BrowsePlaces,
|
||||||
|
/// List one folder in the Drive browser.
|
||||||
|
BrowseFolder(pstr_core::proton_sdk::ids::NodeUid),
|
||||||
/// Ask whether to forget a share. [`Action::ForgetShare`] does it.
|
/// Ask whether to forget a share. [`Action::ForgetShare`] does it.
|
||||||
RemoveShare(String),
|
RemoveShare(String),
|
||||||
ForgetShare(String),
|
ForgetShare(String),
|
||||||
@@ -535,6 +555,8 @@ pub struct App {
|
|||||||
/// library means "not loaded", not "nothing in it".
|
/// library means "not loaded", not "nothing in it".
|
||||||
pub loaded: bool,
|
pub loaded: bool,
|
||||||
pub form: ShareForm,
|
pub form: ShareForm,
|
||||||
|
/// The Proton account section of the shares page.
|
||||||
|
pub account: ui::account::AccountPanel,
|
||||||
pub playback: Option<Playback>,
|
pub playback: Option<Playback>,
|
||||||
/// Whether the player page's controls are showing, and why.
|
/// Whether the player page's controls are showing, and why.
|
||||||
pub overlay: ui::player::Overlay,
|
pub overlay: ui::player::Overlay,
|
||||||
@@ -603,6 +625,7 @@ impl App {
|
|||||||
// that was crawled yesterday is on screen before the shares open.
|
// that was crawled yesterday is on screen before the shares open.
|
||||||
engine.load_shares();
|
engine.load_shares();
|
||||||
engine.load_library();
|
engine.load_library();
|
||||||
|
engine.forget_orphaned_shares();
|
||||||
engine.load_metadata();
|
engine.load_metadata();
|
||||||
engine.connect();
|
engine.connect();
|
||||||
let settings = engine.metadata_config();
|
let settings = engine.metadata_config();
|
||||||
@@ -630,6 +653,7 @@ impl App {
|
|||||||
view: LibraryView::default(),
|
view: LibraryView::default(),
|
||||||
loaded: false,
|
loaded: false,
|
||||||
form: ShareForm::default(),
|
form: ShareForm::default(),
|
||||||
|
account: ui::account::AccountPanel::default(),
|
||||||
playback: None,
|
playback: None,
|
||||||
overlay: ui::player::Overlay::default(),
|
overlay: ui::player::Overlay::default(),
|
||||||
up_next: UpNext::default(),
|
up_next: UpNext::default(),
|
||||||
@@ -770,6 +794,10 @@ impl App {
|
|||||||
self.view.stale = true;
|
self.view.stale = true;
|
||||||
}
|
}
|
||||||
Event::EpisodeMetadata(episodes) => self.episodes = episodes,
|
Event::EpisodeMetadata(episodes) => self.episodes = episodes,
|
||||||
|
Event::Appearance(appearance) => {
|
||||||
|
theme::apply(ctx, appearance);
|
||||||
|
self.view.stale = true;
|
||||||
|
}
|
||||||
Event::MetadataConfig(config) => {
|
Event::MetadataConfig(config) => {
|
||||||
self.settings = config;
|
self.settings = config;
|
||||||
self.posters.clear();
|
self.posters.clear();
|
||||||
@@ -792,6 +820,7 @@ impl App {
|
|||||||
}
|
}
|
||||||
self.note(ctx, text, failed > 0);
|
self.note(ctx, text, failed > 0);
|
||||||
}
|
}
|
||||||
|
Event::MatchStarted => self.matching = true,
|
||||||
Event::MatchFinished => self.matching = false,
|
Event::MatchFinished => self.matching = false,
|
||||||
// Both of these are addressed to a dialog, and the viewer may
|
// Both of these are addressed to a dialog, and the viewer may
|
||||||
// have closed it or opened another one while the request was
|
// have closed it or opened another one while the request was
|
||||||
@@ -882,6 +911,9 @@ impl App {
|
|||||||
None => Page::Library,
|
None => Page::Library,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
// Where it stopped is the position another device
|
||||||
|
// wants next, so it goes now rather than on the timer.
|
||||||
|
self.engine.sync_watch_history();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Event::Watched {
|
Event::Watched {
|
||||||
@@ -905,6 +937,23 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Event::OfflineFiles(files) => self.offline_files = files,
|
Event::OfflineFiles(files) => self.offline_files = files,
|
||||||
|
Event::Account(status) => {
|
||||||
|
if let AccountStatus::SignedIn(username) = &status
|
||||||
|
&& self.account.busy
|
||||||
|
{
|
||||||
|
self.note(ctx, format!("signed in as {username}"), false);
|
||||||
|
}
|
||||||
|
self.account.set_status(status);
|
||||||
|
}
|
||||||
|
Event::SignInFailed(error) => self.account.refused(error),
|
||||||
|
Event::Synced(report) => {
|
||||||
|
self.account.sync_error = None;
|
||||||
|
self.account.synced = Some((ui::account::unix_now(), report));
|
||||||
|
}
|
||||||
|
Event::SyncFailed(error) => self.account.sync_error = Some(error),
|
||||||
|
Event::Places(places) => self.account.browser.places_arrived(places),
|
||||||
|
Event::Folder { uid, entries } => self.account.browser.folder_arrived(uid, entries),
|
||||||
|
Event::BrowseFailed(error) => self.account.browser.failed(error),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Pictures become textures a few per frame; the rest wait for the next.
|
// Pictures become textures a few per frame; the rest wait for the next.
|
||||||
@@ -1369,6 +1418,20 @@ impl App {
|
|||||||
self.form.error = None;
|
self.form.error = None;
|
||||||
self.engine.add_share(name, url, password);
|
self.engine.add_share(name, url, password);
|
||||||
}
|
}
|
||||||
|
Action::AddAccountFolder { name, uid } => {
|
||||||
|
self.crawling = true;
|
||||||
|
self.engine.add_account_folder(name, uid);
|
||||||
|
}
|
||||||
|
Action::SignIn { username, password } => self.engine.sign_in(username, password),
|
||||||
|
Action::SubmitSecondFactor(code) => self.engine.submit_second_factor(code),
|
||||||
|
Action::SubmitMailboxPassword(password) => {
|
||||||
|
self.engine.submit_mailbox_password(password)
|
||||||
|
}
|
||||||
|
Action::CancelSignIn => self.engine.cancel_sign_in(),
|
||||||
|
Action::SignOut => self.engine.sign_out(),
|
||||||
|
Action::SyncNow => self.engine.sync_watch_history(),
|
||||||
|
Action::BrowsePlaces => self.engine.browse_places(),
|
||||||
|
Action::BrowseFolder(uid) => self.engine.browse_folder(uid),
|
||||||
Action::RemoveShare(id) => self.confirm_remove_share = Some(id),
|
Action::RemoveShare(id) => self.confirm_remove_share = Some(id),
|
||||||
Action::ForgetShare(id) => {
|
Action::ForgetShare(id) => {
|
||||||
self.confirm_remove_share = None;
|
self.confirm_remove_share = None;
|
||||||
@@ -1868,6 +1931,7 @@ impl eframe::App for App {
|
|||||||
view,
|
view,
|
||||||
loaded,
|
loaded,
|
||||||
form,
|
form,
|
||||||
|
account,
|
||||||
matcher,
|
matcher,
|
||||||
playback,
|
playback,
|
||||||
opening,
|
opening,
|
||||||
@@ -1964,7 +2028,9 @@ impl eframe::App for App {
|
|||||||
},
|
},
|
||||||
&mut actions,
|
&mut actions,
|
||||||
),
|
),
|
||||||
Page::Shares => ui::shares::show(ui, shares, library, form, &mut actions),
|
Page::Shares => {
|
||||||
|
ui::shares::show(ui, shares, library, form, account, &mut actions)
|
||||||
|
}
|
||||||
Page::Settings => {
|
Page::Settings => {
|
||||||
ui::settings::show(ui, settings, prefs, api_key, &mut actions)
|
ui::settings::show(ui, settings, prefs, api_key, &mut actions)
|
||||||
}
|
}
|
||||||
@@ -2058,6 +2124,9 @@ impl eframe::App for App {
|
|||||||
self.playback = None;
|
self.playback = None;
|
||||||
self.engine
|
self.engine
|
||||||
.shutdown_downloads(std::time::Duration::from_secs(3));
|
.shutdown_downloads(std::time::Duration::from_secs(3));
|
||||||
|
// After the player: its last position is saved on the way out.
|
||||||
|
self.engine
|
||||||
|
.sync_before_exit(std::time::Duration::from_secs(3));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ use std::sync::atomic::{AtomicBool, Ordering};
|
|||||||
use std::sync::mpsc::{Receiver, Sender, channel};
|
use std::sync::mpsc::{Receiver, Sender, channel};
|
||||||
|
|
||||||
use parking_lot::Mutex;
|
use parking_lot::Mutex;
|
||||||
|
use pstr_core::account::{DriveEntry, DrivePlace, PendingSignIn, SignIn};
|
||||||
use pstr_core::appearance::Appearance;
|
use pstr_core::appearance::Appearance;
|
||||||
use pstr_core::catalog::{
|
use pstr_core::catalog::{
|
||||||
Catalog, CatalogNode, OfflineFile, TitleTrackPrefs, WatchState, build_rows,
|
Catalog, CatalogNode, OfflineFile, TitleTrackPrefs, WatchState, build_rows,
|
||||||
@@ -33,15 +34,19 @@ use pstr_core::config::AppDirs;
|
|||||||
use pstr_core::library::{Library, Title, TitleKind};
|
use pstr_core::library::{Library, Title, TitleKind};
|
||||||
use pstr_core::metadata::{EpisodeGuide, MetadataConfig, MetadataRecord, ProviderId};
|
use pstr_core::metadata::{EpisodeGuide, MetadataConfig, MetadataRecord, ProviderId};
|
||||||
use pstr_core::prefs::PlaybackPrefs;
|
use pstr_core::prefs::PlaybackPrefs;
|
||||||
|
use pstr_core::profile::ProfileChanges;
|
||||||
use pstr_core::proton_drive_rs::ThumbnailType;
|
use pstr_core::proton_drive_rs::ThumbnailType;
|
||||||
|
use pstr_core::proton_sdk::api::HumanVerificationCredential;
|
||||||
use pstr_core::proton_sdk::ids::{LinkId, NodeUid, VolumeId};
|
use pstr_core::proton_sdk::ids::{LinkId, NodeUid, VolumeId};
|
||||||
use pstr_core::{Share, ShareStore, SharedLibrary};
|
use pstr_core::shares::AccountFolder;
|
||||||
|
use pstr_core::sync::{SyncReport, WatchSync};
|
||||||
|
use pstr_core::{Account, AccountStore, KeyringSecretStore, Share, ShareStore, SharedLibrary};
|
||||||
use pstr_meta::MetadataService;
|
use pstr_meta::MetadataService;
|
||||||
|
|
||||||
use crate::desktop_prefs::{DesktopPrefs, DesktopPrefsFile};
|
use crate::desktop_prefs::{DesktopPrefs, DesktopPrefsFile};
|
||||||
use pstr_stream::{
|
use pstr_stream::{
|
||||||
BlockSource, DiskCacheConfig, FileBlocks, LibraryOpener, StreamConfig, StreamSource,
|
BlockSource, COPY_BLOCKS_IN_FLIGHT, DiskCacheConfig, FileBlocks, LibraryOpener, StreamConfig,
|
||||||
VideoStream,
|
StreamSource, VideoStream,
|
||||||
};
|
};
|
||||||
use tokio::runtime::Runtime;
|
use tokio::runtime::Runtime;
|
||||||
|
|
||||||
@@ -211,12 +216,16 @@ pub enum Event {
|
|||||||
EpisodeMetadata(HashMap<String, EpisodeGuide>),
|
EpisodeMetadata(HashMap<String, EpisodeGuide>),
|
||||||
/// The enrichment settings changed, on disk.
|
/// The enrichment settings changed, on disk.
|
||||||
MetadataConfig(MetadataConfig),
|
MetadataConfig(MetadataConfig),
|
||||||
|
/// Another device's appearance arrived through sync.
|
||||||
|
Appearance(Appearance),
|
||||||
/// A matching run finished, with how it went.
|
/// A matching run finished, with how it went.
|
||||||
Matched {
|
Matched {
|
||||||
matched: usize,
|
matched: usize,
|
||||||
unmatched: usize,
|
unmatched: usize,
|
||||||
failed: usize,
|
failed: usize,
|
||||||
},
|
},
|
||||||
|
/// A matching run has work to do — one asked for, or one after a crawl.
|
||||||
|
MatchStarted,
|
||||||
/// Every requested match is done.
|
/// Every requested match is done.
|
||||||
MatchFinished,
|
MatchFinished,
|
||||||
/// What the provider answered a hand search with, unscored and in its own
|
/// What the provider answered a hand search with, unscored and in its own
|
||||||
@@ -269,6 +278,38 @@ pub enum Event {
|
|||||||
DownloadProgress(Box<DownloadItem>),
|
DownloadProgress(Box<DownloadItem>),
|
||||||
/// Completed local copies, for badges and online-only actions.
|
/// Completed local copies, for badges and online-only actions.
|
||||||
OfflineFiles(HashSet<DownloadKey>),
|
OfflineFiles(HashSet<DownloadKey>),
|
||||||
|
/// The Proton account signed in or out, or a sign-in wants more.
|
||||||
|
Account(AccountStatus),
|
||||||
|
/// A sign-in step was refused, with why.
|
||||||
|
SignInFailed(String),
|
||||||
|
/// Watch history synced with the account's Drive.
|
||||||
|
Synced(SyncReport),
|
||||||
|
/// Sync did not run, with why. Not a toast: it is retried on a
|
||||||
|
/// timer, and an offline evening would be a stack of them.
|
||||||
|
SyncFailed(String),
|
||||||
|
/// The account's top-level places, for the Drive browser.
|
||||||
|
Places(Vec<DrivePlace>),
|
||||||
|
/// One folder of the account, for the Drive browser.
|
||||||
|
Folder {
|
||||||
|
uid: NodeUid,
|
||||||
|
entries: Vec<DriveEntry>,
|
||||||
|
},
|
||||||
|
/// The Drive browser's last request failed.
|
||||||
|
BrowseFailed(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where the Proton account stands.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||||
|
pub enum AccountStatus {
|
||||||
|
/// The stored session has not been read yet.
|
||||||
|
#[default]
|
||||||
|
Unknown,
|
||||||
|
SignedOut,
|
||||||
|
SignedIn(String),
|
||||||
|
/// A sign-in is waiting for a second-factor code.
|
||||||
|
SecondFactor,
|
||||||
|
/// A sign-in is waiting for the mailbox password.
|
||||||
|
MailboxPassword,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The share clients and the block layer, once they exist.
|
/// The share clients and the block layer, once they exist.
|
||||||
@@ -335,6 +376,17 @@ pub struct Engine {
|
|||||||
desktop_file: Arc<DesktopPrefsFile>,
|
desktop_file: Arc<DesktopPrefsFile>,
|
||||||
/// Bumped to stop the crawls in flight; each watches it from its start.
|
/// Bumped to stop the crawls in flight; each watches it from its start.
|
||||||
crawl_cancel: Arc<tokio::sync::watch::Sender<u64>>,
|
crawl_cancel: Arc<tokio::sync::watch::Sender<u64>>,
|
||||||
|
accounts: AccountStore,
|
||||||
|
/// The signed-in Proton account, if any.
|
||||||
|
account: Arc<Mutex<Option<Account>>>,
|
||||||
|
/// False until the stored session has been read. Opening the shares waits
|
||||||
|
/// on it, or a launch would open the account's folders before the account
|
||||||
|
/// and report every one of them as failing to.
|
||||||
|
account_settled: Arc<tokio::sync::watch::Sender<bool>>,
|
||||||
|
/// A sign-in waiting for a code or the mailbox password.
|
||||||
|
pending_sign_in: Arc<Mutex<Option<PendingSignIn>>>,
|
||||||
|
/// `None` when this installation's sync identity could not be written.
|
||||||
|
watch_sync: Option<Arc<WatchSync>>,
|
||||||
events: Sender<Event>,
|
events: Sender<Event>,
|
||||||
ctx: egui::Context,
|
ctx: egui::Context,
|
||||||
}
|
}
|
||||||
@@ -345,10 +397,10 @@ const THUMBNAIL_CONCURRENCY: usize = 6;
|
|||||||
/// How many provider lookups may be in flight at once. See [`Engine::lookups`].
|
/// How many provider lookups may be in flight at once. See [`Engine::lookups`].
|
||||||
const LOOKUP_CONCURRENCY: usize = 2;
|
const LOOKUP_CONCURRENCY: usize = 2;
|
||||||
const DOWNLOAD_CONCURRENCY: usize = 3;
|
const DOWNLOAD_CONCURRENCY: usize = 3;
|
||||||
/// How many blocks of one download are fetched at once. Four is what the
|
|
||||||
/// stream layer itself allows a single read, for the same reason: enough to
|
/// How often watch history is synced while the app is open, on top of at
|
||||||
/// hide a round trip, few enough that three downloads do not swamp a player.
|
/// launch and whenever a player stops.
|
||||||
const DOWNLOAD_BLOCKS_IN_FLIGHT: usize = 4;
|
const SYNC_INTERVAL: std::time::Duration = std::time::Duration::from_secs(5 * 60);
|
||||||
|
|
||||||
/// Enrichment as currently configured.
|
/// Enrichment as currently configured.
|
||||||
struct Enrichment {
|
struct Enrichment {
|
||||||
@@ -427,6 +479,11 @@ impl Engine {
|
|||||||
Enrichment::build(config.clone())
|
Enrichment::build(config.clone())
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let watch_sync = WatchSync::open(&dirs)
|
||||||
|
.inspect_err(|error| tracing::warn!("watch-history sync unavailable: {error}"))
|
||||||
|
.ok()
|
||||||
|
.map(Arc::new);
|
||||||
|
|
||||||
let track_prefs = catalog.all_title_track_prefs().unwrap_or_else(|error| {
|
let track_prefs = catalog.all_title_track_prefs().unwrap_or_else(|error| {
|
||||||
tracing::warn!("read show track preferences: {error}");
|
tracing::warn!("read show track preferences: {error}");
|
||||||
HashMap::new()
|
HashMap::new()
|
||||||
@@ -449,6 +506,11 @@ impl Engine {
|
|||||||
download_permits: Arc::new(tokio::sync::Semaphore::new(DOWNLOAD_CONCURRENCY)),
|
download_permits: Arc::new(tokio::sync::Semaphore::new(DOWNLOAD_CONCURRENCY)),
|
||||||
picture_shown: Arc::new(AtomicBool::new(false)),
|
picture_shown: Arc::new(AtomicBool::new(false)),
|
||||||
crawl_cancel: Arc::new(tokio::sync::watch::channel(0).0),
|
crawl_cancel: Arc::new(tokio::sync::watch::channel(0).0),
|
||||||
|
accounts: AccountStore::new(Arc::new(KeyringSecretStore)),
|
||||||
|
account: Arc::new(Mutex::new(None)),
|
||||||
|
account_settled: Arc::new(tokio::sync::watch::channel(false).0),
|
||||||
|
pending_sign_in: Arc::new(Mutex::new(None)),
|
||||||
|
watch_sync,
|
||||||
desktop: Arc::new(Mutex::new(desktop)),
|
desktop: Arc::new(Mutex::new(desktop)),
|
||||||
desktop_file: Arc::new(desktop_file),
|
desktop_file: Arc::new(desktop_file),
|
||||||
events,
|
events,
|
||||||
@@ -460,6 +522,8 @@ impl Engine {
|
|||||||
deferred.install_enrichment(Enrichment::build(config), true);
|
deferred.install_enrichment(Enrichment::build(config), true);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
engine.load_account();
|
||||||
|
engine.sync_periodically();
|
||||||
Ok((engine, receiver))
|
Ok((engine, receiver))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -581,24 +645,396 @@ impl Engine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Add a share, then open and crawl it — which is what "add" means to
|
/// Add a share, then open and crawl it — which is what "add" means to
|
||||||
/// someone who just pasted a link.
|
/// someone who just pasted a link. A link that will not open is refused
|
||||||
|
/// before anything is stored, so trying again is not "already configured".
|
||||||
pub fn add_share(&self, name: String, url: String, password: Option<String>) {
|
pub fn add_share(&self, name: String, url: String, password: Option<String>) {
|
||||||
let engine = self.clone();
|
let engine = self.clone();
|
||||||
self.runtime.spawn(async move {
|
self.runtime.spawn(async move {
|
||||||
let share = match engine.store.add(&name, &url, password.as_deref()) {
|
let share = match engine
|
||||||
|
.store
|
||||||
|
.add_verified(&name, &url, password.as_deref())
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok((share, _)) => share,
|
||||||
|
Err(error) => return engine.emit(Event::ShareRejected(error.to_string())),
|
||||||
|
};
|
||||||
|
engine.emit(Event::ShareAdded(share.name.clone()));
|
||||||
|
engine.load_shares();
|
||||||
|
// The open connection predates the share, and crawling it through
|
||||||
|
// that would find it "not open".
|
||||||
|
engine.connection.lock().take();
|
||||||
|
engine.connect_and_crawl(Some(share.id)).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a folder of the signed-in account as a share, then crawl it.
|
||||||
|
pub fn add_account_folder(&self, name: String, folder: NodeUid) {
|
||||||
|
let engine = self.clone();
|
||||||
|
self.runtime.spawn(async move {
|
||||||
|
let folder = AccountFolder {
|
||||||
|
volume_id: folder.volume_id.as_str().to_owned(),
|
||||||
|
link_id: folder.link_id.as_str().to_owned(),
|
||||||
|
};
|
||||||
|
let share = match engine.store.add_folder(&name, folder) {
|
||||||
Ok(share) => share,
|
Ok(share) => share,
|
||||||
Err(error) => return engine.emit(Event::ShareRejected(error.to_string())),
|
Err(error) => return engine.emit(Event::ShareRejected(error.to_string())),
|
||||||
};
|
};
|
||||||
engine.emit(Event::ShareAdded(share.name.clone()));
|
engine.emit(Event::ShareAdded(share.name.clone()));
|
||||||
engine.load_shares();
|
engine.load_shares();
|
||||||
|
engine.connection.lock().take();
|
||||||
engine.connect_and_crawl(Some(share.id)).await;
|
engine.connect_and_crawl(Some(share.id)).await;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------- account
|
||||||
|
|
||||||
|
/// Read the stored session, off the UI thread — a keyring read can be a
|
||||||
|
/// D-Bus round trip to a keyring that has to unlock first.
|
||||||
|
fn load_account(&self) {
|
||||||
|
let engine = self.clone();
|
||||||
|
self.runtime.spawn_blocking(move || {
|
||||||
|
match engine.accounts.resume() {
|
||||||
|
Ok(Some(account)) => {
|
||||||
|
let username = account.username().to_owned();
|
||||||
|
*engine.account.lock() = Some(account);
|
||||||
|
engine.emit(Event::Account(AccountStatus::SignedIn(username)));
|
||||||
|
engine.sync_watch_history();
|
||||||
|
}
|
||||||
|
Ok(None) => engine.emit(Event::Account(AccountStatus::SignedOut)),
|
||||||
|
Err(error) => {
|
||||||
|
engine.emit(Event::Account(AccountStatus::SignedOut));
|
||||||
|
engine.fail("read the Proton session", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
engine.account_settled.send_replace(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start signing in. What happens next arrives as [`Event::Account`] or
|
||||||
|
/// [`Event::SignInFailed`].
|
||||||
|
pub fn sign_in(&self, username: String, password: String) {
|
||||||
|
let engine = self.clone();
|
||||||
|
self.runtime.spawn(async move {
|
||||||
|
let mut verification = None;
|
||||||
|
// Twice at most: once as asked, and once more with a solved
|
||||||
|
// CAPTCHA if Proton wanted one. A second gate after that is
|
||||||
|
// Proton refusing the answer, not a puzzle worth repeating.
|
||||||
|
for _ in 0..2 {
|
||||||
|
match engine
|
||||||
|
.accounts
|
||||||
|
.sign_in(&username, &password, verification.take())
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(step) => return engine.sign_in_step(step),
|
||||||
|
Err(pstr_core::Error::HumanVerification(challenge)) => {
|
||||||
|
let url = challenge.verification_url();
|
||||||
|
let token = tokio::task::spawn_blocking(move || crate::verify::solve(&url))
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten();
|
||||||
|
let Some(token) = token else {
|
||||||
|
return engine.emit(Event::SignInFailed(
|
||||||
|
"Proton wants you to confirm you are human before it accepts \
|
||||||
|
this sign-in. Sign in again to solve the puzzle."
|
||||||
|
.to_owned(),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
verification = Some(HumanVerificationCredential::captcha(token));
|
||||||
|
}
|
||||||
|
Err(error) => return engine.emit(Event::SignInFailed(error.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
engine.emit(Event::SignInFailed(
|
||||||
|
"Proton did not accept the verification. Try again in a while.".to_owned(),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn submit_second_factor(&self, code: String) {
|
||||||
|
let Some(pending) = self.pending_sign_in.lock().take() else {
|
||||||
|
return self.emit(Event::Account(AccountStatus::SignedOut));
|
||||||
|
};
|
||||||
|
let engine = self.clone();
|
||||||
|
self.runtime.spawn(async move {
|
||||||
|
match pending.second_factor(&code).await {
|
||||||
|
Ok(step) => engine.sign_in_step(step),
|
||||||
|
Err(error) => {
|
||||||
|
// The half-made session is spent; the viewer starts over.
|
||||||
|
engine.emit(Event::Account(AccountStatus::SignedOut));
|
||||||
|
engine.emit(Event::SignInFailed(error.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn submit_mailbox_password(&self, password: String) {
|
||||||
|
let engine = self.clone();
|
||||||
|
self.runtime.spawn(async move {
|
||||||
|
let Some(pending) = engine.pending_sign_in.lock().take() else {
|
||||||
|
return engine.emit(Event::Account(AccountStatus::SignedOut));
|
||||||
|
};
|
||||||
|
match pending.mailbox_password(&password).await {
|
||||||
|
Ok(account) => engine.signed_in(account),
|
||||||
|
Err(error) => {
|
||||||
|
// Kept: a mistyped mailbox password can be typed again.
|
||||||
|
*engine.pending_sign_in.lock() = Some(pending);
|
||||||
|
engine.emit(Event::SignInFailed(error.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cancel_sign_in(&self) {
|
||||||
|
self.pending_sign_in.lock().take();
|
||||||
|
self.emit(Event::Account(AccountStatus::SignedOut));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sign_in_step(&self, step: SignIn) {
|
||||||
|
match step {
|
||||||
|
SignIn::Done(account) => self.signed_in(account),
|
||||||
|
SignIn::SecondFactor(pending) => {
|
||||||
|
*self.pending_sign_in.lock() = Some(pending);
|
||||||
|
self.emit(Event::Account(AccountStatus::SecondFactor));
|
||||||
|
}
|
||||||
|
SignIn::MailboxPassword(pending) => {
|
||||||
|
*self.pending_sign_in.lock() = Some(pending);
|
||||||
|
self.emit(Event::Account(AccountStatus::MailboxPassword));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn signed_in(&self, account: Account) {
|
||||||
|
let username = account.username().to_owned();
|
||||||
|
*self.account.lock() = Some(account);
|
||||||
|
if let Some(sync) = &self.watch_sync {
|
||||||
|
sync.reset();
|
||||||
|
}
|
||||||
|
// Folders of the account that failed to open before are openable now.
|
||||||
|
self.connection.lock().take();
|
||||||
|
self.emit(Event::Account(AccountStatus::SignedIn(username)));
|
||||||
|
self.connect();
|
||||||
|
self.sync_watch_history();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sign out and forget the stored session. The library keeps everything
|
||||||
|
/// it has; the account's folders stop opening until the next sign-in.
|
||||||
|
pub fn sign_out(&self) {
|
||||||
|
let engine = self.clone();
|
||||||
|
self.runtime.spawn(async move {
|
||||||
|
let account = engine.account.lock().take();
|
||||||
|
if let Err(error) = engine.accounts.sign_out(account.as_ref()).await {
|
||||||
|
engine.fail("forget the Proton session", error);
|
||||||
|
}
|
||||||
|
engine.signed_out();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn signed_out(&self) {
|
||||||
|
self.account.lock().take();
|
||||||
|
if let Some(sync) = &self.watch_sync {
|
||||||
|
sync.reset();
|
||||||
|
}
|
||||||
|
self.connection.lock().take();
|
||||||
|
self.emit(Event::Account(AccountStatus::SignedOut));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sync watch history now, if an account is signed in.
|
||||||
|
pub fn sync_watch_history(&self) {
|
||||||
|
let (Some(account), Some(sync)) = (self.account.lock().clone(), self.watch_sync.clone())
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let engine = self.clone();
|
||||||
|
self.runtime.spawn(async move {
|
||||||
|
match sync.sync(&account, &engine.catalog, &engine.store).await {
|
||||||
|
Ok(report) => {
|
||||||
|
engine.take_synced_profile(&report.profile).await;
|
||||||
|
if report.applied > 0 || report.profile.titles > 0 {
|
||||||
|
engine.load_library();
|
||||||
|
}
|
||||||
|
engine.emit(Event::Synced(report));
|
||||||
|
}
|
||||||
|
Err(error) if error.is_signed_out() => {
|
||||||
|
// The refresh token died — revoked from another device, or
|
||||||
|
// unused for too long. Signing in again is the only fix,
|
||||||
|
// and a stored session that can never resume only makes
|
||||||
|
// every later launch fail the same way.
|
||||||
|
let _ = engine.accounts.sign_out(None).await;
|
||||||
|
engine.signed_out();
|
||||||
|
engine.emit(Event::Error(
|
||||||
|
"Your Proton session ended. Sign in again to keep syncing watch history."
|
||||||
|
.to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
tracing::warn!("sync watch history: {error}");
|
||||||
|
engine.emit(Event::SyncFailed(error.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sync once more before the app exits, waiting at most `timeout`.
|
||||||
|
///
|
||||||
|
/// Where the viewer stopped is what another device wants next, and the
|
||||||
|
/// timer would not get to it. Bounded, because quitting must not hang on
|
||||||
|
/// a slow network; a sync cut short loses nothing, the next launch sends
|
||||||
|
/// the same table.
|
||||||
|
pub fn sync_before_exit(&self, timeout: std::time::Duration) {
|
||||||
|
let (Some(account), Some(sync)) = (self.account.lock().clone(), self.watch_sync.clone())
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let catalog = Arc::clone(&self.catalog);
|
||||||
|
let store = Arc::clone(&self.store);
|
||||||
|
self.runtime.block_on(async move {
|
||||||
|
match tokio::time::timeout(timeout, sync.sync(&account, &catalog, &store)).await {
|
||||||
|
Ok(Ok(_)) => {}
|
||||||
|
Ok(Err(error)) => tracing::warn!("sync watch history on exit: {error}"),
|
||||||
|
Err(_) => tracing::warn!("sync watch history on exit: timed out"),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Act on what sync brought from other devices: reread the settings it
|
||||||
|
/// rewrote, crawl the shares it added, drop the ones it removed.
|
||||||
|
async fn take_synced_profile(&self, profile: &ProfileChanges) {
|
||||||
|
if profile.settings {
|
||||||
|
match pstr_core::prefs::load(&self.dirs) {
|
||||||
|
// The volume is this device's and was not synced; the file
|
||||||
|
// still holds it.
|
||||||
|
Ok(prefs) => *self.prefs.lock() = prefs,
|
||||||
|
Err(error) => tracing::warn!("reread the playback preferences: {error}"),
|
||||||
|
}
|
||||||
|
match pstr_core::appearance::load(&self.dirs) {
|
||||||
|
Ok(appearance) => {
|
||||||
|
*self.appearance.lock() = appearance;
|
||||||
|
self.emit(Event::Appearance(appearance));
|
||||||
|
}
|
||||||
|
Err(error) => tracing::warn!("reread the appearance: {error}"),
|
||||||
|
}
|
||||||
|
match pstr_meta::settings::load(&self.dirs) {
|
||||||
|
Ok(config) => {
|
||||||
|
let engine = self.clone();
|
||||||
|
let _ = self
|
||||||
|
.runtime
|
||||||
|
.spawn_blocking(move || {
|
||||||
|
engine.install_enrichment(Enrichment::build(config.clone()), false);
|
||||||
|
engine.emit(Event::MetadataConfig(config));
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
Err(error) => tracing::warn!("reread the metadata settings: {error}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if profile.titles > 0 {
|
||||||
|
match self.catalog.lock().all_title_track_prefs() {
|
||||||
|
Ok(prefs) => *self.track_prefs.lock() = prefs,
|
||||||
|
Err(error) => tracing::warn!("reread show track preferences: {error}"),
|
||||||
|
}
|
||||||
|
self.load_metadata();
|
||||||
|
}
|
||||||
|
for id in &profile.shares_removed {
|
||||||
|
self.forget_share(id.clone(), false).await;
|
||||||
|
}
|
||||||
|
if !profile.shares() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.connection.lock().take();
|
||||||
|
self.load_shares();
|
||||||
|
self.load_library();
|
||||||
|
for id in profile.shares_added.iter().chain(&profile.shares_changed) {
|
||||||
|
self.connect_and_crawl(Some(id.clone())).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sync_periodically(&self) {
|
||||||
|
let engine = self.clone();
|
||||||
|
self.runtime.spawn(async move {
|
||||||
|
let mut ticks = tokio::time::interval(SYNC_INTERVAL);
|
||||||
|
// The first tick is immediate, and launch already syncs.
|
||||||
|
ticks.tick().await;
|
||||||
|
loop {
|
||||||
|
ticks.tick().await;
|
||||||
|
engine.sync_watch_history();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List the account's top-level places, for the Drive browser.
|
||||||
|
pub fn browse_places(&self) {
|
||||||
|
let Some(account) = self.account.lock().clone() else {
|
||||||
|
return self.emit(Event::BrowseFailed("not signed in".to_owned()));
|
||||||
|
};
|
||||||
|
let engine = self.clone();
|
||||||
|
self.runtime.spawn(async move {
|
||||||
|
match account.places().await {
|
||||||
|
Ok(places) => engine.emit(Event::Places(places)),
|
||||||
|
Err(error) => engine.emit(Event::BrowseFailed(error.to_string())),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List one folder of the account, for the Drive browser.
|
||||||
|
pub fn browse_folder(&self, uid: NodeUid) {
|
||||||
|
let Some(account) = self.account.lock().clone() else {
|
||||||
|
return self.emit(Event::BrowseFailed("not signed in".to_owned()));
|
||||||
|
};
|
||||||
|
let engine = self.clone();
|
||||||
|
self.runtime.spawn(async move {
|
||||||
|
match account.folder_children(&uid).await {
|
||||||
|
Ok(entries) => engine.emit(Event::Folder { uid, entries }),
|
||||||
|
Err(error) => engine.emit(Event::BrowseFailed(error.to_string())),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Forget a share and its stored secrets.
|
/// Forget a share and its stored secrets.
|
||||||
pub fn remove_share(&self, id: String) {
|
pub fn remove_share(&self, id: String) {
|
||||||
let engine = self.clone();
|
let engine = self.clone();
|
||||||
self.runtime.spawn(async move {
|
self.runtime.spawn(async move {
|
||||||
|
engine.forget_share(id, true).await;
|
||||||
|
engine.connection.lock().take();
|
||||||
|
engine.load_library();
|
||||||
|
engine.emit(Event::Status("removed".into()));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forget every share the catalog holds files for that the share list no
|
||||||
|
/// longer names — what an older `pstr remove` left behind. Its files would
|
||||||
|
/// otherwise sit in the library beside whatever replaced it, every episode
|
||||||
|
/// twice.
|
||||||
|
pub fn forget_orphaned_shares(&self) {
|
||||||
|
let engine = self.clone();
|
||||||
|
self.runtime.spawn(async move {
|
||||||
|
let configured = match engine.store.list() {
|
||||||
|
Ok(shares) => shares,
|
||||||
|
Err(error) => return engine.fail("read the share list", error),
|
||||||
|
};
|
||||||
|
let orphans: Vec<String> = match engine.catalog.lock().share_ids() {
|
||||||
|
Ok(ids) => ids
|
||||||
|
.into_iter()
|
||||||
|
.filter(|id| !configured.iter().any(|share| &share.id == id))
|
||||||
|
.collect(),
|
||||||
|
Err(error) => return engine.fail("read the catalog", error),
|
||||||
|
};
|
||||||
|
if orphans.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for id in orphans {
|
||||||
|
tracing::info!(share = %id, "forgetting the catalog rows of a removed share");
|
||||||
|
engine.forget_share(id, false).await;
|
||||||
|
}
|
||||||
|
engine.load_library();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop a share's downloads and delete its files and catalog rows, and,
|
||||||
|
/// when it is still `configured` — not already removed by sync — its
|
||||||
|
/// entry and secrets.
|
||||||
|
async fn forget_share(&self, id: String, configured: bool) {
|
||||||
|
let engine = self;
|
||||||
|
{
|
||||||
let keys: Vec<_> = engine
|
let keys: Vec<_> = engine
|
||||||
.downloads
|
.downloads
|
||||||
.lock()
|
.lock()
|
||||||
@@ -641,7 +1077,7 @@ impl Engine {
|
|||||||
if let Err(error) = engine.catalog.lock().remove_share(&id) {
|
if let Err(error) = engine.catalog.lock().remove_share(&id) {
|
||||||
return engine.fail("drop that share's catalog rows", error);
|
return engine.fail("drop that share's catalog rows", error);
|
||||||
}
|
}
|
||||||
if let Err(error) = engine.store.remove(&id) {
|
if configured && let Err(error) = engine.store.remove(&id) {
|
||||||
return engine.fail("remove that share", error);
|
return engine.fail("remove that share", error);
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
@@ -653,9 +1089,7 @@ impl Engine {
|
|||||||
engine.load_shares();
|
engine.load_shares();
|
||||||
// Its clients are stale now; the next action reopens what is left.
|
// Its clients are stale now; the next action reopens what is left.
|
||||||
engine.connection.lock().take();
|
engine.connection.lock().take();
|
||||||
engine.load_library();
|
}
|
||||||
engine.emit(Event::Status("removed".into()));
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------ connection
|
// ------------------------------------------------------------ connection
|
||||||
@@ -696,7 +1130,11 @@ impl Engine {
|
|||||||
return Some(opened);
|
return Some(opened);
|
||||||
}
|
}
|
||||||
|
|
||||||
let (library, failures) = match SharedLibrary::open_all(&self.store).await {
|
let mut settled = self.account_settled.subscribe();
|
||||||
|
let _ = settled.wait_for(|settled| *settled).await;
|
||||||
|
let account = self.account.lock().clone();
|
||||||
|
let (library, failures) = match SharedLibrary::open_all(&self.store, account.as_ref()).await
|
||||||
|
{
|
||||||
Ok(opened) => opened,
|
Ok(opened) => opened,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
self.emit(Event::ConnectFailed(error.to_string()));
|
self.emit(Event::ConnectFailed(error.to_string()));
|
||||||
@@ -864,6 +1302,32 @@ impl Engine {
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
self.emit(Event::CrawlFinished);
|
self.emit(Event::CrawlFinished);
|
||||||
|
self.match_after_crawl().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Look up whatever the crawl brought that has no answer yet — a new show,
|
||||||
|
/// or a new episode the stored guide cannot name. Silent when there is
|
||||||
|
/// nothing to ask, which is almost every time: `plan` makes no requests for
|
||||||
|
/// a library that is already matched.
|
||||||
|
async fn match_after_crawl(&self) {
|
||||||
|
if !self.metadata_config().enabled {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let titles = {
|
||||||
|
let catalog = self.catalog.lock();
|
||||||
|
match catalog
|
||||||
|
.all_files()
|
||||||
|
.and_then(|files| Ok(Library::build(files, &catalog.all_watch_states()?)))
|
||||||
|
{
|
||||||
|
Ok(library) => library.titles,
|
||||||
|
Err(error) => return self.fail("read the catalog", error),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if titles.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.run_match(titles, false, true).await;
|
||||||
|
self.emit(Event::MatchFinished);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stop every crawl still listing. Shares already past their listing
|
/// Stop every crawl still listing. Shares already past their listing
|
||||||
@@ -1555,8 +2019,8 @@ impl Engine {
|
|||||||
return Ok(DownloadEnd::Cancelled);
|
return Ok(DownloadEnd::Cancelled);
|
||||||
}
|
}
|
||||||
let size = block_sizes[block_index];
|
let size = block_sizes[block_index];
|
||||||
let pending =
|
let pending = fetches
|
||||||
fetches.get_or_insert_with(|| fetch_in_order(&stream, &block_sizes, block_index));
|
.get_or_insert_with(|| stream.blocks_in_order(block_index, COPY_BLOCKS_IN_FLIGHT));
|
||||||
let Some(bytes) = pending.try_next().await? else {
|
let Some(bytes) = pending.try_next().await? else {
|
||||||
anyhow::bail!("block {block_index} never arrived");
|
anyhow::bail!("block {block_index} never arrived");
|
||||||
};
|
};
|
||||||
@@ -1899,13 +2363,18 @@ impl Engine {
|
|||||||
pub fn match_titles(&self, titles: Vec<Title>, force: bool) {
|
pub fn match_titles(&self, titles: Vec<Title>, force: bool) {
|
||||||
let engine = self.clone();
|
let engine = self.clone();
|
||||||
self.runtime.spawn(async move {
|
self.runtime.spawn(async move {
|
||||||
engine.run_match(titles, force).await;
|
engine.run_match(titles, force, false).await;
|
||||||
engine.emit(Event::MatchFinished);
|
engine.emit(Event::MatchFinished);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn run_match(&self, titles: Vec<Title>, force: bool) {
|
/// `automatic` is a run nobody asked for, after a crawl: it says nothing
|
||||||
|
/// when there is nothing to do, or no provider to do it with.
|
||||||
|
async fn run_match(&self, titles: Vec<Title>, force: bool, automatic: bool) {
|
||||||
let Some(service) = self.metadata_service().await else {
|
let Some(service) = self.metadata_service().await else {
|
||||||
|
if automatic {
|
||||||
|
return;
|
||||||
|
}
|
||||||
return self.emit(Event::Error(self.no_service_reason()));
|
return self.emit(Event::Error(self.no_service_reason()));
|
||||||
};
|
};
|
||||||
let provider = service.provider();
|
let provider = service.provider();
|
||||||
@@ -1930,8 +2399,12 @@ impl Engine {
|
|||||||
force,
|
force,
|
||||||
);
|
);
|
||||||
if pending.is_empty() {
|
if pending.is_empty() {
|
||||||
|
if automatic {
|
||||||
|
return;
|
||||||
|
}
|
||||||
return self.emit(Event::Status("everything is already matched".into()));
|
return self.emit(Event::Status("everything is already matched".into()));
|
||||||
}
|
}
|
||||||
|
self.emit(Event::MatchStarted);
|
||||||
self.emit(Event::Status(format!(
|
self.emit(Event::Status(format!(
|
||||||
"matching {} titles against {}…",
|
"matching {} titles against {}…",
|
||||||
pending.len(),
|
pending.len(),
|
||||||
@@ -2043,26 +2516,24 @@ impl Engine {
|
|||||||
return engine.emit(Event::Error(engine.no_service_reason()));
|
return engine.emit(Event::Error(engine.no_service_reason()));
|
||||||
};
|
};
|
||||||
let name = found.name.clone();
|
let name = found.name.clone();
|
||||||
let record = service.chosen(title.key.clone(), found.clone());
|
let outcome = service.choose(&title, found).await;
|
||||||
if let Err(error) = engine.catalog.lock().set_metadata(&record) {
|
if let Some(record) = &outcome.record
|
||||||
|
&& let Err(error) = engine.catalog.lock().set_metadata(record)
|
||||||
|
{
|
||||||
return engine.fail("store the match", error);
|
return engine.fail("store the match", error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// A failure leaves the title un-enriched, and the next match run
|
// A failure leaves the title un-enriched, and the next match run
|
||||||
// asks again.
|
// asks again.
|
||||||
match service.enrich(&title, &found).await {
|
if let Some(enrichment) = &outcome.enrichment {
|
||||||
Ok(enrichment) => {
|
let stored = engine.catalog.lock().set_enrichment(
|
||||||
let stored = engine.catalog.lock().set_enrichment(
|
&title.key,
|
||||||
&title.key,
|
service.provider(),
|
||||||
service.provider(),
|
now(),
|
||||||
now(),
|
enrichment,
|
||||||
&enrichment,
|
);
|
||||||
);
|
if let Err(error) = stored {
|
||||||
if let Err(error) = stored {
|
tracing::warn!("store enrichment for {}: {error}", title.key);
|
||||||
tracing::warn!("store enrichment for {}: {error}", title.key);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Err(error) => tracing::warn!("enrich {}: {error}", title.key),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
engine.emit(Event::Status(format!("{} is now {name}", title.name)));
|
engine.emit(Event::Status(format!("{} is now {name}", title.name)));
|
||||||
@@ -2223,35 +2694,6 @@ fn resume_position(existing: u64, block_sizes: &[u64]) -> (usize, u64) {
|
|||||||
(block_sizes.len(), offset)
|
(block_sizes.len(), offset)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A file's blocks from `first` on, fetched several at a time and yielded in
|
|
||||||
/// file order — which is what the resume marker's "the length is the
|
|
||||||
/// boundary" rule needs. One at a time, a download runs at one block per round
|
|
||||||
/// trip whatever the link can carry.
|
|
||||||
fn fetch_in_order(
|
|
||||||
stream: &VideoStream,
|
|
||||||
block_sizes: &[u64],
|
|
||||||
first: usize,
|
|
||||||
) -> futures::stream::BoxStream<'static, pstr_stream::Result<Vec<u8>>> {
|
|
||||||
use futures::StreamExt as _;
|
|
||||||
let start: u64 = block_sizes[..first].iter().sum();
|
|
||||||
let ranges: Vec<(u64, u64)> = block_sizes[first..]
|
|
||||||
.iter()
|
|
||||||
.scan(start, |offset, size| {
|
|
||||||
let range = (*offset, *size);
|
|
||||||
*offset += size;
|
|
||||||
Some(range)
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
let stream = stream.clone();
|
|
||||||
futures::stream::iter(ranges)
|
|
||||||
.map(move |(offset, size)| {
|
|
||||||
let stream = stream.clone();
|
|
||||||
async move { stream.read_range(offset, size).await }
|
|
||||||
})
|
|
||||||
.buffered(DOWNLOAD_BLOCKS_IN_FLIGHT)
|
|
||||||
.boxed()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn wait_until_runnable(command: &mut tokio::sync::watch::Receiver<DownloadCommand>) -> bool {
|
async fn wait_until_runnable(command: &mut tokio::sync::watch::Receiver<DownloadCommand>) -> bool {
|
||||||
loop {
|
loop {
|
||||||
let current = *command.borrow_and_update();
|
let current = *command.borrow_and_update();
|
||||||
|
|||||||
@@ -30,5 +30,6 @@ pub mod pacing;
|
|||||||
pub mod playback;
|
pub mod playback;
|
||||||
pub mod theme;
|
pub mod theme;
|
||||||
pub mod ui;
|
pub mod ui;
|
||||||
|
pub mod verify;
|
||||||
pub mod video;
|
pub mod video;
|
||||||
pub mod window;
|
pub mod window;
|
||||||
@@ -16,6 +16,12 @@ use anyhow::Context;
|
|||||||
use pstr_core::config::AppDirs;
|
use pstr_core::config::AppDirs;
|
||||||
|
|
||||||
fn main() -> anyhow::Result<()> {
|
fn main() -> anyhow::Result<()> {
|
||||||
|
// Before anything else: a verification window is a child process of the
|
||||||
|
// app, and must not claim the single instance or open a library.
|
||||||
|
if let Some(code) = pstr_app::verify::run_if_requested() {
|
||||||
|
std::process::exit(code);
|
||||||
|
}
|
||||||
|
|
||||||
tracing_subscriber::fmt()
|
tracing_subscriber::fmt()
|
||||||
.with_env_filter(
|
.with_env_filter(
|
||||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||||
|
|||||||
@@ -0,0 +1,605 @@
|
|||||||
|
//! The Proton account, on the shares page: signing in, watch-history sync, and
|
||||||
|
//! picking folders of the viewer's own Drive as library shares.
|
||||||
|
//!
|
||||||
|
//! Optional and said so: public links need no account, and the page reads the
|
||||||
|
//! same without one. What signing in adds is spelled out next to the form,
|
||||||
|
//! because "sign in" alone does not say why an app that streams public links
|
||||||
|
//! would want a password.
|
||||||
|
|
||||||
|
use pstr_core::account::{DriveEntry, DrivePlace, PlaceKind};
|
||||||
|
use pstr_core::proton_sdk::ids::NodeUid;
|
||||||
|
use pstr_core::sync::SyncReport;
|
||||||
|
|
||||||
|
use crate::app::Action;
|
||||||
|
use crate::engine::AccountStatus;
|
||||||
|
use crate::theme;
|
||||||
|
use crate::ui;
|
||||||
|
|
||||||
|
/// What the account section holds between frames.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct AccountPanel {
|
||||||
|
pub status: AccountStatus,
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
|
pub code: String,
|
||||||
|
pub mailbox: String,
|
||||||
|
/// A request is out and not answered yet.
|
||||||
|
pub busy: bool,
|
||||||
|
/// Why the last sign-in step was refused.
|
||||||
|
pub error: Option<String>,
|
||||||
|
/// When watch history last synced, in Unix seconds, and what it did.
|
||||||
|
pub synced: Option<(i64, SyncReport)>,
|
||||||
|
/// Why the last sync failed, until one succeeds.
|
||||||
|
pub sync_error: Option<String>,
|
||||||
|
pub browser: DriveBrowser,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AccountPanel {
|
||||||
|
/// Apply where the account now stands. The typed secrets go as soon as
|
||||||
|
/// they are not needed: a password has no business sitting in a struct for
|
||||||
|
/// the rest of the session.
|
||||||
|
pub fn set_status(&mut self, status: AccountStatus) {
|
||||||
|
self.busy = false;
|
||||||
|
match &status {
|
||||||
|
AccountStatus::SignedIn(_) | AccountStatus::SignedOut => {
|
||||||
|
self.password.clear();
|
||||||
|
self.code.clear();
|
||||||
|
self.mailbox.clear();
|
||||||
|
if matches!(status, AccountStatus::SignedIn(_)) {
|
||||||
|
self.error = None;
|
||||||
|
} else {
|
||||||
|
self.browser = DriveBrowser::default();
|
||||||
|
self.synced = None;
|
||||||
|
self.sync_error = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AccountStatus::SecondFactor => self.error = None,
|
||||||
|
AccountStatus::MailboxPassword => {
|
||||||
|
self.code.clear();
|
||||||
|
self.error = None;
|
||||||
|
}
|
||||||
|
AccountStatus::Unknown => {}
|
||||||
|
}
|
||||||
|
self.status = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn refused(&mut self, error: String) {
|
||||||
|
self.busy = false;
|
||||||
|
self.error = Some(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where the Drive browser is.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct DriveBrowser {
|
||||||
|
pub open: bool,
|
||||||
|
/// The folders descended through, each by name. Empty means the places.
|
||||||
|
pub trail: Vec<(String, NodeUid)>,
|
||||||
|
pub places: Option<Vec<DrivePlace>>,
|
||||||
|
/// The listing of the folder at the end of the trail, once it arrives.
|
||||||
|
pub entries: Option<(NodeUid, Vec<DriveEntry>)>,
|
||||||
|
pub loading: bool,
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DriveBrowser {
|
||||||
|
pub fn places_arrived(&mut self, places: Vec<DrivePlace>) {
|
||||||
|
self.loading = false;
|
||||||
|
self.error = None;
|
||||||
|
self.places = Some(places);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Take a listing, unless the viewer has already moved on from it.
|
||||||
|
pub fn folder_arrived(&mut self, uid: NodeUid, entries: Vec<DriveEntry>) {
|
||||||
|
if self
|
||||||
|
.trail
|
||||||
|
.last()
|
||||||
|
.is_some_and(|(_, current)| *current == uid)
|
||||||
|
{
|
||||||
|
self.loading = false;
|
||||||
|
self.error = None;
|
||||||
|
self.entries = Some((uid, entries));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn failed(&mut self, error: String) {
|
||||||
|
self.loading = false;
|
||||||
|
self.error = Some(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn enter(&mut self, name: String, uid: NodeUid, actions: &mut Vec<Action>) {
|
||||||
|
self.trail.push((name, uid.clone()));
|
||||||
|
self.entries = None;
|
||||||
|
self.loading = true;
|
||||||
|
self.error = None;
|
||||||
|
actions.push(Action::BrowseFolder(uid));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn up(&mut self, actions: &mut Vec<Action>) {
|
||||||
|
self.trail.pop();
|
||||||
|
self.entries = None;
|
||||||
|
self.error = None;
|
||||||
|
match self.trail.last() {
|
||||||
|
Some((_, uid)) => {
|
||||||
|
self.loading = true;
|
||||||
|
actions.push(Action::BrowseFolder(uid.clone()));
|
||||||
|
}
|
||||||
|
None => self.loading = self.places.is_none(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The account card: a sign-in form, or who is signed in and how sync is doing.
|
||||||
|
pub fn section(ui: &mut egui::Ui, panel: &mut AccountPanel, actions: &mut Vec<Action>) {
|
||||||
|
ui::section(ui, "Proton account");
|
||||||
|
ui.label(ui::muted(
|
||||||
|
"Optional. Signed in, your shares, settings and watch history are kept in your own \
|
||||||
|
Drive, so every device shows the same library and resumes where another left off, \
|
||||||
|
and folders of your Drive — your files, other devices, what was shared with you — \
|
||||||
|
can be added to the library.",
|
||||||
|
));
|
||||||
|
ui.add_space(theme::space::L);
|
||||||
|
|
||||||
|
card(ui, |ui| match panel.status.clone() {
|
||||||
|
AccountStatus::Unknown => {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.add(egui::Spinner::new().size(14.0));
|
||||||
|
ui.label(ui::muted("reading the stored session…"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
AccountStatus::SignedOut => sign_in_form(ui, panel, actions),
|
||||||
|
AccountStatus::SecondFactor => code_form(ui, panel, actions),
|
||||||
|
AccountStatus::MailboxPassword => mailbox_form(ui, panel, actions),
|
||||||
|
AccountStatus::SignedIn(username) => signed_in(ui, panel, &username, actions),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn card(ui: &mut egui::Ui, add: impl FnOnce(&mut egui::Ui)) {
|
||||||
|
egui::Frame::new()
|
||||||
|
.fill(theme::card())
|
||||||
|
.corner_radius(egui::CornerRadius::same(theme::radius::MD))
|
||||||
|
.inner_margin(egui::Margin::same(14))
|
||||||
|
.show(ui, |ui| {
|
||||||
|
ui.set_width(ui.available_width().min(640.0));
|
||||||
|
add(ui);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sign_in_form(ui: &mut egui::Ui, panel: &mut AccountPanel, actions: &mut Vec<Action>) {
|
||||||
|
ui.label(ui::muted("Address"));
|
||||||
|
ui.add(
|
||||||
|
egui::TextEdit::singleline(&mut panel.username)
|
||||||
|
.hint_text("you@proton.me")
|
||||||
|
.desired_width(f32::INFINITY),
|
||||||
|
);
|
||||||
|
ui.add_space(theme::space::M);
|
||||||
|
ui.label(ui::muted("Password"));
|
||||||
|
let field = ui.add(
|
||||||
|
egui::TextEdit::singleline(&mut panel.password)
|
||||||
|
.password(true)
|
||||||
|
.desired_width(f32::INFINITY),
|
||||||
|
);
|
||||||
|
ui.add_space(theme::space::L);
|
||||||
|
error_line(ui, panel);
|
||||||
|
let ready = !panel.username.trim().is_empty() && !panel.password.is_empty() && !panel.busy;
|
||||||
|
let submitted = field.lost_focus() && ui.input(|input| input.key_pressed(egui::Key::Enter));
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
let clicked = ui
|
||||||
|
.add_enabled_ui(ready, |ui| ui::accent_button(ui, "Sign in"))
|
||||||
|
.inner
|
||||||
|
.clicked();
|
||||||
|
if ready && (clicked || submitted) {
|
||||||
|
panel.busy = true;
|
||||||
|
panel.error = None;
|
||||||
|
actions.push(Action::SignIn {
|
||||||
|
username: panel.username.trim().to_owned(),
|
||||||
|
password: panel.password.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
busy_line(ui, panel, "signing in…");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn code_form(ui: &mut egui::Ui, panel: &mut AccountPanel, actions: &mut Vec<Action>) {
|
||||||
|
ui.label(ui::muted("Two-factor code from your authenticator app"));
|
||||||
|
let field = ui.add(
|
||||||
|
egui::TextEdit::singleline(&mut panel.code)
|
||||||
|
.hint_text("123456")
|
||||||
|
.desired_width(160.0),
|
||||||
|
);
|
||||||
|
field.request_focus();
|
||||||
|
ui.add_space(theme::space::S);
|
||||||
|
// Security keys need WebAuthn, which the sign-in here does not speak. An
|
||||||
|
// account with nothing else is refused before this form (`AccountStore`).
|
||||||
|
ui.label(ui::muted(
|
||||||
|
"Security keys are not supported here — use the code from your authenticator app.",
|
||||||
|
));
|
||||||
|
ui.add_space(theme::space::L);
|
||||||
|
error_line(ui, panel);
|
||||||
|
let ready = !panel.code.trim().is_empty() && !panel.busy;
|
||||||
|
let submitted = field.lost_focus() && ui.input(|input| input.key_pressed(egui::Key::Enter));
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
let clicked = ui
|
||||||
|
.add_enabled_ui(ready, |ui| ui::accent_button(ui, "Continue"))
|
||||||
|
.inner
|
||||||
|
.clicked();
|
||||||
|
if ready && (clicked || submitted) {
|
||||||
|
panel.busy = true;
|
||||||
|
actions.push(Action::SubmitSecondFactor(panel.code.trim().to_owned()));
|
||||||
|
}
|
||||||
|
if ui.button("Cancel").clicked() {
|
||||||
|
actions.push(Action::CancelSignIn);
|
||||||
|
}
|
||||||
|
busy_line(ui, panel, "checking…");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mailbox_form(ui: &mut egui::Ui, panel: &mut AccountPanel, actions: &mut Vec<Action>) {
|
||||||
|
ui.label(ui::muted(
|
||||||
|
"This account has a separate mailbox password. It unlocks your files, and is kept \
|
||||||
|
in your system keyring.",
|
||||||
|
));
|
||||||
|
ui.add_space(theme::space::S);
|
||||||
|
let field = ui.add(
|
||||||
|
egui::TextEdit::singleline(&mut panel.mailbox)
|
||||||
|
.password(true)
|
||||||
|
.hint_text("Mailbox password")
|
||||||
|
.desired_width(f32::INFINITY),
|
||||||
|
);
|
||||||
|
ui.add_space(theme::space::L);
|
||||||
|
error_line(ui, panel);
|
||||||
|
let ready = !panel.mailbox.is_empty() && !panel.busy;
|
||||||
|
let submitted = field.lost_focus() && ui.input(|input| input.key_pressed(egui::Key::Enter));
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
let clicked = ui
|
||||||
|
.add_enabled_ui(ready, |ui| ui::accent_button(ui, "Unlock"))
|
||||||
|
.inner
|
||||||
|
.clicked();
|
||||||
|
if ready && (clicked || submitted) {
|
||||||
|
panel.busy = true;
|
||||||
|
actions.push(Action::SubmitMailboxPassword(panel.mailbox.clone()));
|
||||||
|
}
|
||||||
|
if ui.button("Cancel").clicked() {
|
||||||
|
actions.push(Action::CancelSignIn);
|
||||||
|
}
|
||||||
|
busy_line(ui, panel, "unlocking…");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn signed_in(
|
||||||
|
ui: &mut egui::Ui,
|
||||||
|
panel: &mut AccountPanel,
|
||||||
|
username: &str,
|
||||||
|
actions: &mut Vec<Action>,
|
||||||
|
) {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.vertical(|ui| {
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new(format!(
|
||||||
|
"{} {username}",
|
||||||
|
egui_phosphor::regular::USER_CIRCLE
|
||||||
|
))
|
||||||
|
.strong(),
|
||||||
|
);
|
||||||
|
let status = match (&panel.sync_error, &panel.synced) {
|
||||||
|
(Some(error), _) => format!("Did not sync: {error}"),
|
||||||
|
(None, Some((at, report))) => sync_line(*at, report, unix_now()),
|
||||||
|
(None, None) => {
|
||||||
|
"Library, settings and watch history sync through your Drive".to_owned()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
ui.label(ui::muted(status));
|
||||||
|
});
|
||||||
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||||
|
if ui
|
||||||
|
.add(
|
||||||
|
egui::Button::new(format!("{} Sign out", egui_phosphor::regular::SIGN_OUT))
|
||||||
|
.fill(theme::card_hover()),
|
||||||
|
)
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
actions.push(Action::SignOut);
|
||||||
|
}
|
||||||
|
if ui
|
||||||
|
.button(format!(
|
||||||
|
"{} Sync now",
|
||||||
|
egui_phosphor::regular::ARROWS_CLOCKWISE
|
||||||
|
))
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
actions.push(Action::SyncNow);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "Synced 3 min ago · 2 positions from other devices".
|
||||||
|
fn sync_line(at: i64, report: &SyncReport, now: i64) -> String {
|
||||||
|
let ago = (now - at).max(0);
|
||||||
|
let when = match ago {
|
||||||
|
0..60 => "just now".to_owned(),
|
||||||
|
60..3600 => format!("{} min ago", ago / 60),
|
||||||
|
_ => format!("{} h ago", ago / 3600),
|
||||||
|
};
|
||||||
|
let mut line = format!("Synced {when}");
|
||||||
|
if report.applied > 0 {
|
||||||
|
line.push_str(&format!(
|
||||||
|
" · {} from other devices",
|
||||||
|
ui::library::plural(report.applied, "position")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
line
|
||||||
|
}
|
||||||
|
|
||||||
|
fn error_line(ui: &mut egui::Ui, panel: &AccountPanel) {
|
||||||
|
if let Some(error) = &panel.error {
|
||||||
|
ui.label(theme::Role::Caption.rich(error).color(theme::danger()));
|
||||||
|
ui.add_space(theme::space::S);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn busy_line(ui: &mut egui::Ui, panel: &AccountPanel, text: &str) {
|
||||||
|
if panel.busy {
|
||||||
|
ui.add(egui::Spinner::new().size(14.0));
|
||||||
|
ui.label(ui::muted(text));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Browse the account's Drive and add a folder of it to the library.
|
||||||
|
pub fn browser(
|
||||||
|
ui: &mut egui::Ui,
|
||||||
|
panel: &mut AccountPanel,
|
||||||
|
shares: &[pstr_core::Share],
|
||||||
|
actions: &mut Vec<Action>,
|
||||||
|
) {
|
||||||
|
if !matches!(panel.status, AccountStatus::SignedIn(_)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ui.add_space(theme::space::XXL);
|
||||||
|
ui::section(ui, "Add from your Drive");
|
||||||
|
let browser = &mut panel.browser;
|
||||||
|
if !browser.open {
|
||||||
|
ui.label(ui::muted(
|
||||||
|
"A folder of your own Drive becomes a share like any link: crawled, browsable and \
|
||||||
|
streamed through your account.",
|
||||||
|
));
|
||||||
|
ui.add_space(theme::space::L);
|
||||||
|
if ui
|
||||||
|
.button(format!(
|
||||||
|
"{} Browse your Drive",
|
||||||
|
egui_phosphor::regular::FOLDER
|
||||||
|
))
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
browser.open = true;
|
||||||
|
if browser.places.is_none() {
|
||||||
|
browser.loading = true;
|
||||||
|
actions.push(Action::BrowsePlaces);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
card(ui, |ui| {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
if !browser.trail.is_empty()
|
||||||
|
&& ui
|
||||||
|
.button(egui_phosphor::regular::ARROW_LEFT)
|
||||||
|
.on_hover_text("Up a folder")
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
browser.up(actions);
|
||||||
|
}
|
||||||
|
let mut crumbs = vec!["Drive".to_owned()];
|
||||||
|
crumbs.extend(browser.trail.iter().map(|(name, _)| name.clone()));
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new(
|
||||||
|
crumbs.join(&format!(" {} ", egui_phosphor::regular::CARET_RIGHT)),
|
||||||
|
)
|
||||||
|
.strong(),
|
||||||
|
);
|
||||||
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||||
|
if ui.button("Close").clicked() {
|
||||||
|
browser.open = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
ui.add_space(theme::space::M);
|
||||||
|
|
||||||
|
if let Some(error) = &browser.error {
|
||||||
|
ui.label(theme::Role::Caption.rich(error).color(theme::danger()));
|
||||||
|
ui.add_space(theme::space::S);
|
||||||
|
}
|
||||||
|
if browser.loading {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.add(egui::Spinner::new().size(14.0));
|
||||||
|
ui.label(ui::muted("listing…"));
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut entered = None;
|
||||||
|
egui::ScrollArea::vertical()
|
||||||
|
.max_height(320.0)
|
||||||
|
.auto_shrink([false, true])
|
||||||
|
.show(ui, |ui| match browser.trail.last() {
|
||||||
|
None => {
|
||||||
|
for place in browser.places.iter().flatten() {
|
||||||
|
let icon = match place.kind {
|
||||||
|
PlaceKind::MyFiles => egui_phosphor::regular::HARD_DRIVES,
|
||||||
|
PlaceKind::Device => egui_phosphor::regular::DESKTOP,
|
||||||
|
PlaceKind::SharedWithMe => egui_phosphor::regular::USERS,
|
||||||
|
};
|
||||||
|
if row(ui, icon, &place.name, None, true).clicked() {
|
||||||
|
entered = Some((place.name.clone(), place.uid.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(_) => {
|
||||||
|
let entries = browser
|
||||||
|
.entries
|
||||||
|
.as_ref()
|
||||||
|
.map(|(_, entries)| entries.as_slice());
|
||||||
|
if entries.is_some_and(<[DriveEntry]>::is_empty) {
|
||||||
|
ui.label(ui::muted("This folder is empty."));
|
||||||
|
}
|
||||||
|
for entry in entries.unwrap_or_default() {
|
||||||
|
let (icon, detail) = if entry.is_folder {
|
||||||
|
(egui_phosphor::regular::FOLDER, None)
|
||||||
|
} else if entry
|
||||||
|
.media_type
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|media| media.starts_with("video/"))
|
||||||
|
{
|
||||||
|
(
|
||||||
|
egui_phosphor::regular::FILE_VIDEO,
|
||||||
|
entry.size.map(format_size),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(egui_phosphor::regular::FILE, entry.size.map(format_size))
|
||||||
|
};
|
||||||
|
if row(ui, icon, &entry.name, detail.as_deref(), entry.is_folder).clicked()
|
||||||
|
&& entry.is_folder
|
||||||
|
{
|
||||||
|
entered = Some((entry.name.clone(), entry.uid.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if let Some((name, uid)) = entered {
|
||||||
|
browser.enter(name, uid, actions);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some((name, uid)) = browser.trail.last() {
|
||||||
|
ui.add_space(theme::space::L);
|
||||||
|
let added = shares.iter().any(|share| {
|
||||||
|
share
|
||||||
|
.folder
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|folder| folder.uid() == *uid)
|
||||||
|
});
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
if added {
|
||||||
|
ui.label(ui::muted("This folder is already in the library."));
|
||||||
|
} else if ui::accent_button(ui, "Add this folder").clicked() {
|
||||||
|
actions.push(Action::AddAccountFolder {
|
||||||
|
name: name.clone(),
|
||||||
|
uid: uid.clone(),
|
||||||
|
});
|
||||||
|
browser.open = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One line of the browser. Only folders and places are clickable.
|
||||||
|
fn row(
|
||||||
|
ui: &mut egui::Ui,
|
||||||
|
icon: &str,
|
||||||
|
name: &str,
|
||||||
|
detail: Option<&str>,
|
||||||
|
clickable: bool,
|
||||||
|
) -> egui::Response {
|
||||||
|
let mut text = egui::text::LayoutJob::default();
|
||||||
|
let body = ui.style().text_styles[&egui::TextStyle::Body].clone();
|
||||||
|
text.append(
|
||||||
|
&format!("{icon} {name}"),
|
||||||
|
0.0,
|
||||||
|
egui::TextFormat::simple(
|
||||||
|
body.clone(),
|
||||||
|
if clickable {
|
||||||
|
theme::text()
|
||||||
|
} else {
|
||||||
|
theme::muted()
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if let Some(detail) = detail {
|
||||||
|
text.append(
|
||||||
|
&format!(" {detail}"),
|
||||||
|
0.0,
|
||||||
|
egui::TextFormat::simple(body, theme::muted()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let sense = if clickable {
|
||||||
|
egui::Sense::click()
|
||||||
|
} else {
|
||||||
|
egui::Sense::hover()
|
||||||
|
};
|
||||||
|
let response = ui.add(egui::Label::new(text).sense(sense).truncate());
|
||||||
|
if clickable && response.hovered() {
|
||||||
|
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
|
||||||
|
}
|
||||||
|
response
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_size(bytes: i64) -> String {
|
||||||
|
let bytes = bytes.max(0) as f64;
|
||||||
|
const GIB: f64 = 1024.0 * 1024.0 * 1024.0;
|
||||||
|
const MIB: f64 = 1024.0 * 1024.0;
|
||||||
|
if bytes >= GIB {
|
||||||
|
format!("{:.1} GiB", bytes / GIB)
|
||||||
|
} else {
|
||||||
|
format!("{:.0} MiB", bytes / MIB)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn unix_now() -> i64 {
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|since| since.as_secs() as i64)
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_sync_line_says_how_long_ago_and_what_came_in() {
|
||||||
|
let quiet = SyncReport::default();
|
||||||
|
assert_eq!(sync_line(100, &quiet, 130), "Synced just now");
|
||||||
|
assert_eq!(sync_line(100, &quiet, 400), "Synced 5 min ago");
|
||||||
|
let busy = SyncReport {
|
||||||
|
applied: 2,
|
||||||
|
..SyncReport::default()
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
sync_line(0, &busy, 7200),
|
||||||
|
"Synced 2 h ago · 2 positions from other devices"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn signing_out_forgets_what_was_typed_and_browsed() {
|
||||||
|
let mut panel = AccountPanel {
|
||||||
|
password: "secret".into(),
|
||||||
|
mailbox: "also secret".into(),
|
||||||
|
..AccountPanel::default()
|
||||||
|
};
|
||||||
|
panel.browser.open = true;
|
||||||
|
panel.set_status(AccountStatus::SignedOut);
|
||||||
|
assert!(panel.password.is_empty() && panel.mailbox.is_empty());
|
||||||
|
assert!(!panel.browser.open);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_listing_for_a_folder_already_left_is_dropped() {
|
||||||
|
let uid = |link: &str| {
|
||||||
|
NodeUid::new(
|
||||||
|
pstr_core::proton_sdk::ids::VolumeId::new("v".to_owned()),
|
||||||
|
pstr_core::proton_sdk::ids::LinkId::new(link.to_owned()),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let mut browser = DriveBrowser {
|
||||||
|
trail: vec![("b".into(), uid("b"))],
|
||||||
|
loading: true,
|
||||||
|
..DriveBrowser::default()
|
||||||
|
};
|
||||||
|
browser.folder_arrived(uid("a"), Vec::new());
|
||||||
|
assert!(browser.loading && browser.entries.is_none());
|
||||||
|
browser.folder_arrived(uid("b"), Vec::new());
|
||||||
|
assert!(!browser.loading && browser.entries.is_some());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
//! mutating the library it is iterating: pages collect [`Action`]s and the app
|
//! mutating the library it is iterating: pages collect [`Action`]s and the app
|
||||||
//! applies them after the frame is drawn.
|
//! applies them after the frame is drawn.
|
||||||
|
|
||||||
|
pub mod account;
|
||||||
pub mod downloads;
|
pub mod downloads;
|
||||||
pub mod history;
|
pub mod history;
|
||||||
pub mod library;
|
pub mod library;
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ pub fn show(
|
|||||||
shares: &[Share],
|
shares: &[Share],
|
||||||
library: &Library,
|
library: &Library,
|
||||||
form: &mut ShareForm,
|
form: &mut ShareForm,
|
||||||
|
account: &mut ui::account::AccountPanel,
|
||||||
actions: &mut Vec<Action>,
|
actions: &mut Vec<Action>,
|
||||||
) {
|
) {
|
||||||
egui::ScrollArea::vertical()
|
egui::ScrollArea::vertical()
|
||||||
@@ -40,6 +41,10 @@ pub fn show(
|
|||||||
|
|
||||||
ui.add_space(theme::space::XXL);
|
ui.add_space(theme::space::XXL);
|
||||||
add_form(ui, shares, form, actions);
|
add_form(ui, shares, form, actions);
|
||||||
|
|
||||||
|
ui.add_space(theme::space::XXL);
|
||||||
|
ui::account::section(ui, account, actions);
|
||||||
|
ui::account::browser(ui, account, shares, actions);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,6 +61,9 @@ fn share_row(ui: &mut egui::Ui, share: &Share, titles: usize, actions: &mut Vec<
|
|||||||
// What is in it, rather than what it is called inside the
|
// What is in it, rather than what it is called inside the
|
||||||
// app. The id is still a hover away for a bug report.
|
// app. The id is still a hover away for a bug report.
|
||||||
let mut detail = ui::library::plural(titles, "title");
|
let mut detail = ui::library::plural(titles, "title");
|
||||||
|
if share.folder.is_some() {
|
||||||
|
detail.push_str(" · from your Drive");
|
||||||
|
}
|
||||||
if share.has_custom_password {
|
if share.has_custom_password {
|
||||||
detail.push_str(" · password protected");
|
detail.push_str(" · password protected");
|
||||||
}
|
}
|
||||||
@@ -211,6 +219,7 @@ mod tests {
|
|||||||
name: "anime".into(),
|
name: "anime".into(),
|
||||||
token: token.into(),
|
token: token.into(),
|
||||||
has_custom_password: false,
|
has_custom_password: false,
|
||||||
|
folder: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
//! Proton's CAPTCHA, in a window of its own.
|
||||||
|
//!
|
||||||
|
//! Proton puts a CAPTCHA in front of a sign-in it does not recognise — a new
|
||||||
|
//! network, a VPN exit. The page reports success by posting a message to its
|
||||||
|
//! host rather than by redirecting, so the system browser has nowhere to send
|
||||||
|
//! it back to: it needs a webview this app controls, as on Android and in
|
||||||
|
//! `proton-drive-linux`.
|
||||||
|
//!
|
||||||
|
//! The webview runs in a child process — this same executable, started with
|
||||||
|
//! [`ARG`] — because its windowing library and eframe's cannot both own the
|
||||||
|
//! event loop of one process. The child prints the solved token on stdout and
|
||||||
|
//! exits; closing its window exits with nothing printed, which is a cancel.
|
||||||
|
|
||||||
|
use std::io::{Read as _, Write as _};
|
||||||
|
|
||||||
|
/// The argument that starts this executable as the verification window,
|
||||||
|
/// followed by the page's URL.
|
||||||
|
pub const ARG: &str = "--human-verification";
|
||||||
|
|
||||||
|
/// Forwards every message the page posts to this process. The page targets
|
||||||
|
/// `window.parent`, which for a top-level page is the page itself, so a
|
||||||
|
/// listener on its own window hears it.
|
||||||
|
const BRIDGE: &str = r#"
|
||||||
|
window.addEventListener('message', function (event) {
|
||||||
|
try {
|
||||||
|
window.ipc.postMessage(typeof event.data === 'string' ? event.data : JSON.stringify(event.data));
|
||||||
|
} catch (e) {}
|
||||||
|
});
|
||||||
|
"#;
|
||||||
|
|
||||||
|
/// Run the verification window if this process was started for it, returning
|
||||||
|
/// the process's exit code; `None` when it was started as the app.
|
||||||
|
pub fn run_if_requested() -> Option<i32> {
|
||||||
|
let mut args = std::env::args().skip(1);
|
||||||
|
if args.next().as_deref() != Some(ARG) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let Some(url) = args.next().filter(|url| is_proton_page(url)) else {
|
||||||
|
eprintln!("{ARG} needs a verification page on proton.me");
|
||||||
|
return Some(2);
|
||||||
|
};
|
||||||
|
Some(match window(&url) {
|
||||||
|
Ok(()) => 0,
|
||||||
|
Err(error) => {
|
||||||
|
eprintln!("the verification window failed: {error}");
|
||||||
|
1
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Present `url` in the verification window and wait for the token it yields.
|
||||||
|
///
|
||||||
|
/// `None` when the viewer closed the window, or it could not be shown.
|
||||||
|
/// Blocking: call it off the UI thread and outside the async runtime.
|
||||||
|
pub fn solve(url: &str) -> Option<String> {
|
||||||
|
let exe = std::env::current_exe()
|
||||||
|
.inspect_err(|error| tracing::warn!("locate this executable: {error}"))
|
||||||
|
.ok()?;
|
||||||
|
let mut child = std::process::Command::new(exe)
|
||||||
|
.arg(ARG)
|
||||||
|
.arg(url)
|
||||||
|
.stdin(std::process::Stdio::null())
|
||||||
|
.stdout(std::process::Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
.inspect_err(|error| tracing::warn!("open the verification window: {error}"))
|
||||||
|
.ok()?;
|
||||||
|
let mut out = String::new();
|
||||||
|
if let Some(mut stdout) = child.stdout.take() {
|
||||||
|
let _ = stdout.read_to_string(&mut out);
|
||||||
|
}
|
||||||
|
let status = child.wait().ok()?;
|
||||||
|
if !status.success() {
|
||||||
|
tracing::warn!("the verification window exited with {status}");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
out.lines()
|
||||||
|
.map(str::trim)
|
||||||
|
.rfind(|line| !line.is_empty())
|
||||||
|
.map(str::to_owned)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn window(url: &str) -> anyhow::Result<()> {
|
||||||
|
use tao::event::{Event, WindowEvent};
|
||||||
|
use tao::event_loop::{ControlFlow, EventLoopBuilder};
|
||||||
|
use tao::window::WindowBuilder;
|
||||||
|
use wry::{NewWindowResponse, WebViewBuilder};
|
||||||
|
|
||||||
|
let event_loop = EventLoopBuilder::<String>::with_user_event().build();
|
||||||
|
let window = WindowBuilder::new()
|
||||||
|
.with_title("Confirm you are human — Proton Stream")
|
||||||
|
.with_inner_size(tao::dpi::LogicalSize::new(440.0, 680.0))
|
||||||
|
.build(&event_loop)?;
|
||||||
|
|
||||||
|
let proxy = event_loop.create_proxy();
|
||||||
|
let builder = WebViewBuilder::new()
|
||||||
|
.with_url(url)
|
||||||
|
.with_initialization_script(BRIDGE)
|
||||||
|
.with_ipc_handler(move |request| {
|
||||||
|
// Every message the page posts lands here, most of them its own
|
||||||
|
// chatter; only a completion carries a token.
|
||||||
|
if let Some(token) = pstr_core::account::verification_token(request.body()) {
|
||||||
|
let _ = proxy.send_event(token);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// The bridge above is reachable by whatever the page shows, so the
|
||||||
|
// page stays on Proton.
|
||||||
|
.with_navigation_handler(|url| is_allowed_navigation(&url))
|
||||||
|
.with_new_window_req_handler(|_, _| NewWindowResponse::Deny);
|
||||||
|
|
||||||
|
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
||||||
|
let _webview = {
|
||||||
|
use tao::platform::unix::WindowExtUnix as _;
|
||||||
|
use wry::WebViewBuilderExtUnix as _;
|
||||||
|
let vbox = window
|
||||||
|
.default_vbox()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("the window has no GTK container"))?;
|
||||||
|
builder.build_gtk(vbox)?
|
||||||
|
};
|
||||||
|
#[cfg(any(target_os = "windows", target_os = "macos"))]
|
||||||
|
let _webview = builder.build(&window)?;
|
||||||
|
|
||||||
|
let mut answered = false;
|
||||||
|
event_loop.run(move |event, _, control_flow| {
|
||||||
|
*control_flow = ControlFlow::Wait;
|
||||||
|
match event {
|
||||||
|
Event::UserEvent(token) if !answered => {
|
||||||
|
answered = true;
|
||||||
|
let mut stdout = std::io::stdout().lock();
|
||||||
|
let _ = writeln!(stdout, "{token}");
|
||||||
|
let _ = stdout.flush();
|
||||||
|
*control_flow = ControlFlow::Exit;
|
||||||
|
}
|
||||||
|
Event::WindowEvent {
|
||||||
|
event: WindowEvent::CloseRequested,
|
||||||
|
..
|
||||||
|
} => *control_flow = ControlFlow::Exit,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `url` is a page of Proton's own, over HTTPS.
|
||||||
|
fn is_proton_page(url: &str) -> bool {
|
||||||
|
let Some(rest) = url.strip_prefix("https://") else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let authority = rest.split(['/', '?', '#']).next().unwrap_or_default();
|
||||||
|
// A user-info part would let `proton.me@evil.example` pass.
|
||||||
|
if authority.contains('@') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let host = authority.split(':').next().unwrap_or_default();
|
||||||
|
host == "proton.me" || host.ends_with(".proton.me")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the webview may go to `url`: Proton's pages, and the documents a
|
||||||
|
/// page makes for itself, which have no origin of their own to leave to.
|
||||||
|
fn is_allowed_navigation(url: &str) -> bool {
|
||||||
|
is_proton_page(url) || url == "about:blank" || url.starts_with("about:srcdoc")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_proton_pages_over_https_are_proton_pages() {
|
||||||
|
assert!(is_proton_page(
|
||||||
|
"https://verify.proton.me/?embed=1&methods=captcha&token=x"
|
||||||
|
));
|
||||||
|
assert!(is_proton_page("https://proton.me"));
|
||||||
|
assert!(!is_proton_page("http://verify.proton.me/"));
|
||||||
|
assert!(!is_proton_page("https://proton.me.evil.example/"));
|
||||||
|
assert!(!is_proton_page("https://evilproton.me/"));
|
||||||
|
assert!(!is_proton_page("https://proton.me@evil.example/"));
|
||||||
|
assert!(!is_proton_page(
|
||||||
|
"https://evil.example/?next=https://proton.me"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_window_stays_on_proton() {
|
||||||
|
assert!(is_allowed_navigation(
|
||||||
|
"https://verify-api.proton.me/core/v4/captcha"
|
||||||
|
));
|
||||||
|
assert!(is_allowed_navigation("about:blank"));
|
||||||
|
assert!(!is_allowed_navigation("https://example.com/"));
|
||||||
|
assert!(!is_allowed_navigation("file:///etc/passwd"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,5 +23,6 @@ tokio.workspace = true
|
|||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
clap.workspace = true
|
clap.workspace = true
|
||||||
rpassword.workspace = true
|
rpassword.workspace = true
|
||||||
|
parking_lot.workspace = true
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
tracing-subscriber.workspace = true
|
tracing-subscriber.workspace = true
|
||||||
@@ -8,9 +8,12 @@
|
|||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
use proton_sdk::ids::{LinkId, NodeUid, VolumeId};
|
use proton_sdk::ids::{LinkId, NodeUid, VolumeId};
|
||||||
|
use pstr_core::account::{PlaceKind, SignIn};
|
||||||
use pstr_core::catalog::{Catalog, build_rows};
|
use pstr_core::catalog::{Catalog, build_rows};
|
||||||
use pstr_core::config::AppDirs;
|
use pstr_core::config::AppDirs;
|
||||||
use pstr_core::{ShareStore, SharedLibrary};
|
use pstr_core::shares::AccountFolder;
|
||||||
|
use pstr_core::sync::WatchSync;
|
||||||
|
use pstr_core::{Account, AccountStore, KeyringSecretStore, ShareStore, SharedLibrary};
|
||||||
use pstr_player::{EndReason, Player, PlayerConfig, PlayerEvent};
|
use pstr_player::{EndReason, Player, PlayerConfig, PlayerEvent};
|
||||||
use pstr_stream::{DiskCacheConfig, LibraryOpener, StreamConfig, StreamSource};
|
use pstr_stream::{DiskCacheConfig, LibraryOpener, StreamConfig, StreamSource};
|
||||||
|
|
||||||
@@ -69,8 +72,38 @@ enum Command {
|
|||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
custom_password: bool,
|
custom_password: bool,
|
||||||
},
|
},
|
||||||
|
/// Add a folder of the signed-in account as a share.
|
||||||
|
///
|
||||||
|
/// The folder is named by the `VOLUME:LINK` id `browse` prints.
|
||||||
|
AddFolder {
|
||||||
|
/// What to call it in the UI.
|
||||||
|
#[arg(long)]
|
||||||
|
name: String,
|
||||||
|
/// The folder, as `VOLUME:LINK`.
|
||||||
|
folder: String,
|
||||||
|
},
|
||||||
/// List configured shares.
|
/// List configured shares.
|
||||||
Shares,
|
Shares,
|
||||||
|
/// Sign in to a Proton account, for watch history across devices and for
|
||||||
|
/// folders of your own Drive.
|
||||||
|
///
|
||||||
|
/// The password, a second-factor code and a mailbox password are prompted
|
||||||
|
/// for as the account needs them.
|
||||||
|
Login {
|
||||||
|
/// The account's address.
|
||||||
|
username: String,
|
||||||
|
},
|
||||||
|
/// Sign out and forget the stored session.
|
||||||
|
Logout,
|
||||||
|
/// Show which account is signed in.
|
||||||
|
Account,
|
||||||
|
/// List the account's Drive: its top-level places, or one folder.
|
||||||
|
Browse {
|
||||||
|
/// The folder to list, as `VOLUME:LINK`; the places when omitted.
|
||||||
|
folder: Option<String>,
|
||||||
|
},
|
||||||
|
/// Sync watch history with the signed-in account's Drive.
|
||||||
|
Sync,
|
||||||
/// Forget a share and its stored credentials.
|
/// Forget a share and its stored credentials.
|
||||||
Remove {
|
Remove {
|
||||||
/// The share id, as printed by `shares`.
|
/// The share id, as printed by `shares`.
|
||||||
@@ -210,6 +243,7 @@ async fn run(runtime: std::sync::Arc<tokio::runtime::Runtime>) -> Result<()> {
|
|||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
let dirs = AppDirs::ensure().context("resolve app directories")?;
|
let dirs = AppDirs::ensure().context("resolve app directories")?;
|
||||||
let store = ShareStore::new(dirs.clone());
|
let store = ShareStore::new(dirs.clone());
|
||||||
|
let accounts = AccountStore::new(std::sync::Arc::new(KeyringSecretStore));
|
||||||
|
|
||||||
match cli.command {
|
match cli.command {
|
||||||
Command::Add {
|
Command::Add {
|
||||||
@@ -222,12 +256,105 @@ async fn run(runtime: std::sync::Arc<tokio::runtime::Runtime>) -> Result<()> {
|
|||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
let share = store
|
let (share, _) = store
|
||||||
.add(&name, &url, password.as_deref())
|
.add_verified(&name, &url, password.as_deref())
|
||||||
|
.await
|
||||||
.context("add share")?;
|
.context("add share")?;
|
||||||
println!("added {} ({})", share.name, share.id);
|
println!("added {} ({})", share.name, share.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Command::AddFolder { name, folder } => {
|
||||||
|
let uid = parse_uid(&folder)?;
|
||||||
|
let share = store
|
||||||
|
.add_folder(
|
||||||
|
&name,
|
||||||
|
AccountFolder {
|
||||||
|
volume_id: uid.volume_id.as_str().to_owned(),
|
||||||
|
link_id: uid.link_id.as_str().to_owned(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.context("add folder")?;
|
||||||
|
println!("added {} ({})", share.name, share.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
Command::Login { username } => login(&accounts, &username).await?,
|
||||||
|
|
||||||
|
Command::Logout => {
|
||||||
|
let account = accounts.resume().ok().flatten();
|
||||||
|
accounts
|
||||||
|
.sign_out(account.as_ref())
|
||||||
|
.await
|
||||||
|
.context("sign out")?;
|
||||||
|
println!("signed out");
|
||||||
|
}
|
||||||
|
|
||||||
|
Command::Account => match accounts.username().context("read the account")? {
|
||||||
|
Some(username) => println!("signed in as {username}"),
|
||||||
|
None => println!("not signed in — sign in with `pstr login <address>`"),
|
||||||
|
},
|
||||||
|
|
||||||
|
Command::Browse { folder } => {
|
||||||
|
let account = signed_in(&accounts)?;
|
||||||
|
match folder {
|
||||||
|
None => {
|
||||||
|
for place in account.places().await.context("list places")? {
|
||||||
|
let kind = match place.kind {
|
||||||
|
PlaceKind::MyFiles => "files",
|
||||||
|
PlaceKind::Device => "device",
|
||||||
|
PlaceKind::SharedWithMe => "shared",
|
||||||
|
};
|
||||||
|
println!("{}\t{kind}\t{}", format_uid(&place.uid), place.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(folder) => {
|
||||||
|
let entries = account
|
||||||
|
.folder_children(&parse_uid(&folder)?)
|
||||||
|
.await
|
||||||
|
.context("list folder")?;
|
||||||
|
for entry in entries {
|
||||||
|
let kind = if entry.is_folder { "dir" } else { "file" };
|
||||||
|
println!("{}\t{kind}\t{}", format_uid(&entry.uid), entry.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Command::Sync => {
|
||||||
|
let account = signed_in(&accounts)?;
|
||||||
|
let sync = WatchSync::open(&dirs).context("open sync state")?;
|
||||||
|
let catalog =
|
||||||
|
parking_lot::Mutex::new(Catalog::open(&dirs.catalog_db()).context("open catalog")?);
|
||||||
|
let report = sync
|
||||||
|
.sync(&account, &catalog, &store)
|
||||||
|
.await
|
||||||
|
.context("sync")?;
|
||||||
|
let profile = &report.profile;
|
||||||
|
for id in &profile.shares_removed {
|
||||||
|
catalog
|
||||||
|
.lock()
|
||||||
|
.remove_share(id)
|
||||||
|
.context("drop a removed share")?;
|
||||||
|
}
|
||||||
|
println!(
|
||||||
|
"read {} other device(s), took {} newer position(s), \
|
||||||
|
{} share(s) added, {} removed, settings {}, {}",
|
||||||
|
report.devices,
|
||||||
|
report.applied,
|
||||||
|
profile.shares_added.len() + profile.shares_changed.len(),
|
||||||
|
profile.shares_removed.len(),
|
||||||
|
if profile.settings {
|
||||||
|
"updated"
|
||||||
|
} else {
|
||||||
|
"unchanged"
|
||||||
|
},
|
||||||
|
if report.uploaded {
|
||||||
|
"uploaded this device's history"
|
||||||
|
} else {
|
||||||
|
"this device's history was already up to date"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Command::Shares => {
|
Command::Shares => {
|
||||||
let shares = store.list().context("list shares")?;
|
let shares = store.list().context("list shares")?;
|
||||||
if shares.is_empty() {
|
if shares.is_empty() {
|
||||||
@@ -239,17 +366,41 @@ async fn run(runtime: std::sync::Arc<tokio::runtime::Runtime>) -> Result<()> {
|
|||||||
} else {
|
} else {
|
||||||
""
|
""
|
||||||
};
|
};
|
||||||
println!("{}\t{}{}", share.id, share.name, custom);
|
let kind = if share.folder.is_some() {
|
||||||
|
" [account folder]"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
println!("{}\t{}{}{}", share.id, share.name, custom, kind);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Command::Remove { id } => {
|
Command::Remove { id } => {
|
||||||
store.remove(&id).context("remove share")?;
|
store.remove(&id).context("remove share")?;
|
||||||
|
// Its catalog rows go too, or the library keeps listing its files —
|
||||||
|
// beside the new share's, when the same folder was added back
|
||||||
|
// another way.
|
||||||
|
let mut catalog = Catalog::open(&dirs.catalog_db()).context("open catalog")?;
|
||||||
|
let offline = catalog
|
||||||
|
.offline_files_for_share(&id)
|
||||||
|
.context("read the share's downloads")?;
|
||||||
|
for (link_id, file) in &offline {
|
||||||
|
match std::fs::remove_file(dirs.offline_file(&id, link_id, &file.revision_id)) {
|
||||||
|
Err(error) if error.kind() != std::io::ErrorKind::NotFound => {
|
||||||
|
return Err(error).context("delete a downloaded file");
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catalog
|
||||||
|
.remove_share(&id)
|
||||||
|
.context("drop the share's catalog rows")?;
|
||||||
println!("removed {id}");
|
println!("removed {id}");
|
||||||
}
|
}
|
||||||
|
|
||||||
Command::Crawl { share } => {
|
Command::Crawl { share } => {
|
||||||
let (library, failures) = SharedLibrary::open_all(&store)
|
let account = accounts.resume().context("resume the account")?;
|
||||||
|
let (library, failures) = SharedLibrary::open_all(&store, account.as_ref())
|
||||||
.await
|
.await
|
||||||
.context("open shares")?;
|
.context("open shares")?;
|
||||||
for (share, error) in &failures {
|
for (share, error) in &failures {
|
||||||
@@ -643,7 +794,10 @@ async fn open_target(
|
|||||||
|
|
||||||
println!("file: {} ({})", target.name, target.share_id);
|
println!("file: {} ({})", target.name, target.share_id);
|
||||||
|
|
||||||
let (library, failures) = SharedLibrary::open_all(store)
|
let account = AccountStore::new(std::sync::Arc::new(KeyringSecretStore))
|
||||||
|
.resume()
|
||||||
|
.context("resume the account")?;
|
||||||
|
let (library, failures) = SharedLibrary::open_all(store, account.as_ref())
|
||||||
.await
|
.await
|
||||||
.context("open shares")?;
|
.context("open shares")?;
|
||||||
for (share, error) in &failures {
|
for (share, error) in &failures {
|
||||||
@@ -967,7 +1121,74 @@ async fn play(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Unix seconds, for the "when was this asked" columns.
|
/// Walk an interactive sign-in through whatever the account asks for.
|
||||||
|
async fn login(accounts: &AccountStore, username: &str) -> Result<()> {
|
||||||
|
let password = read_password("Password: ")?;
|
||||||
|
let mut step = match accounts.sign_in(username, &password, None).await {
|
||||||
|
Ok(step) => step,
|
||||||
|
Err(pstr_core::Error::HumanVerification(challenge)) => {
|
||||||
|
// Solving a CAPTCHA needs a browser engine that can hand the
|
||||||
|
// token back, which a terminal does not have. The desktop app has
|
||||||
|
// one, and keeps its session in the same keyring entry.
|
||||||
|
anyhow::bail!(
|
||||||
|
"Proton wants a CAPTCHA before it accepts this sign-in. Sign in once in the \
|
||||||
|
desktop app on this computer, which shows it and shares its session with \
|
||||||
|
pstr, or try again later. ({})",
|
||||||
|
challenge.verification_url()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(error) => return Err(error).context("sign in"),
|
||||||
|
};
|
||||||
|
loop {
|
||||||
|
step = match step {
|
||||||
|
SignIn::Done(account) => {
|
||||||
|
println!("signed in as {}", account.username());
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
SignIn::SecondFactor(pending) => {
|
||||||
|
// Security keys need WebAuthn, which this sign-in does not speak.
|
||||||
|
let code = read_password("Code from your authenticator app: ")?;
|
||||||
|
pending
|
||||||
|
.second_factor(&code)
|
||||||
|
.await
|
||||||
|
.context("second factor")?
|
||||||
|
}
|
||||||
|
SignIn::MailboxPassword(pending) => {
|
||||||
|
let mailbox = read_password("Mailbox password: ")?;
|
||||||
|
SignIn::Done(
|
||||||
|
pending
|
||||||
|
.mailbox_password(&mailbox)
|
||||||
|
.await
|
||||||
|
.context("mailbox password")?,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn signed_in(accounts: &AccountStore) -> Result<Account> {
|
||||||
|
accounts
|
||||||
|
.resume()
|
||||||
|
.context("resume the account")?
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("not signed in — sign in with `pstr login <address>`"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A node as `browse` prints it and `add-folder` takes it.
|
||||||
|
fn format_uid(uid: &NodeUid) -> String {
|
||||||
|
format!("{}:{}", uid.volume_id.as_str(), uid.link_id.as_str())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_uid(text: &str) -> Result<NodeUid> {
|
||||||
|
let (volume, link) = text
|
||||||
|
.split_once(':')
|
||||||
|
.filter(|(volume, link)| !volume.is_empty() && !link.is_empty())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("expected VOLUME:LINK, as `pstr browse` prints"))?;
|
||||||
|
Ok(NodeUid::new(
|
||||||
|
VolumeId::new(volume.to_owned()),
|
||||||
|
LinkId::new(link.to_owned()),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
fn unix_now() -> i64 {
|
fn unix_now() -> i64 {
|
||||||
std::time::SystemTime::now()
|
std::time::SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ tracing.workspace = true
|
|||||||
directories.workspace = true
|
directories.workspace = true
|
||||||
rusqlite.workspace = true
|
rusqlite.workspace = true
|
||||||
sha2.workspace = true
|
sha2.workspace = true
|
||||||
|
parking_lot.workspace = true
|
||||||
|
getrandom.workspace = true
|
||||||
|
|
||||||
# The credential store, per platform. Without the target's feature `keyring`
|
# The credential store, per platform. Without the target's feature `keyring`
|
||||||
# compiles fine and silently resolves to its in-memory `mock` store, which
|
# compiles fine and silently resolves to its in-memory `mock` store, which
|
||||||
|
|||||||
@@ -0,0 +1,673 @@
|
|||||||
|
//! Signing in to a Proton account, and keeping the session.
|
||||||
|
//!
|
||||||
|
//! The rest of the app works without one — public links need no account. A
|
||||||
|
//! signed-in account adds two things: folders of the viewer's own Drive (My
|
||||||
|
//! Files, other devices, what was shared with them) as library shares, and
|
||||||
|
//! watch history kept in their Drive so every device resumes where another
|
||||||
|
//! left off ([`crate::sync`]).
|
||||||
|
//!
|
||||||
|
//! ## What is kept, and where
|
||||||
|
//!
|
||||||
|
//! One JSON blob in the [`SecretStore`] holding the session tokens and, per
|
||||||
|
//! account key, the passphrase that unlocks it. Those passphrases are derived
|
||||||
|
//! at sign-in from the mailbox password and the key salts, and kept *instead*
|
||||||
|
//! of the password: they unlock the key chain just the same, but a store that
|
||||||
|
//! leaks gives away this app's access to the account rather than the password
|
||||||
|
//! — which, on a single-password account, is the login password itself.
|
||||||
|
//! Deriving them later is not an option either way: `core/v4/keys/salts`
|
||||||
|
//! needs a scope only a password sign-in grants, and answers 403 after the
|
||||||
|
//! first token refresh.
|
||||||
|
//!
|
||||||
|
//! Proton refresh tokens are single-use, so every refresh is written back
|
||||||
|
//! (see [`persist_refreshed_tokens`]). A refresh that is not leaves the store
|
||||||
|
//! holding a dead token and the next launch signed out.
|
||||||
|
//!
|
||||||
|
//! ## Signing in is several steps
|
||||||
|
//!
|
||||||
|
//! Password, then maybe a second-factor code, then — on an account with a
|
||||||
|
//! separate mailbox password — that. Each front end asks for them in its own
|
||||||
|
//! way, so [`AccountStore::sign_in`] returns a [`SignIn`] saying what it needs
|
||||||
|
//! next and holding the half-made session until it is given.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use proton_drive_rs::{NodeKind, ProtonDriveClient};
|
||||||
|
use proton_sdk::api::HumanVerificationCredential;
|
||||||
|
use proton_sdk::error::ProtonError;
|
||||||
|
use proton_sdk::ids::NodeUid;
|
||||||
|
use proton_sdk::session::{PasswordMode, ProtonApiSession, ResumeParameters};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::error::{Error, Result};
|
||||||
|
use crate::shares::{SecretStore, client_configuration};
|
||||||
|
|
||||||
|
/// The secret-store key the session is kept under. Share secrets are keyed
|
||||||
|
/// `share-…`, so the two cannot collide.
|
||||||
|
const SESSION_KEY: &str = "proton-account";
|
||||||
|
|
||||||
|
/// Why an account whose only second factor is a security key cannot sign in.
|
||||||
|
const SECURITY_KEY_ONLY: &str = "This account's only second factor is a security key, which this \
|
||||||
|
app cannot use yet. Add an authenticator app in your Proton account settings, then sign in \
|
||||||
|
with its code.";
|
||||||
|
|
||||||
|
/// Blocks in flight for streaming from the account, as for a public link —
|
||||||
|
/// see `ShareStore::open`.
|
||||||
|
const MAX_INFLIGHT_BLOCKS: usize = 48;
|
||||||
|
|
||||||
|
/// Everything needed to resume the session unattended.
|
||||||
|
#[derive(Serialize, Deserialize, Clone)]
|
||||||
|
struct StoredSession {
|
||||||
|
session_id: String,
|
||||||
|
username: String,
|
||||||
|
user_id: String,
|
||||||
|
access_token: String,
|
||||||
|
refresh_token: String,
|
||||||
|
scopes: Vec<String>,
|
||||||
|
/// `1` single password, `2` separate mailbox password — Proton's values.
|
||||||
|
password_mode: u8,
|
||||||
|
/// Key id → the passphrase that unlocks it. ASCII: they are bcrypt output.
|
||||||
|
key_passphrases: HashMap<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StoredSession {
|
||||||
|
fn capture(
|
||||||
|
session: &ProtonApiSession,
|
||||||
|
tokens: proton_sdk::http::Tokens,
|
||||||
|
key_passphrases: HashMap<String, String>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
session_id: session.session_id().as_str().to_owned(),
|
||||||
|
username: session.username().to_owned(),
|
||||||
|
user_id: session.user_id().as_str().to_owned(),
|
||||||
|
access_token: tokens.access_token,
|
||||||
|
refresh_token: tokens.refresh_token,
|
||||||
|
scopes: session.scopes().to_vec(),
|
||||||
|
password_mode: match session.password_mode() {
|
||||||
|
PasswordMode::Single => 1,
|
||||||
|
PasswordMode::Dual => 2,
|
||||||
|
},
|
||||||
|
key_passphrases,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unlock(&self) -> HashMap<String, Vec<u8>> {
|
||||||
|
self.key_passphrases
|
||||||
|
.iter()
|
||||||
|
.map(|(id, passphrase)| (id.clone(), passphrase.as_bytes().to_vec()))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resume_parameters(&self) -> ResumeParameters {
|
||||||
|
ResumeParameters {
|
||||||
|
session_id: self.session_id.clone().into(),
|
||||||
|
username: self.username.clone(),
|
||||||
|
user_id: self.user_id.clone().into(),
|
||||||
|
access_token: self.access_token.clone(),
|
||||||
|
refresh_token: self.refresh_token.clone(),
|
||||||
|
scopes: self.scopes.clone(),
|
||||||
|
is_waiting_for_second_factor_code: false,
|
||||||
|
password_mode: match self.password_mode {
|
||||||
|
1 => PasswordMode::Single,
|
||||||
|
_ => PasswordMode::Dual,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A signed-in account, ready for Drive work.
|
||||||
|
///
|
||||||
|
/// Cheap to clone; clones share one session and one set of caches.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct Account {
|
||||||
|
session: ProtonApiSession,
|
||||||
|
drive: ProtonDriveClient,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Account {
|
||||||
|
/// The address the viewer signed in with.
|
||||||
|
pub fn username(&self) -> &str {
|
||||||
|
self.session.username()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Drive client the account's folders and watch history go through.
|
||||||
|
pub fn drive(&self) -> &ProtonDriveClient {
|
||||||
|
&self.drive
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where a library folder can come from: My Files, each device, and each
|
||||||
|
/// folder someone shared with the viewer.
|
||||||
|
///
|
||||||
|
/// The device this app keeps watch history in is left out — it holds
|
||||||
|
/// nothing to watch. A device or share whose name will not decrypt is
|
||||||
|
/// still listed, under a placeholder, rather than hidden: it is the
|
||||||
|
/// viewer's, and they can still open it.
|
||||||
|
pub async fn places(&self) -> Result<Vec<DrivePlace>> {
|
||||||
|
let mut places = Vec::new();
|
||||||
|
let my_files = self.drive.get_my_files_folder().await?;
|
||||||
|
places.push(DrivePlace {
|
||||||
|
kind: PlaceKind::MyFiles,
|
||||||
|
name: "My files".to_owned(),
|
||||||
|
uid: my_files.uid,
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut devices = self.drive.enumerate_devices().await?;
|
||||||
|
devices.sort_by_key(|device| device.creation_time);
|
||||||
|
for device in devices {
|
||||||
|
let name = device.name.unwrap_or_else(|_| "Unnamed device".to_owned());
|
||||||
|
if name == crate::sync::DEVICE_NAME {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
places.push(DrivePlace {
|
||||||
|
kind: PlaceKind::Device,
|
||||||
|
name,
|
||||||
|
uid: device.root_folder_uid,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let shared: Vec<NodeUid> = self
|
||||||
|
.drive
|
||||||
|
.enumerate_shared_with_me()
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(|item| item.uid)
|
||||||
|
.collect();
|
||||||
|
if !shared.is_empty() {
|
||||||
|
let mut nodes = self.drive.enumerate_nodes(&shared).await?;
|
||||||
|
nodes.retain(|node| node.is_folder() && !node.trashed);
|
||||||
|
nodes.sort_by_key(|node| node.name.to_lowercase());
|
||||||
|
places.extend(nodes.into_iter().map(|node| DrivePlace {
|
||||||
|
kind: PlaceKind::SharedWithMe,
|
||||||
|
name: node.name,
|
||||||
|
uid: node.uid,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
Ok(places)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What one folder of the account holds: folders first, then files, each
|
||||||
|
/// by name. Trashed nodes are left out.
|
||||||
|
pub async fn folder_children(&self, folder: &NodeUid) -> Result<Vec<DriveEntry>> {
|
||||||
|
let uids = self
|
||||||
|
.drive
|
||||||
|
.enumerate_folder_children_node_uids(folder)
|
||||||
|
.await?;
|
||||||
|
if uids.is_empty() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
let mut entries: Vec<DriveEntry> = self
|
||||||
|
.drive
|
||||||
|
.enumerate_nodes(&uids)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.filter(|node| !node.trashed)
|
||||||
|
.map(|node| {
|
||||||
|
let (size, media_type) = match &node.kind {
|
||||||
|
NodeKind::File {
|
||||||
|
media_type,
|
||||||
|
claimed_size,
|
||||||
|
..
|
||||||
|
} => (*claimed_size, Some(media_type.clone())),
|
||||||
|
_ => (None, None),
|
||||||
|
};
|
||||||
|
DriveEntry {
|
||||||
|
is_folder: node.is_folder(),
|
||||||
|
uid: node.uid,
|
||||||
|
name: node.name,
|
||||||
|
size,
|
||||||
|
media_type,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
entries.sort_by(|a, b| {
|
||||||
|
b.is_folder
|
||||||
|
.cmp(&a.is_folder)
|
||||||
|
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
|
||||||
|
});
|
||||||
|
Ok(entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// End the session on Proton's side. Best-effort: a session that is
|
||||||
|
/// already dead has nothing to end.
|
||||||
|
async fn end(&self) {
|
||||||
|
if let Err(error) = self.session.end().await {
|
||||||
|
tracing::debug!(%error, "ending the Proton session failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which kind of place a [`DrivePlace`] is.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum PlaceKind {
|
||||||
|
MyFiles,
|
||||||
|
Device,
|
||||||
|
SharedWithMe,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A top-level place in the account's Drive.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct DrivePlace {
|
||||||
|
pub kind: PlaceKind,
|
||||||
|
pub name: String,
|
||||||
|
pub uid: NodeUid,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One node of a folder listing.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct DriveEntry {
|
||||||
|
pub uid: NodeUid,
|
||||||
|
pub name: String,
|
||||||
|
pub is_folder: bool,
|
||||||
|
/// Plaintext size, when the uploader recorded one.
|
||||||
|
pub size: Option<i64>,
|
||||||
|
pub media_type: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where a sign-in stands.
|
||||||
|
pub enum SignIn {
|
||||||
|
/// Signed in; the session is stored.
|
||||||
|
Done(Account),
|
||||||
|
/// The account has two-factor authentication: ask for a code and pass it to
|
||||||
|
/// [`PendingSignIn::second_factor`].
|
||||||
|
SecondFactor(PendingSignIn),
|
||||||
|
/// The account has a separate mailbox password: ask for it and pass it to
|
||||||
|
/// [`PendingSignIn::mailbox_password`].
|
||||||
|
MailboxPassword(PendingSignIn),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A sign-in waiting on the viewer.
|
||||||
|
pub struct PendingSignIn {
|
||||||
|
secrets: Arc<dyn SecretStore>,
|
||||||
|
session: ProtonApiSession,
|
||||||
|
password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PendingSignIn {
|
||||||
|
/// Submit a second-factor code.
|
||||||
|
///
|
||||||
|
/// A wrong code fails here and leaves nothing behind; the front end starts
|
||||||
|
/// the sign-in over, because Proton does not accept a second try on the
|
||||||
|
/// same half-made session.
|
||||||
|
pub async fn second_factor(mut self, code: &str) -> Result<SignIn> {
|
||||||
|
self.session
|
||||||
|
.apply_second_factor_code(code.trim())
|
||||||
|
.await
|
||||||
|
.map_err(|error| match error {
|
||||||
|
ProtonError::Api(api) if (400..500).contains(&api.http_status) => {
|
||||||
|
Error::SignIn("that code was not accepted".to_owned())
|
||||||
|
}
|
||||||
|
other => other.into(),
|
||||||
|
})?;
|
||||||
|
self.after_second_factor().await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Submit the mailbox password, on an account that has one.
|
||||||
|
///
|
||||||
|
/// By reference, unlike the code: a mistyped mailbox password is checked
|
||||||
|
/// on this side, the session survives it, and the viewer can try again
|
||||||
|
/// without starting over.
|
||||||
|
pub async fn mailbox_password(&self, mailbox_password: &str) -> Result<Account> {
|
||||||
|
finish(
|
||||||
|
Arc::clone(&self.secrets),
|
||||||
|
self.session.clone(),
|
||||||
|
mailbox_password,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn after_second_factor(self) -> Result<SignIn> {
|
||||||
|
if self.session.password_mode() == PasswordMode::Dual {
|
||||||
|
return Ok(SignIn::MailboxPassword(self));
|
||||||
|
}
|
||||||
|
let password = self.password.clone();
|
||||||
|
finish(self.secrets, self.session, &password)
|
||||||
|
.await
|
||||||
|
.map(SignIn::Done)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The signed-in account, if any, and signing in and out.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AccountStore {
|
||||||
|
secrets: Arc<dyn SecretStore>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AccountStore {
|
||||||
|
pub fn new(secrets: Arc<dyn SecretStore>) -> Self {
|
||||||
|
Self { secrets }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The address of the stored account, without touching the network.
|
||||||
|
pub fn username(&self) -> Result<Option<String>> {
|
||||||
|
Ok(self.load()?.map(|stored| stored.username))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start signing in.
|
||||||
|
///
|
||||||
|
/// `verification` is the token from a solved CAPTCHA, after a first try
|
||||||
|
/// failed with [`Error::HumanVerification`]; the sign-in starts over with
|
||||||
|
/// it, because the gated attempt's SRP handshake is spent.
|
||||||
|
pub async fn sign_in(
|
||||||
|
&self,
|
||||||
|
username: &str,
|
||||||
|
password: &str,
|
||||||
|
verification: Option<HumanVerificationCredential>,
|
||||||
|
) -> Result<SignIn> {
|
||||||
|
let session = ProtonApiSession::begin_verified(
|
||||||
|
client_configuration(),
|
||||||
|
username.trim(),
|
||||||
|
password.as_bytes(),
|
||||||
|
verification.as_ref(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(classify_sign_in_failure)?;
|
||||||
|
let pending = PendingSignIn {
|
||||||
|
secrets: Arc::clone(&self.secrets),
|
||||||
|
session,
|
||||||
|
password: password.to_owned(),
|
||||||
|
};
|
||||||
|
if pending.session.is_waiting_for_second_factor() {
|
||||||
|
let methods = pending.session.second_factor_methods();
|
||||||
|
if methods.fido2 && !methods.totp {
|
||||||
|
// A security key needs WebAuthn, which this sign-in does not
|
||||||
|
// speak; asking for a code would only fail as a wrong one.
|
||||||
|
pending.session.end().await.ok();
|
||||||
|
return Err(Error::SignIn(SECURITY_KEY_ONLY.to_owned()));
|
||||||
|
}
|
||||||
|
return Ok(SignIn::SecondFactor(pending));
|
||||||
|
}
|
||||||
|
pending.after_second_factor().await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The stored account, resumed, or `None` when nobody is signed in.
|
||||||
|
///
|
||||||
|
/// Makes no request: a session whose refresh token has died is found out
|
||||||
|
/// by the first call that uses it, as [`Error::is_signed_out`].
|
||||||
|
pub fn resume(&self) -> Result<Option<Account>> {
|
||||||
|
let Some(stored) = self.load()? else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let session = ProtonApiSession::resume(client_configuration(), stored.resume_parameters())?;
|
||||||
|
persist_refreshed_tokens(&self.secrets, &session, stored.key_passphrases.clone());
|
||||||
|
let drive = ProtonDriveClient::with_key_passphrases(&session, stored.unlock());
|
||||||
|
Ok(Some(account(session, drive)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forget the account: end its session and delete what was stored.
|
||||||
|
///
|
||||||
|
/// The stored session goes even when ending it fails — signing out on a
|
||||||
|
/// plane must still sign out.
|
||||||
|
pub async fn sign_out(&self, account: Option<&Account>) -> Result<()> {
|
||||||
|
if let Some(account) = account {
|
||||||
|
account.end().await;
|
||||||
|
}
|
||||||
|
self.secrets.delete(SESSION_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The stored session, or `None` for none — or for one this build cannot
|
||||||
|
/// read, such as the shape development builds before 1.2.0 stored, which
|
||||||
|
/// kept the mailbox password. Signing in again is the only fix for either,
|
||||||
|
/// and it replaces what is there.
|
||||||
|
fn load(&self) -> Result<Option<StoredSession>> {
|
||||||
|
let Some(encoded) = self.secrets.get(SESSION_KEY)? else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
Ok(serde_json::from_str(&encoded)
|
||||||
|
.inspect_err(|error| {
|
||||||
|
tracing::warn!(%error, "the stored Proton session is unreadable; sign in again");
|
||||||
|
})
|
||||||
|
.ok())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unlock the key chain with `mailbox_password`, store the session, and hand
|
||||||
|
/// back the account.
|
||||||
|
///
|
||||||
|
/// The key passphrases are derived here, while the token still has the scope
|
||||||
|
/// the key salts need. Loading the addresses is what proves the mailbox
|
||||||
|
/// password: it unlocks the user keys, and a wrong password unlocks none of
|
||||||
|
/// them. Only the passphrases are stored; the password goes no further.
|
||||||
|
async fn finish(
|
||||||
|
secrets: Arc<dyn SecretStore>,
|
||||||
|
session: ProtonApiSession,
|
||||||
|
mailbox_password: &str,
|
||||||
|
) -> Result<Account> {
|
||||||
|
let probe = ProtonDriveClient::new(&session, mailbox_password.as_bytes().to_vec());
|
||||||
|
probe
|
||||||
|
.account()
|
||||||
|
.addresses()
|
||||||
|
.await
|
||||||
|
.map_err(|error| match error {
|
||||||
|
ProtonError::InvalidOperation(_) => {
|
||||||
|
Error::SignIn("that mailbox password did not unlock the account".to_owned())
|
||||||
|
}
|
||||||
|
other => other.into(),
|
||||||
|
})?;
|
||||||
|
let key_passphrases = probe
|
||||||
|
.account()
|
||||||
|
.key_passphrases()
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(|(id, passphrase)| {
|
||||||
|
String::from_utf8(passphrase)
|
||||||
|
.map(|passphrase| (id, passphrase))
|
||||||
|
.map_err(|_| Error::Config("a derived key passphrase is not text".to_owned()))
|
||||||
|
})
|
||||||
|
.collect::<Result<HashMap<_, _>>>()?;
|
||||||
|
|
||||||
|
let stored = StoredSession::capture(
|
||||||
|
&session,
|
||||||
|
session.current_tokens().await,
|
||||||
|
key_passphrases.clone(),
|
||||||
|
);
|
||||||
|
let encoded = serde_json::to_string(&stored)
|
||||||
|
.map_err(|e| Error::Config(format!("serialize the Proton session: {e}")))?;
|
||||||
|
secrets.set(SESSION_KEY, &encoded)?;
|
||||||
|
persist_refreshed_tokens(&secrets, &session, key_passphrases);
|
||||||
|
|
||||||
|
let drive = ProtonDriveClient::with_key_passphrases(&session, stored.unlock());
|
||||||
|
Ok(account(session, drive))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn account(session: ProtonApiSession, drive: ProtonDriveClient) -> Account {
|
||||||
|
let drive = drive
|
||||||
|
.with_max_inflight_blocks(MAX_INFLIGHT_BLOCKS)
|
||||||
|
// A watch-history file is one small block; the single-request upload
|
||||||
|
// is one failure point where the draft dance is three.
|
||||||
|
.with_small_file_upload(true);
|
||||||
|
Account { session, drive }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write every refreshed token pair back to the secret store.
|
||||||
|
///
|
||||||
|
/// Refresh tokens are single-use: once the HTTP client has spent the stored
|
||||||
|
/// one, only the pair it got back can resume the session.
|
||||||
|
fn persist_refreshed_tokens(
|
||||||
|
secrets: &Arc<dyn SecretStore>,
|
||||||
|
session: &ProtonApiSession,
|
||||||
|
key_passphrases: HashMap<String, String>,
|
||||||
|
) {
|
||||||
|
let secrets = Arc::clone(secrets);
|
||||||
|
let template = session.clone();
|
||||||
|
session.http().set_on_tokens_refreshed(move |tokens| {
|
||||||
|
let stored = StoredSession::capture(&template, tokens, key_passphrases.clone());
|
||||||
|
let written = serde_json::to_string(&stored)
|
||||||
|
.map_err(|e| Error::Config(e.to_string()))
|
||||||
|
.and_then(|encoded| secrets.set(SESSION_KEY, &encoded));
|
||||||
|
if let Err(error) = written {
|
||||||
|
tracing::warn!(%error, "the refreshed Proton session could not be stored");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lift a CAPTCHA gate and a wrong password out of the generic API error.
|
||||||
|
///
|
||||||
|
/// Only a gate that can be solved in an app becomes
|
||||||
|
/// [`Error::HumanVerification`]; one that wants an e-mail or SMS code is a plain
|
||||||
|
/// failure, which reads better than an empty verification page.
|
||||||
|
fn classify_sign_in_failure(error: ProtonError) -> Error {
|
||||||
|
if let ProtonError::Api(api) = &error {
|
||||||
|
if let Some(challenge) = api.human_verification()
|
||||||
|
&& challenge.supports_captcha()
|
||||||
|
{
|
||||||
|
return Error::HumanVerification(Box::new(challenge));
|
||||||
|
}
|
||||||
|
if api.http_status == 422 && !api.is_human_verification_required() {
|
||||||
|
return Error::SignIn(if api.message.is_empty() {
|
||||||
|
"Proton did not accept that address and password".to_owned()
|
||||||
|
} else {
|
||||||
|
api.message.clone()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
error.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The token out of a message posted by Proton's verification page, or `None`
|
||||||
|
/// for anything that is not a completion.
|
||||||
|
///
|
||||||
|
/// The page posts plenty besides — resize requests, readiness pings — and
|
||||||
|
/// taking an unrecognised message for success would retry the sign-in with a
|
||||||
|
/// garbage token. Both spellings of the success type have shipped, and some
|
||||||
|
/// hosts hand the message over stringified twice.
|
||||||
|
pub fn verification_token(raw: &str) -> Option<String> {
|
||||||
|
let mut value: serde_json::Value = serde_json::from_str(raw).ok()?;
|
||||||
|
if let Some(inner) = value.as_str()
|
||||||
|
&& let Ok(parsed) = serde_json::from_str(inner)
|
||||||
|
{
|
||||||
|
value = parsed;
|
||||||
|
}
|
||||||
|
let kind = value.get("type")?.as_str()?;
|
||||||
|
if !matches!(
|
||||||
|
kind,
|
||||||
|
"HUMAN_VERIFICATION_SUCCESS" | "human_verification_success"
|
||||||
|
) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let token = value.get("payload")?.get("token")?.as_str()?;
|
||||||
|
(!token.is_empty()).then(|| token.to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use parking_lot::Mutex;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct MemorySecrets(Mutex<HashMap<String, String>>);
|
||||||
|
|
||||||
|
impl SecretStore for MemorySecrets {
|
||||||
|
fn set(&self, key: &str, value: &str) -> Result<()> {
|
||||||
|
self.0.lock().insert(key.to_owned(), value.to_owned());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
fn get(&self, key: &str) -> Result<Option<String>> {
|
||||||
|
Ok(self.0.lock().get(key).cloned())
|
||||||
|
}
|
||||||
|
fn delete(&self, key: &str) -> Result<()> {
|
||||||
|
self.0.lock().remove(key);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stored() -> StoredSession {
|
||||||
|
StoredSession {
|
||||||
|
session_id: "session".to_owned(),
|
||||||
|
username: "viewer@proton.me".to_owned(),
|
||||||
|
user_id: "user".to_owned(),
|
||||||
|
access_token: "access".to_owned(),
|
||||||
|
refresh_token: "refresh".to_owned(),
|
||||||
|
scopes: vec!["drive".to_owned()],
|
||||||
|
password_mode: 2,
|
||||||
|
key_passphrases: HashMap::from([("key-1".to_owned(), "derived".to_owned())]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nobody_is_signed_in_until_a_session_is_stored() {
|
||||||
|
let store = AccountStore::new(Arc::new(MemorySecrets::default()));
|
||||||
|
assert_eq!(store.username().expect("read"), None);
|
||||||
|
assert!(store.resume().expect("resume").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_stored_session_resumes_as_its_account_without_a_request() {
|
||||||
|
let secrets = Arc::new(MemorySecrets::default());
|
||||||
|
secrets
|
||||||
|
.set(SESSION_KEY, &serde_json::to_string(&stored()).unwrap())
|
||||||
|
.unwrap();
|
||||||
|
let store = AccountStore::new(secrets);
|
||||||
|
assert_eq!(
|
||||||
|
store.username().expect("read").as_deref(),
|
||||||
|
Some("viewer@proton.me")
|
||||||
|
);
|
||||||
|
let account = store.resume().expect("resume").expect("signed in");
|
||||||
|
assert_eq!(account.username(), "viewer@proton.me");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn signing_out_forgets_the_stored_session() {
|
||||||
|
let secrets = Arc::new(MemorySecrets::default());
|
||||||
|
secrets
|
||||||
|
.set(SESSION_KEY, &serde_json::to_string(&stored()).unwrap())
|
||||||
|
.unwrap();
|
||||||
|
let store = AccountStore::new(secrets);
|
||||||
|
store.sign_out(None).await.expect("sign out");
|
||||||
|
assert_eq!(store.username().expect("read"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_stored_session_holds_no_password() {
|
||||||
|
let encoded = serde_json::to_string(&stored()).unwrap();
|
||||||
|
assert!(!encoded.contains("password\""), "{encoded}");
|
||||||
|
assert!(!encoded.contains("key_salts"), "{encoded}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_session_stored_by_an_older_build_reads_as_signed_out() {
|
||||||
|
let secrets = Arc::new(MemorySecrets::default());
|
||||||
|
secrets
|
||||||
|
.set(
|
||||||
|
SESSION_KEY,
|
||||||
|
r#"{"session_id":"s","username":"u","user_id":"i","access_token":"a",
|
||||||
|
"refresh_token":"r","scopes":[],"password_mode":1,
|
||||||
|
"mailbox_password":"hunter2","key_salts":[]}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let store = AccountStore::new(secrets);
|
||||||
|
assert_eq!(store.username().expect("read"), None);
|
||||||
|
assert!(store.resume().expect("resume").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_stored_session_keeps_its_password_mode() {
|
||||||
|
let params = stored().resume_parameters();
|
||||||
|
assert_eq!(params.password_mode, PasswordMode::Dual);
|
||||||
|
assert!(!params.is_waiting_for_second_factor_code);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_verification_completion_yields_its_token() {
|
||||||
|
let raw =
|
||||||
|
r#"{"type":"HUMAN_VERIFICATION_SUCCESS","payload":{"token":"tok-1","type":"captcha"}}"#;
|
||||||
|
assert_eq!(verification_token(raw).as_deref(), Some("tok-1"));
|
||||||
|
let twice = r#""{\"type\":\"HUMAN_VERIFICATION_SUCCESS\",\"payload\":{\"token\":\"tok-1\",\"type\":\"captcha\"}}""#;
|
||||||
|
assert_eq!(verification_token(twice).as_deref(), Some("tok-1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn messages_that_are_not_a_verification_completion_are_ignored() {
|
||||||
|
for raw in [
|
||||||
|
r#"{"type":"resize","payload":{"height":400}}"#,
|
||||||
|
r#"{"type":"HUMAN_VERIFICATION_SUCCESS"}"#,
|
||||||
|
r#"{"type":"HUMAN_VERIFICATION_SUCCESS","payload":{"token":""}}"#,
|
||||||
|
"not json",
|
||||||
|
"",
|
||||||
|
] {
|
||||||
|
assert!(verification_token(raw).is_none(), "accepted: {raw}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -254,6 +254,16 @@ fn title_track_prefs_row(
|
|||||||
/// 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;
|
||||||
|
|
||||||
|
/// How long after an episode airs its guide keeps being asked about daily
|
||||||
|
/// while the provider has no name for it.
|
||||||
|
///
|
||||||
|
/// A guide is otherwise trusted as long as a match, a month, and one fetched
|
||||||
|
/// the week before an episode aired lists it unnamed — "Episode 2" with a
|
||||||
|
/// date — until the month is up. A name usually follows the air date by a day
|
||||||
|
/// or so; one that has not come in two weeks is not coming, and asking every
|
||||||
|
/// day for the rest of time would not change that.
|
||||||
|
const GUIDE_WAIT_DAYS: i64 = 14;
|
||||||
|
|
||||||
/// One catalog row: a file or folder inside a share.
|
/// One catalog row: a file or folder inside a share.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct CatalogNode {
|
pub struct CatalogNode {
|
||||||
@@ -513,19 +523,54 @@ impl Catalog {
|
|||||||
Ok(rows.collect::<std::result::Result<_, _>>()?)
|
Ok(rows.collect::<std::result::Result<_, _>>()?)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Drop everything a share contributed, watch positions included.
|
/// Every share the catalog holds files for. One no longer configured was
|
||||||
|
/// forgotten without [`Catalog::remove_share`] — by an older `pstr remove`
|
||||||
|
/// — and its rows still show up in the library beside whatever replaced it.
|
||||||
|
pub fn share_ids(&self) -> Result<Vec<String>> {
|
||||||
|
let mut statement = self
|
||||||
|
.conn
|
||||||
|
.prepare("SELECT DISTINCT share_id FROM nodes ORDER BY share_id")?;
|
||||||
|
let rows = statement.query_map([], |row| row.get(0))?;
|
||||||
|
Ok(rows.collect::<std::result::Result<_, _>>()?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop everything a share contributed except where it was watched.
|
||||||
///
|
///
|
||||||
/// Unlike a recrawl — which replaces rows and deliberately leaves watch
|
/// Watch state stays, as through a recrawl. It is history, and not only
|
||||||
/// state alone — this is the viewer saying they are done with the share.
|
/// this device's: with an account signed in, the next sync would bring it
|
||||||
/// Keeping positions for files they can no longer reach would only leave
|
/// straight back from the other devices' files, so deleting it here would
|
||||||
/// the "continue watching" row pointing at nothing.
|
/// only make it flicker. Nothing shows it — the library pairs positions
|
||||||
|
/// with files, and the files are gone — until the share is added again,
|
||||||
|
/// when it picks up where it left off.
|
||||||
|
///
|
||||||
|
/// It is also copied, newer wins as in a merge, onto every other share that
|
||||||
|
/// holds the same file. A public link swapped for the account's own folder
|
||||||
|
/// reaches the very same links under a new share id, and without the copy
|
||||||
|
/// the swap reads as a library nobody has watched.
|
||||||
pub fn remove_share(&mut self, share_id: &str) -> Result<()> {
|
pub fn remove_share(&mut self, share_id: &str) -> Result<()> {
|
||||||
let tx = self.conn.transaction()?;
|
let tx = self.conn.transaction()?;
|
||||||
tx.execute("DELETE FROM nodes WHERE share_id = ?1", params![share_id])?;
|
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"DELETE FROM watch_state WHERE share_id = ?1",
|
"INSERT INTO watch_state (share_id, link_id, position_secs, duration_secs, watched, updated_at)
|
||||||
|
SELECT other.share_id, w.link_id, w.position_secs, w.duration_secs, w.watched, w.updated_at
|
||||||
|
FROM watch_state w
|
||||||
|
JOIN nodes own ON own.share_id = w.share_id AND own.link_id = w.link_id
|
||||||
|
JOIN nodes other ON other.volume_id = own.volume_id
|
||||||
|
AND other.link_id = own.link_id
|
||||||
|
AND other.share_id <> own.share_id
|
||||||
|
WHERE w.share_id = ?1
|
||||||
|
ON CONFLICT (share_id, link_id) DO UPDATE SET
|
||||||
|
position_secs = excluded.position_secs,
|
||||||
|
duration_secs = excluded.duration_secs,
|
||||||
|
watched = excluded.watched,
|
||||||
|
updated_at = excluded.updated_at
|
||||||
|
WHERE excluded.updated_at > watch_state.updated_at
|
||||||
|
OR (excluded.updated_at = watch_state.updated_at
|
||||||
|
AND (excluded.watched > watch_state.watched
|
||||||
|
OR (excluded.watched = watch_state.watched
|
||||||
|
AND excluded.position_secs > watch_state.position_secs)))",
|
||||||
params![share_id],
|
params![share_id],
|
||||||
)?;
|
)?;
|
||||||
|
tx.execute("DELETE FROM nodes WHERE share_id = ?1", params![share_id])?;
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"DELETE FROM offline_files WHERE share_id = ?1",
|
"DELETE FROM offline_files WHERE share_id = ?1",
|
||||||
params![share_id],
|
params![share_id],
|
||||||
@@ -721,6 +766,46 @@ impl Catalog {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Take each of `states` that is newer than what is recorded, in one
|
||||||
|
/// transaction, returning how many were taken.
|
||||||
|
///
|
||||||
|
/// Newer is a later `updated_at`. A tie is broken by the state itself —
|
||||||
|
/// watched over not, then the further position — rather than by who is
|
||||||
|
/// asking, so two devices holding the same tie settle on the same answer
|
||||||
|
/// instead of each keeping its own forever. See [`crate::sync`].
|
||||||
|
pub fn merge_watch_states(&mut self, states: &[(String, String, WatchState)]) -> Result<usize> {
|
||||||
|
let tx = self.conn.transaction()?;
|
||||||
|
let mut taken = 0;
|
||||||
|
{
|
||||||
|
let mut upsert = tx.prepare_cached(
|
||||||
|
"INSERT INTO watch_state (share_id, link_id, position_secs, duration_secs, watched, updated_at)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
|
||||||
|
ON CONFLICT (share_id, link_id) DO UPDATE SET
|
||||||
|
position_secs = excluded.position_secs,
|
||||||
|
duration_secs = excluded.duration_secs,
|
||||||
|
watched = excluded.watched,
|
||||||
|
updated_at = excluded.updated_at
|
||||||
|
WHERE excluded.updated_at > watch_state.updated_at
|
||||||
|
OR (excluded.updated_at = watch_state.updated_at
|
||||||
|
AND (excluded.watched > watch_state.watched
|
||||||
|
OR (excluded.watched = watch_state.watched
|
||||||
|
AND excluded.position_secs > watch_state.position_secs)))",
|
||||||
|
)?;
|
||||||
|
for (share_id, link_id, state) in states {
|
||||||
|
taken += upsert.execute(params![
|
||||||
|
share_id,
|
||||||
|
link_id,
|
||||||
|
state.position_secs,
|
||||||
|
state.duration_secs,
|
||||||
|
state.watched as i64,
|
||||||
|
state.updated_at,
|
||||||
|
])?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tx.commit()?;
|
||||||
|
Ok(taken)
|
||||||
|
}
|
||||||
|
|
||||||
/// Where playback left off, if it ever started.
|
/// Where playback left off, if it ever started.
|
||||||
pub fn watch_state(&self, share_id: &str, link_id: &str) -> Result<Option<WatchState>> {
|
pub fn watch_state(&self, share_id: &str, link_id: &str) -> Result<Option<WatchState>> {
|
||||||
let state = self
|
let state = self
|
||||||
@@ -992,14 +1077,27 @@ impl Catalog {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// When each matched title was last enriched, for the provider it is
|
/// When each matched title was last enriched, for the provider it is
|
||||||
/// matched against. A title missing here has never been, or its match has
|
/// matched against. A title missing here has never been, its match has
|
||||||
/// changed since.
|
/// changed since, or its guide is waiting on the name of an episode that
|
||||||
|
/// has aired since — see `GUIDE_WAIT_DAYS`.
|
||||||
pub fn enrichment_ages(&self) -> Result<HashMap<String, (ProviderId, i64)>> {
|
pub fn enrichment_ages(&self) -> Result<HashMap<String, (ProviderId, i64)>> {
|
||||||
|
self.enrichment_ages_at(unix_now())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn enrichment_ages_at(&self, now: i64) -> Result<HashMap<String, (ProviderId, i64)>> {
|
||||||
let mut statement = self.conn.prepare(
|
let mut statement = self.conn.prepare(
|
||||||
"SELECT title_key, provider, enriched_at FROM title_metadata
|
"SELECT title_key, provider, enriched_at FROM title_metadata t
|
||||||
WHERE enriched_at IS NOT NULL",
|
WHERE enriched_at IS NOT NULL
|
||||||
|
AND NOT (enriched_at < ?1 - 86400
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM episode_metadata e
|
||||||
|
WHERE e.title_key = t.title_key
|
||||||
|
AND e.name IS NULL
|
||||||
|
AND e.air_date <= date(?1, 'unixepoch')
|
||||||
|
AND e.air_date >= date(t.enriched_at, 'unixepoch',
|
||||||
|
'-' || ?2 || ' days')))",
|
||||||
)?;
|
)?;
|
||||||
let rows = statement.query_map([], |row| {
|
let rows = statement.query_map(params![now, GUIDE_WAIT_DAYS], |row| {
|
||||||
Ok((
|
Ok((
|
||||||
row.get::<_, String>(0)?,
|
row.get::<_, String>(0)?,
|
||||||
row.get::<_, String>(1)?,
|
row.get::<_, String>(1)?,
|
||||||
@@ -1014,6 +1112,36 @@ impl Catalog {
|
|||||||
ages.insert(title_key, (provider, enriched_at));
|
ages.insert(title_key, (provider, enriched_at));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
drop(statement);
|
||||||
|
|
||||||
|
// A file that arrived after the guide was fetched, and that the guide
|
||||||
|
// has no name for, is an episode the guide predates: the next one of a
|
||||||
|
// running show. Asked again once — the refetch is newer than the file,
|
||||||
|
// so a name the provider never gives does not make it ask forever.
|
||||||
|
if !ages.is_empty() {
|
||||||
|
let first_seen = self.first_seen()?;
|
||||||
|
let guides = self.all_episode_metadata()?;
|
||||||
|
for node in self.all_files()? {
|
||||||
|
let Some(number) = node.parsed.episode else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let key = crate::library::title_key(&node.parsed.title);
|
||||||
|
let Some((_, enriched_at)) = ages.get(&key) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let seen = first_seen
|
||||||
|
.get(&(node.share_id, node.link_id))
|
||||||
|
.copied()
|
||||||
|
.unwrap_or(0);
|
||||||
|
let named = guides
|
||||||
|
.get(&key)
|
||||||
|
.and_then(|guide| guide.get(node.parsed.season, number))
|
||||||
|
.is_some_and(|episode| episode.name.is_some());
|
||||||
|
if seen > *enriched_at && !named {
|
||||||
|
ages.remove(&key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(ages)
|
Ok(ages)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1806,6 +1934,129 @@ mod tests {
|
|||||||
assert!(catalog.enrichment_ages().expect("ages").is_empty());
|
assert!(catalog.enrichment_ages().expect("ages").is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_file_newer_than_its_guide_that_the_guide_cannot_name_asks_for_the_guide_again() {
|
||||||
|
let mut catalog = Catalog::in_memory().expect("open");
|
||||||
|
catalog
|
||||||
|
.set_metadata(&MetadataRecord {
|
||||||
|
title_key: "kusuriya".into(),
|
||||||
|
provider: ProviderId::AniList,
|
||||||
|
metadata: Some(found()),
|
||||||
|
fetched_at: 1_000,
|
||||||
|
manual: false,
|
||||||
|
})
|
||||||
|
.expect("store");
|
||||||
|
let enrichment = Enrichment {
|
||||||
|
episodes: vec![EpisodeMetadata {
|
||||||
|
season: Some(3),
|
||||||
|
number: 1,
|
||||||
|
absolute: None,
|
||||||
|
entry_season: None,
|
||||||
|
entry_number: None,
|
||||||
|
name: Some("Locusts".into()),
|
||||||
|
overview: None,
|
||||||
|
still_url: None,
|
||||||
|
air_date: None,
|
||||||
|
}],
|
||||||
|
backdrop_url: None,
|
||||||
|
chain: Vec::new(),
|
||||||
|
};
|
||||||
|
catalog
|
||||||
|
.set_enrichment("kusuriya", ProviderId::AniList, 1_000, &enrichment)
|
||||||
|
.expect("enrich");
|
||||||
|
let trusted = |catalog: &Catalog| {
|
||||||
|
catalog
|
||||||
|
.enrichment_ages()
|
||||||
|
.expect("ages")
|
||||||
|
.contains_key("kusuriya")
|
||||||
|
};
|
||||||
|
|
||||||
|
// Newer than the guide, but the guide names it.
|
||||||
|
let first = file("s", "e1", "Kusuriya S03E01.mkv");
|
||||||
|
catalog
|
||||||
|
.replace_share("s", std::slice::from_ref(&first))
|
||||||
|
.expect("crawl");
|
||||||
|
assert!(trusted(&catalog));
|
||||||
|
|
||||||
|
// The next episode, which the guide does not know yet.
|
||||||
|
catalog
|
||||||
|
.replace_share("s", &[first, file("s", "e2", "Kusuriya S03E02.mkv")])
|
||||||
|
.expect("crawl again");
|
||||||
|
assert!(!trusted(&catalog));
|
||||||
|
|
||||||
|
// Fetched again since, still without it: not asked about again.
|
||||||
|
catalog
|
||||||
|
.set_enrichment("kusuriya", ProviderId::AniList, unix_now() + 1, &enrichment)
|
||||||
|
.expect("enrich again");
|
||||||
|
assert!(trusted(&catalog));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_guide_fetched_before_an_episode_had_a_name_is_asked_again_once_it_airs() {
|
||||||
|
const DAY: i64 = 86_400;
|
||||||
|
// 2026-10-04, when the guide was fetched; episode 2 airs on the 9th.
|
||||||
|
let fetched = 1_791_072_000;
|
||||||
|
let mut catalog = Catalog::in_memory().expect("open");
|
||||||
|
catalog
|
||||||
|
.set_metadata(&MetadataRecord {
|
||||||
|
title_key: "kusuriya".into(),
|
||||||
|
provider: ProviderId::AniList,
|
||||||
|
metadata: Some(found()),
|
||||||
|
fetched_at: fetched,
|
||||||
|
manual: false,
|
||||||
|
})
|
||||||
|
.expect("store");
|
||||||
|
let episode = |number: u32, name: Option<&str>, air_date: &str| EpisodeMetadata {
|
||||||
|
season: Some(3),
|
||||||
|
number,
|
||||||
|
absolute: None,
|
||||||
|
entry_season: Some(1),
|
||||||
|
entry_number: Some(number),
|
||||||
|
name: name.map(str::to_owned),
|
||||||
|
overview: None,
|
||||||
|
still_url: None,
|
||||||
|
air_date: Some(air_date.into()),
|
||||||
|
};
|
||||||
|
let enrichment = Enrichment {
|
||||||
|
episodes: vec![
|
||||||
|
episode(1, Some("Locusts"), "2026-10-02"),
|
||||||
|
episode(2, None, "2026-10-09"),
|
||||||
|
],
|
||||||
|
backdrop_url: None,
|
||||||
|
chain: Vec::new(),
|
||||||
|
};
|
||||||
|
catalog
|
||||||
|
.set_enrichment("kusuriya", ProviderId::AniList, fetched, &enrichment)
|
||||||
|
.expect("enrich");
|
||||||
|
let trusted = |catalog: &Catalog, now: i64| {
|
||||||
|
catalog
|
||||||
|
.enrichment_ages_at(now)
|
||||||
|
.expect("ages")
|
||||||
|
.contains_key("kusuriya")
|
||||||
|
};
|
||||||
|
|
||||||
|
// Before episode 2 airs, a nameless future episode is no reason.
|
||||||
|
assert!(trusted(&catalog, fetched + 4 * DAY));
|
||||||
|
// Once it has aired, the guide is asked again.
|
||||||
|
assert!(!trusted(&catalog, fetched + 8 * DAY));
|
||||||
|
|
||||||
|
// Asked on the 12th and still nameless: not again the same day…
|
||||||
|
let refetched = fetched + 8 * DAY;
|
||||||
|
catalog
|
||||||
|
.set_enrichment("kusuriya", ProviderId::AniList, refetched, &enrichment)
|
||||||
|
.expect("enrich again");
|
||||||
|
assert!(trusted(&catalog, refetched + DAY / 2));
|
||||||
|
// …but the next.
|
||||||
|
assert!(!trusted(&catalog, refetched + DAY + 1));
|
||||||
|
|
||||||
|
// Two weeks past the air date it is given up on.
|
||||||
|
let given_up = fetched + 21 * DAY;
|
||||||
|
catalog
|
||||||
|
.set_enrichment("kusuriya", ProviderId::AniList, given_up, &enrichment)
|
||||||
|
.expect("enrich late");
|
||||||
|
assert!(trusted(&catalog, given_up + 2 * DAY));
|
||||||
|
}
|
||||||
|
|
||||||
/// Details round-trip, and refreshing a match's details — the same entry
|
/// Details round-trip, and refreshing a match's details — the same entry
|
||||||
/// stored again — keeps what enrichment found for it.
|
/// stored again — keeps what enrichment found for it.
|
||||||
#[test]
|
#[test]
|
||||||
@@ -2390,6 +2641,161 @@ mod tests {
|
|||||||
assert_eq!(rows[0].link_id, "film");
|
assert_eq!(rows[0].link_id, "film");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn removing_a_share_keeps_its_watch_state_out_of_sight() {
|
||||||
|
let mut catalog = Catalog::in_memory().expect("open");
|
||||||
|
catalog
|
||||||
|
.replace_share("s1", &[file("s1", "a", "Show S01E01.mkv")])
|
||||||
|
.expect("crawl");
|
||||||
|
let state = WatchState {
|
||||||
|
position_secs: 120.0,
|
||||||
|
duration_secs: Some(1440.0),
|
||||||
|
watched: false,
|
||||||
|
updated_at: 10,
|
||||||
|
};
|
||||||
|
catalog.set_watch_state("s1", "a", &state).expect("set");
|
||||||
|
|
||||||
|
catalog.remove_share("s1").expect("remove");
|
||||||
|
let library = crate::library::Library::build(
|
||||||
|
catalog.all_files().expect("files"),
|
||||||
|
&catalog.all_watch_states().expect("watch"),
|
||||||
|
);
|
||||||
|
assert!(library.titles.is_empty());
|
||||||
|
assert_eq!(catalog.watch_state("s1", "a").expect("read"), Some(state));
|
||||||
|
|
||||||
|
catalog
|
||||||
|
.replace_share("s1", &[file("s1", "a", "Show S01E01.mkv")])
|
||||||
|
.expect("re-add");
|
||||||
|
let library = crate::library::Library::build(
|
||||||
|
catalog.all_files().expect("files"),
|
||||||
|
&catalog.all_watch_states().expect("watch"),
|
||||||
|
);
|
||||||
|
assert_eq!(library.titles[0].seasons[0].episodes[0].watch, Some(state));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn removing_a_share_hands_its_watch_state_to_another_share_holding_the_same_file() {
|
||||||
|
let mut catalog = Catalog::in_memory().expect("open");
|
||||||
|
let at = |position_secs: f64, updated_at: i64| WatchState {
|
||||||
|
position_secs,
|
||||||
|
duration_secs: Some(1440.0),
|
||||||
|
watched: false,
|
||||||
|
updated_at,
|
||||||
|
};
|
||||||
|
catalog
|
||||||
|
.replace_share(
|
||||||
|
"link",
|
||||||
|
&[
|
||||||
|
file("link", "a", "Show S01E01.mkv"),
|
||||||
|
file("link", "b", "Show S01E02.mkv"),
|
||||||
|
file("link", "gone", "Show S01E03.mkv"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.expect("crawl the link");
|
||||||
|
catalog
|
||||||
|
.replace_share(
|
||||||
|
"folder",
|
||||||
|
&[
|
||||||
|
file("folder", "a", "Show S01E01.mkv"),
|
||||||
|
file("folder", "b", "Show S01E02.mkv"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.expect("crawl the folder");
|
||||||
|
catalog
|
||||||
|
.set_watch_state("link", "a", &at(100.0, 10))
|
||||||
|
.unwrap();
|
||||||
|
catalog
|
||||||
|
.set_watch_state("link", "b", &at(100.0, 10))
|
||||||
|
.unwrap();
|
||||||
|
catalog
|
||||||
|
.set_watch_state("link", "gone", &at(100.0, 10))
|
||||||
|
.unwrap();
|
||||||
|
// Watched through the folder since: the link's older position must
|
||||||
|
// not overwrite it.
|
||||||
|
catalog
|
||||||
|
.set_watch_state("folder", "b", &at(900.0, 20))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
catalog.remove_share("link").expect("remove");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
catalog.watch_state("folder", "a").unwrap(),
|
||||||
|
Some(at(100.0, 10))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
catalog.watch_state("folder", "b").unwrap(),
|
||||||
|
Some(at(900.0, 20))
|
||||||
|
);
|
||||||
|
assert_eq!(catalog.watch_state("folder", "gone").unwrap(), None);
|
||||||
|
assert_eq!(
|
||||||
|
catalog.watch_state("link", "a").unwrap(),
|
||||||
|
Some(at(100.0, 10))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merging_takes_only_states_newer_than_the_recorded_ones() {
|
||||||
|
let mut catalog = Catalog::in_memory().expect("open");
|
||||||
|
let at = |position_secs: f64, updated_at: i64| WatchState {
|
||||||
|
position_secs,
|
||||||
|
duration_secs: Some(1440.0),
|
||||||
|
watched: false,
|
||||||
|
updated_at,
|
||||||
|
};
|
||||||
|
catalog
|
||||||
|
.set_watch_state("s1", "kept", &at(100.0, 50))
|
||||||
|
.unwrap();
|
||||||
|
catalog
|
||||||
|
.set_watch_state("s1", "taken", &at(100.0, 50))
|
||||||
|
.unwrap();
|
||||||
|
let taken = catalog
|
||||||
|
.merge_watch_states(&[
|
||||||
|
("s1".to_owned(), "kept".to_owned(), at(900.0, 40)),
|
||||||
|
("s1".to_owned(), "taken".to_owned(), at(900.0, 60)),
|
||||||
|
("s1".to_owned(), "new".to_owned(), at(5.0, 10)),
|
||||||
|
])
|
||||||
|
.expect("merge");
|
||||||
|
assert_eq!(taken, 2);
|
||||||
|
assert_eq!(
|
||||||
|
catalog.watch_state("s1", "kept").unwrap(),
|
||||||
|
Some(at(100.0, 50))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
catalog.watch_state("s1", "taken").unwrap(),
|
||||||
|
Some(at(900.0, 60))
|
||||||
|
);
|
||||||
|
assert_eq!(catalog.watch_state("s1", "new").unwrap(), Some(at(5.0, 10)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_merge_tie_settles_the_same_way_whichever_side_holds_it() {
|
||||||
|
let state = |position_secs: f64, watched: bool| WatchState {
|
||||||
|
position_secs,
|
||||||
|
duration_secs: Some(1440.0),
|
||||||
|
watched,
|
||||||
|
updated_at: 70,
|
||||||
|
};
|
||||||
|
for (local, remote) in [
|
||||||
|
(state(300.0, false), state(1440.0, true)),
|
||||||
|
(state(1440.0, true), state(300.0, false)),
|
||||||
|
(state(300.0, false), state(600.0, false)),
|
||||||
|
(state(600.0, false), state(300.0, false)),
|
||||||
|
] {
|
||||||
|
let mut catalog = Catalog::in_memory().expect("open");
|
||||||
|
catalog.set_watch_state("s1", "a", &local).unwrap();
|
||||||
|
catalog
|
||||||
|
.merge_watch_states(&[("s1".to_owned(), "a".to_owned(), remote)])
|
||||||
|
.unwrap();
|
||||||
|
let winner =
|
||||||
|
if (local.watched, local.position_secs) > (remote.watched, remote.position_secs) {
|
||||||
|
local
|
||||||
|
} else {
|
||||||
|
remote
|
||||||
|
};
|
||||||
|
assert_eq!(catalog.watch_state("s1", "a").unwrap(), Some(winner));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn an_unwatched_file_has_no_watch_state() {
|
fn an_unwatched_file_has_no_watch_state() {
|
||||||
let catalog = Catalog::in_memory().expect("open");
|
let catalog = Catalog::in_memory().expect("open");
|
||||||
|
|||||||
@@ -88,6 +88,12 @@ impl AppDirs {
|
|||||||
self.config.join("shares.json")
|
self.config.join("shares.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// This installation's identity for watch-history sync. See
|
||||||
|
/// [`crate::sync`].
|
||||||
|
pub fn sync_file(&self) -> PathBuf {
|
||||||
|
self.config.join("sync.json")
|
||||||
|
}
|
||||||
|
|
||||||
/// The catalog database.
|
/// The catalog database.
|
||||||
pub fn catalog_db(&self) -> PathBuf {
|
pub fn catalog_db(&self) -> PathBuf {
|
||||||
self.data.join("catalog.db")
|
self.data.join("catalog.db")
|
||||||
|
|||||||
@@ -30,6 +30,37 @@ pub enum Error {
|
|||||||
/// The caller asked for something that does not exist or does not apply.
|
/// The caller asked for something that does not exist or does not apply.
|
||||||
#[error("{0}")]
|
#[error("{0}")]
|
||||||
NotFound(String),
|
NotFound(String),
|
||||||
|
|
||||||
|
/// Proton gated a sign-in behind a CAPTCHA. The front end presents
|
||||||
|
/// [`HumanVerification::verification_url`](proton_sdk::api::HumanVerification::verification_url)
|
||||||
|
/// and signs in again with the token it yields.
|
||||||
|
#[error("Proton wants to confirm you are human before signing in")]
|
||||||
|
HumanVerification(Box<proton_sdk::api::HumanVerification>),
|
||||||
|
|
||||||
|
/// No account is signed in, or its session can no longer be refreshed and
|
||||||
|
/// the viewer has to sign in again.
|
||||||
|
#[error("{0}")]
|
||||||
|
SignedOut(String),
|
||||||
|
|
||||||
|
/// Signing in failed for a reason the viewer can act on, such as a wrong
|
||||||
|
/// mailbox password.
|
||||||
|
#[error("{0}")]
|
||||||
|
SignIn(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Error {
|
||||||
|
/// Whether this is the account's session having ended: the refresh token
|
||||||
|
/// was revoked or expired, or the token lacks a scope only a password
|
||||||
|
/// sign-in grants. Nothing but signing in again fixes it.
|
||||||
|
pub fn is_signed_out(&self) -> bool {
|
||||||
|
match self {
|
||||||
|
Self::SignedOut(_) => true,
|
||||||
|
Self::Drive(proton_sdk::error::ProtonError::Api(api)) => {
|
||||||
|
api.is_invalid_refresh_token() || api.is_insufficient_scope()
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(target_os = "android"))]
|
#[cfg(not(target_os = "android"))]
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
//! how a share is opened and how its contents are modelled.
|
//! how a share is opened and how its contents are modelled.
|
||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
|
pub mod account;
|
||||||
pub mod appearance;
|
pub mod appearance;
|
||||||
pub mod browse;
|
pub mod browse;
|
||||||
pub mod catalog;
|
pub mod catalog;
|
||||||
@@ -16,13 +17,16 @@ pub mod library;
|
|||||||
pub mod metadata;
|
pub mod metadata;
|
||||||
pub mod naming;
|
pub mod naming;
|
||||||
pub mod prefs;
|
pub mod prefs;
|
||||||
|
pub mod profile;
|
||||||
pub mod shares;
|
pub mod shares;
|
||||||
pub mod shelves;
|
pub mod shelves;
|
||||||
|
pub mod sync;
|
||||||
|
|
||||||
|
pub use account::{Account, AccountStore};
|
||||||
pub use error::{Error, Result};
|
pub use error::{Error, Result};
|
||||||
#[cfg(not(target_os = "android"))]
|
#[cfg(not(target_os = "android"))]
|
||||||
pub use shares::KeyringSecretStore;
|
pub use shares::KeyringSecretStore;
|
||||||
pub use shares::{SecretStore, Share, ShareStore, SharedLibrary};
|
pub use shares::{SecretStore, Share, ShareClient, ShareStore, SharedLibrary};
|
||||||
|
|
||||||
pub use proton_drive_rs;
|
pub use proton_drive_rs;
|
||||||
pub use proton_sdk;
|
pub use proton_sdk;
|
||||||
@@ -0,0 +1,808 @@
|
|||||||
|
//! The rest of what makes the app look the same on every device: the shares,
|
||||||
|
//! the settings, the hand-picked metadata matches and the per-show track
|
||||||
|
//! choices. Synced next to watch history by [`crate::sync`], in the same file.
|
||||||
|
//!
|
||||||
|
//! ## Registers
|
||||||
|
//!
|
||||||
|
//! Each synced thing is one *register*: a key (`share/<id>`,
|
||||||
|
//! `settings/playback`, `tracks/<title>`, …), its latest value, and when it
|
||||||
|
//! last changed. Merging is the same rule as watch history — the newer
|
||||||
|
//! `updated_at` wins, per key — so it is idempotent and order-free across any
|
||||||
|
//! number of devices. A removed share is a register with no value, so a
|
||||||
|
//! removal travels like any other change.
|
||||||
|
//!
|
||||||
|
//! ## Noticing changes
|
||||||
|
//!
|
||||||
|
//! Nothing that writes a setting or a share knows about sync. Instead
|
||||||
|
//! `sync-state.json` remembers every register as last synced, and each sync
|
||||||
|
//! compares it with what is on this device: whatever differs changed since,
|
||||||
|
//! and is stamped with the time of the sync. That costs a change made between
|
||||||
|
//! syncs its exact time, which matters only when two devices change the same
|
||||||
|
//! setting within one sync interval of each other.
|
||||||
|
//!
|
||||||
|
//! The first sync of an installation stamps what it finds at zero rather than
|
||||||
|
//! now. A fresh install holds only defaults, and those must not outrank the
|
||||||
|
//! library every other device already agreed on; what it has that nobody else
|
||||||
|
//! does is still added, since nothing older competes with it.
|
||||||
|
//!
|
||||||
|
//! ## What does not travel
|
||||||
|
//!
|
||||||
|
//! The volume and mute, which belong to the speakers in front of the viewer,
|
||||||
|
//! and every setting the front ends keep themselves: the cache budget, Wi-Fi
|
||||||
|
//! only, background audio, hardware decoding, window geometry.
|
||||||
|
//!
|
||||||
|
//! ## Secrets
|
||||||
|
//!
|
||||||
|
//! A share's register carries its link, fragment included, and its custom
|
||||||
|
//! password, and the TMDB API key travels too: without them another device
|
||||||
|
//! could list the share but never open it. The file holding them is in the
|
||||||
|
//! viewer's own Drive, end-to-end encrypted with their account's keys, which is
|
||||||
|
//! the same trust the credential store gives them locally; they are never
|
||||||
|
//! written anywhere else in plain text.
|
||||||
|
|
||||||
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use parking_lot::Mutex;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use crate::catalog::{Catalog, TitleTrackPrefs};
|
||||||
|
use crate::config::{AppDirs, read_json, write_json};
|
||||||
|
use crate::error::Result;
|
||||||
|
use crate::metadata::{MetadataConfig, MetadataRecord, ProviderId, TitleMetadata};
|
||||||
|
use crate::shares::{AccountFolder, Share, ShareSecrets, ShareStore};
|
||||||
|
|
||||||
|
const SHARE: &str = "share/";
|
||||||
|
const TRACKS: &str = "tracks/";
|
||||||
|
const MATCH: &str = "match/";
|
||||||
|
|
||||||
|
/// The settings files that travel, by register, and the fields in each that
|
||||||
|
/// stay on the device that wrote them.
|
||||||
|
const SETTINGS: [(&str, &str, &[&str]); 3] = [
|
||||||
|
("settings/playback", "playback.json", &["volume", "muted"]),
|
||||||
|
("settings/appearance", "appearance.json", &[]),
|
||||||
|
("settings/metadata", "metadata.json", &[]),
|
||||||
|
];
|
||||||
|
|
||||||
|
const METADATA_SETTINGS: &str = "settings/metadata";
|
||||||
|
|
||||||
|
/// One synced value and when it last changed.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub(crate) struct Register {
|
||||||
|
/// `None` is a removal.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub value: Option<Value>,
|
||||||
|
pub updated_at: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) type Registers = BTreeMap<String, Register>;
|
||||||
|
|
||||||
|
/// What this installation last synced, kept beside its sync identity.
|
||||||
|
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||||
|
struct State {
|
||||||
|
registers: Registers,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A share as it travels: everything another device needs to open it.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
struct SyncedShare {
|
||||||
|
name: String,
|
||||||
|
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||||
|
token: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
url: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
custom_password: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
folder: Option<AccountFolder>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A match the viewer picked by hand. Automatic ones are not synced: every
|
||||||
|
/// device with enrichment on finds them again, and they expire anyway.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
struct SyncedMatch {
|
||||||
|
provider: ProviderId,
|
||||||
|
metadata: Option<TitleMetadata>,
|
||||||
|
fetched_at: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What applying other devices' registers changed here, for the front end to
|
||||||
|
/// act on: reopen and crawl shares, repaint, reload the library.
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||||
|
pub struct ProfileChanges {
|
||||||
|
/// Shares this device did not have, now configured. Not yet crawled.
|
||||||
|
pub shares_added: Vec<String>,
|
||||||
|
/// Shares whose name or secrets changed.
|
||||||
|
pub shares_changed: Vec<String>,
|
||||||
|
/// Shares removed elsewhere, now gone from the configuration here. Their
|
||||||
|
/// catalog rows and offline files are the front end's to drop, the same
|
||||||
|
/// way it does for a share removed on this device.
|
||||||
|
pub shares_removed: Vec<String>,
|
||||||
|
/// Whether any settings file or the TMDB key changed.
|
||||||
|
pub settings: bool,
|
||||||
|
/// Hand-picked matches and per-show track choices taken.
|
||||||
|
pub titles: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProfileChanges {
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
*self == Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn shares(&self) -> bool {
|
||||||
|
!(self.shares_added.is_empty()
|
||||||
|
&& self.shares_changed.is_empty()
|
||||||
|
&& self.shares_removed.is_empty())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The local side of profile sync: where the state file lives, and the stores
|
||||||
|
/// registers are read from and applied to.
|
||||||
|
pub(crate) struct Profile<'a> {
|
||||||
|
pub store: &'a ShareStore,
|
||||||
|
pub catalog: &'a Mutex<Catalog>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What is on this device, register by register.
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
struct Observed {
|
||||||
|
values: BTreeMap<String, Value>,
|
||||||
|
/// Keys that exist but could not be read — a share whose secret will not
|
||||||
|
/// decrypt. Neither a change nor a removal.
|
||||||
|
unknown: BTreeSet<String>,
|
||||||
|
/// Whether the share list itself could be read. Without it, a share
|
||||||
|
/// missing from `values` says nothing about whether it was removed.
|
||||||
|
shares_listed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn state_file(dirs: &AppDirs) -> PathBuf {
|
||||||
|
dirs.config.join("sync-state.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tmdb_secret() -> String {
|
||||||
|
format!("metadata-{}", ProviderId::Tmdb.as_str())
|
||||||
|
}
|
||||||
|
|
||||||
|
const TMDB_KEY: &str = "secret/metadata-tmdb";
|
||||||
|
|
||||||
|
impl Profile<'_> {
|
||||||
|
fn dirs(&self) -> &AppDirs {
|
||||||
|
&self.store.dirs
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the state, notice what changed here since, take what other
|
||||||
|
/// devices have newer, and write the state back. Returns the registers to
|
||||||
|
/// upload and what changed on this device.
|
||||||
|
pub fn sync<'r>(
|
||||||
|
&self,
|
||||||
|
remote: impl IntoIterator<Item = &'r Registers>,
|
||||||
|
now: i64,
|
||||||
|
) -> Result<(Registers, ProfileChanges)> {
|
||||||
|
let path = state_file(self.dirs());
|
||||||
|
let stored = read_json::<State>(&path)?;
|
||||||
|
let first = stored.is_none();
|
||||||
|
let mut state = stored.unwrap_or_default();
|
||||||
|
|
||||||
|
let observed = self.observe();
|
||||||
|
stamp(&mut state.registers, observed, if first { 0 } else { now });
|
||||||
|
let winners = merge(&state.registers, remote);
|
||||||
|
let changes = self.apply(&mut state.registers, winners);
|
||||||
|
write_json(&path, &state)?;
|
||||||
|
Ok((state.registers, changes))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn observe(&self) -> Observed {
|
||||||
|
let mut observed = Observed::default();
|
||||||
|
for (key, file, local) in SETTINGS {
|
||||||
|
match read_json::<Value>(&self.dirs().config.join(file)) {
|
||||||
|
Ok(Some(value)) => {
|
||||||
|
observed
|
||||||
|
.values
|
||||||
|
.insert(key.to_owned(), without(value, local));
|
||||||
|
}
|
||||||
|
Ok(None) => {}
|
||||||
|
Err(error) => {
|
||||||
|
tracing::warn!(%error, file, "a settings file was not synced");
|
||||||
|
observed.unknown.insert(key.to_owned());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match self.store.secrets.get(&tmdb_secret()) {
|
||||||
|
Ok(Some(key)) => {
|
||||||
|
observed
|
||||||
|
.values
|
||||||
|
.insert(TMDB_KEY.to_owned(), Value::String(key));
|
||||||
|
}
|
||||||
|
Ok(None) => {}
|
||||||
|
Err(_) => {
|
||||||
|
observed.unknown.insert(TMDB_KEY.to_owned());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(shares) = self.store.list() {
|
||||||
|
observed.shares_listed = true;
|
||||||
|
for share in shares {
|
||||||
|
let key = format!("{SHARE}{}", share.id);
|
||||||
|
let secrets = if share.folder.is_some() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
match self.store.secrets_of(&share.id) {
|
||||||
|
Ok(secrets) => Some(secrets),
|
||||||
|
Err(_) => {
|
||||||
|
observed.unknown.insert(key);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let synced = SyncedShare {
|
||||||
|
name: share.name,
|
||||||
|
token: share.token,
|
||||||
|
url: secrets.as_ref().map(|secrets| secrets.url.clone()),
|
||||||
|
custom_password: secrets.and_then(|secrets| secrets.custom_password),
|
||||||
|
folder: share.folder,
|
||||||
|
};
|
||||||
|
if let Ok(value) = serde_json::to_value(synced) {
|
||||||
|
observed.values.insert(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let catalog = self.catalog.lock();
|
||||||
|
if let Ok(tracks) = catalog.all_title_track_prefs() {
|
||||||
|
for (title, prefs) in tracks {
|
||||||
|
if let Ok(value) = serde_json::to_value(prefs) {
|
||||||
|
observed.values.insert(format!("{TRACKS}{title}"), value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Ok(records) = catalog.all_metadata() {
|
||||||
|
for (title, record) in records {
|
||||||
|
if !record.manual {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let synced = SyncedMatch {
|
||||||
|
provider: record.provider,
|
||||||
|
metadata: record.metadata,
|
||||||
|
fetched_at: record.fetched_at,
|
||||||
|
};
|
||||||
|
if let Ok(value) = serde_json::to_value(synced) {
|
||||||
|
observed.values.insert(format!("{MATCH}{title}"), value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
observed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply each winning register here, recording it in `registers` once it
|
||||||
|
/// took. One that fails is left out, so the next sync tries it again.
|
||||||
|
fn apply(&self, registers: &mut Registers, winners: Registers) -> ProfileChanges {
|
||||||
|
let mut changes = ProfileChanges::default();
|
||||||
|
for (key, register) in winners {
|
||||||
|
match self.apply_one(&key, register.value.as_ref(), &mut changes) {
|
||||||
|
Ok(()) => {
|
||||||
|
registers.insert(key, register);
|
||||||
|
}
|
||||||
|
Err(error) => tracing::warn!(%error, key, "a synced change was not applied"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
changes
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_one(
|
||||||
|
&self,
|
||||||
|
key: &str,
|
||||||
|
value: Option<&Value>,
|
||||||
|
changes: &mut ProfileChanges,
|
||||||
|
) -> Result<()> {
|
||||||
|
if let Some(id) = key.strip_prefix(SHARE) {
|
||||||
|
return self.apply_share(id, value, changes);
|
||||||
|
}
|
||||||
|
if let Some(title) = key.strip_prefix(TRACKS) {
|
||||||
|
if let Some(value) = value {
|
||||||
|
let prefs: TitleTrackPrefs = parse(value)?;
|
||||||
|
self.catalog.lock().set_title_track_prefs(title, &prefs)?;
|
||||||
|
changes.titles += 1;
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if let Some(title) = key.strip_prefix(MATCH) {
|
||||||
|
if let Some(value) = value {
|
||||||
|
let synced: SyncedMatch = parse(value)?;
|
||||||
|
self.catalog.lock().set_metadata(&MetadataRecord {
|
||||||
|
title_key: title.to_owned(),
|
||||||
|
provider: synced.provider,
|
||||||
|
metadata: synced.metadata,
|
||||||
|
fetched_at: synced.fetched_at,
|
||||||
|
manual: true,
|
||||||
|
})?;
|
||||||
|
changes.titles += 1;
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if key == TMDB_KEY {
|
||||||
|
match value.and_then(Value::as_str) {
|
||||||
|
Some(secret) => self.store.secrets.set(&tmdb_secret(), secret)?,
|
||||||
|
None => self.store.secrets.delete(&tmdb_secret())?,
|
||||||
|
}
|
||||||
|
changes.settings = true;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if let Some((_, file, local)) = SETTINGS.iter().find(|(name, ..)| *name == key) {
|
||||||
|
let Some(Value::Object(incoming)) = value else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let path = self.dirs().config.join(file);
|
||||||
|
let before = read_json::<Value>(&path)?;
|
||||||
|
let mut merged = match &before {
|
||||||
|
Some(Value::Object(existing)) => existing.clone(),
|
||||||
|
_ => serde_json::Map::new(),
|
||||||
|
};
|
||||||
|
for (field, field_value) in incoming {
|
||||||
|
if !local.contains(&field.as_str()) {
|
||||||
|
merged.insert(field.clone(), field_value.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let merged = Value::Object(merged);
|
||||||
|
if key == METADATA_SETTINGS {
|
||||||
|
self.metadata_settings_changing(before.as_ref(), &merged)?;
|
||||||
|
}
|
||||||
|
write_json(&path, &merged)?;
|
||||||
|
changes.settings = true;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
// A register a newer build writes. Kept, so it travels on.
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_share(
|
||||||
|
&self,
|
||||||
|
id: &str,
|
||||||
|
value: Option<&Value>,
|
||||||
|
changes: &mut ProfileChanges,
|
||||||
|
) -> Result<()> {
|
||||||
|
let existing = self.store.list()?.into_iter().find(|share| share.id == id);
|
||||||
|
let Some(value) = value else {
|
||||||
|
if existing.is_some() {
|
||||||
|
self.store.remove(id)?;
|
||||||
|
changes.shares_removed.push(id.to_owned());
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let synced: SyncedShare = parse(value)?;
|
||||||
|
let expected = match (&synced.folder, &synced.url) {
|
||||||
|
(Some(folder), _) => folder.share_id(),
|
||||||
|
(None, Some(_)) => format!("share-{}", synced.token),
|
||||||
|
(None, None) => return Err(invalid("a synced link has no URL")),
|
||||||
|
};
|
||||||
|
if expected != id {
|
||||||
|
return Err(invalid("a synced share's id does not match what it opens"));
|
||||||
|
}
|
||||||
|
let secrets = synced.url.map(|url| ShareSecrets {
|
||||||
|
url,
|
||||||
|
custom_password: synced.custom_password,
|
||||||
|
});
|
||||||
|
let share = Share {
|
||||||
|
id: id.to_owned(),
|
||||||
|
name: synced.name,
|
||||||
|
token: synced.token,
|
||||||
|
has_custom_password: secrets
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|secrets| secrets.custom_password.is_some()),
|
||||||
|
folder: synced.folder,
|
||||||
|
};
|
||||||
|
if existing.as_ref() == Some(&share)
|
||||||
|
&& secrets.as_ref().is_none_or(|secrets| {
|
||||||
|
self.store
|
||||||
|
.secrets_of(id)
|
||||||
|
.is_ok_and(|stored| &stored == secrets)
|
||||||
|
})
|
||||||
|
{
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
self.store.put_synced(share, secrets.as_ref())?;
|
||||||
|
if existing.is_some() {
|
||||||
|
changes.shares_changed.push(id.to_owned());
|
||||||
|
} else {
|
||||||
|
changes.shares_added.push(id.to_owned());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Turning enrichment off or switching provider drops every stored answer
|
||||||
|
/// on the device that did it, which the settings page also does here.
|
||||||
|
fn metadata_settings_changing(&self, before: Option<&Value>, after: &Value) -> Result<()> {
|
||||||
|
let config = |value: Option<&Value>| -> MetadataConfig {
|
||||||
|
value
|
||||||
|
.and_then(|value| serde_json::from_value(value.clone()).ok())
|
||||||
|
.unwrap_or_default()
|
||||||
|
};
|
||||||
|
let (before, after) = (config(before), config(Some(after)));
|
||||||
|
if !after.enabled || before.provider != after.provider {
|
||||||
|
self.catalog.lock().clear_metadata()?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record what changed on this device since the last sync, stamped `now`.
|
||||||
|
fn stamp(registers: &mut Registers, observed: Observed, now: i64) {
|
||||||
|
for (key, register) in registers.iter_mut() {
|
||||||
|
if register.value.is_some()
|
||||||
|
&& key.starts_with(SHARE)
|
||||||
|
&& observed.shares_listed
|
||||||
|
&& !observed.values.contains_key(key)
|
||||||
|
&& !observed.unknown.contains(key)
|
||||||
|
{
|
||||||
|
*register = Register {
|
||||||
|
value: None,
|
||||||
|
updated_at: now,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (key, value) in observed.values {
|
||||||
|
let unchanged = registers
|
||||||
|
.get(&key)
|
||||||
|
.is_some_and(|register| same(&key, register.value.as_ref(), Some(&value)));
|
||||||
|
if !unchanged {
|
||||||
|
registers.insert(
|
||||||
|
key,
|
||||||
|
Register {
|
||||||
|
value: Some(value),
|
||||||
|
updated_at: now,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether two values of one register are the same choice.
|
||||||
|
///
|
||||||
|
/// A match is compared by what it points at: its stored details move every
|
||||||
|
/// time the provider is asked again, and a register re-stamped by every
|
||||||
|
/// refresh would bounce between devices for ever.
|
||||||
|
fn same(key: &str, a: Option<&Value>, b: Option<&Value>) -> bool {
|
||||||
|
if key.starts_with(MATCH) {
|
||||||
|
let pointer = |value: Option<&Value>| {
|
||||||
|
value.map(|value| {
|
||||||
|
(
|
||||||
|
value.get("provider").cloned(),
|
||||||
|
value
|
||||||
|
.get("metadata")
|
||||||
|
.and_then(|metadata| metadata.get("remote_id"))
|
||||||
|
.cloned(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
};
|
||||||
|
return pointer(a) == pointer(b);
|
||||||
|
}
|
||||||
|
a == b
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The registers from other devices that are newer than this one's.
|
||||||
|
fn merge<'r>(local: &Registers, remote: impl IntoIterator<Item = &'r Registers>) -> Registers {
|
||||||
|
let mut winners = Registers::new();
|
||||||
|
for registers in remote {
|
||||||
|
for (key, register) in registers {
|
||||||
|
let current = winners.get(key).or_else(|| local.get(key));
|
||||||
|
if current.is_none_or(|current| newer(register, current)) {
|
||||||
|
winners.insert(key.clone(), register.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
winners
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Newer wins; a tie between two different values goes the same way on every
|
||||||
|
/// device, or two of them would each keep their own.
|
||||||
|
fn newer(candidate: &Register, current: &Register) -> bool {
|
||||||
|
if candidate.updated_at != current.updated_at {
|
||||||
|
return candidate.updated_at > current.updated_at;
|
||||||
|
}
|
||||||
|
let order = |register: &Register| {
|
||||||
|
register
|
||||||
|
.value
|
||||||
|
.as_ref()
|
||||||
|
.map(Value::to_string)
|
||||||
|
.unwrap_or_default()
|
||||||
|
};
|
||||||
|
order(candidate) > order(current)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `value` without the fields that stay on this device.
|
||||||
|
fn without(value: Value, local: &[&str]) -> Value {
|
||||||
|
match value {
|
||||||
|
Value::Object(mut fields) => {
|
||||||
|
for field in local {
|
||||||
|
fields.remove(*field);
|
||||||
|
}
|
||||||
|
Value::Object(fields)
|
||||||
|
}
|
||||||
|
other => other,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse<T: serde::de::DeserializeOwned>(value: &Value) -> Result<T> {
|
||||||
|
serde_json::from_value(value.clone()).map_err(|e| invalid(&e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invalid(reason: &str) -> crate::Error {
|
||||||
|
crate::Error::Config(format!("synced setting: {reason}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) mod tests {
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use crate::shares::SecretStore;
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct MemorySecrets(Mutex<HashMap<String, String>>);
|
||||||
|
|
||||||
|
impl SecretStore for MemorySecrets {
|
||||||
|
fn set(&self, key: &str, value: &str) -> Result<()> {
|
||||||
|
self.0.lock().insert(key.to_owned(), value.to_owned());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
fn get(&self, key: &str) -> Result<Option<String>> {
|
||||||
|
Ok(self.0.lock().get(key).cloned())
|
||||||
|
}
|
||||||
|
fn delete(&self, key: &str) -> Result<()> {
|
||||||
|
self.0.lock().remove(key);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One installation's configuration and catalog.
|
||||||
|
pub(crate) struct Device {
|
||||||
|
root: PathBuf,
|
||||||
|
pub store: ShareStore,
|
||||||
|
pub catalog: Mutex<Catalog>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Device {
|
||||||
|
pub(crate) fn new(tag: &str) -> Self {
|
||||||
|
let mut bytes = [0_u8; 8];
|
||||||
|
getrandom::fill(&mut bytes).unwrap();
|
||||||
|
let unique: String = bytes.iter().map(|byte| format!("{byte:02x}")).collect();
|
||||||
|
let root = std::env::temp_dir().join(format!("pstr-profile-{tag}-{unique}"));
|
||||||
|
let dirs = AppDirs::from_paths(root.join("c"), root.join("d"), root.join("k")).unwrap();
|
||||||
|
Self {
|
||||||
|
root,
|
||||||
|
store: ShareStore::with_secret_store(dirs, Arc::new(MemorySecrets::default())),
|
||||||
|
catalog: Mutex::new(Catalog::in_memory().unwrap()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn lock(&self) -> parking_lot::MutexGuard<'_, Catalog> {
|
||||||
|
self.catalog.lock()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn profile(&self) -> Profile<'_> {
|
||||||
|
Profile {
|
||||||
|
store: &self.store,
|
||||||
|
catalog: &self.catalog,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sync(&self, remote: &[&Registers], now: i64) -> (Registers, ProfileChanges) {
|
||||||
|
self.profile().sync(remote.iter().copied(), now).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn share_names(&self) -> Vec<String> {
|
||||||
|
self.store
|
||||||
|
.list()
|
||||||
|
.unwrap()
|
||||||
|
.into_iter()
|
||||||
|
.map(|share| share.name)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for Device {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = std::fs::remove_dir_all(&self.root);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const LINK: &str = "https://drive.proton.me/urls/ABCDEFGHIJ#secretpart";
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_share_added_on_one_device_arrives_on_another_with_its_secrets() {
|
||||||
|
let laptop = Device::new("laptop");
|
||||||
|
let phone = Device::new("phone");
|
||||||
|
laptop.sync(&[], 10);
|
||||||
|
laptop.store.add("Anime", LINK, Some("hunter2")).unwrap();
|
||||||
|
let (sent, _) = laptop.sync(&[], 20);
|
||||||
|
|
||||||
|
let (_, changes) = phone.sync(&[&sent], 30);
|
||||||
|
|
||||||
|
assert_eq!(changes.shares_added, ["share-ABCDEFGHIJ"]);
|
||||||
|
assert_eq!(phone.share_names(), ["Anime"]);
|
||||||
|
let secrets = phone.store.secrets_of("share-ABCDEFGHIJ").unwrap();
|
||||||
|
assert_eq!(secrets.url, LINK);
|
||||||
|
assert_eq!(secrets.custom_password.as_deref(), Some("hunter2"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_share_removed_on_one_device_is_removed_on_the_others() {
|
||||||
|
let laptop = Device::new("laptop");
|
||||||
|
let phone = Device::new("phone");
|
||||||
|
laptop.store.add("Anime", LINK, None).unwrap();
|
||||||
|
let (sent, _) = laptop.sync(&[], 10);
|
||||||
|
let (phone_sent, _) = phone.sync(&[&sent], 20);
|
||||||
|
|
||||||
|
laptop.store.remove("share-ABCDEFGHIJ").unwrap();
|
||||||
|
let (sent, _) = laptop.sync(&[&phone_sent], 30);
|
||||||
|
let (_, changes) = phone.sync(&[&sent], 40);
|
||||||
|
|
||||||
|
assert_eq!(changes.shares_removed, ["share-ABCDEFGHIJ"]);
|
||||||
|
assert!(phone.share_names().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_fresh_installation_takes_the_library_rather_than_its_defaults() {
|
||||||
|
let laptop = Device::new("laptop");
|
||||||
|
let phone = Device::new("phone");
|
||||||
|
laptop.sync(&[], 10);
|
||||||
|
let mut prefs = crate::prefs::load(&laptop.store.dirs).unwrap();
|
||||||
|
prefs.autoplay_next = false;
|
||||||
|
prefs.speed = 1.5;
|
||||||
|
crate::prefs::save(&laptop.store.dirs, &prefs).unwrap();
|
||||||
|
let (sent, _) = laptop.sync(&[], 20);
|
||||||
|
|
||||||
|
// The new phone has written its defaults before it ever synced.
|
||||||
|
crate::prefs::save(&phone.store.dirs, &crate::prefs::PlaybackPrefs::default()).unwrap();
|
||||||
|
let (_, changes) = phone.sync(&[&sent], 30);
|
||||||
|
|
||||||
|
assert!(changes.settings);
|
||||||
|
let taken = crate::prefs::load(&phone.store.dirs).unwrap();
|
||||||
|
assert!(!taken.autoplay_next);
|
||||||
|
assert_eq!(taken.speed, 1.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn volume_and_mute_stay_on_the_device_that_set_them() {
|
||||||
|
let laptop = Device::new("laptop");
|
||||||
|
let phone = Device::new("phone");
|
||||||
|
laptop.sync(&[], 10);
|
||||||
|
let mut prefs = crate::prefs::PlaybackPrefs {
|
||||||
|
volume: 20.0,
|
||||||
|
muted: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
crate::prefs::save(&laptop.store.dirs, &prefs).unwrap();
|
||||||
|
let (sent, _) = laptop.sync(&[], 20);
|
||||||
|
prefs.volume = 80.0;
|
||||||
|
prefs.muted = false;
|
||||||
|
prefs.subtitles = false;
|
||||||
|
crate::prefs::save(&phone.store.dirs, &prefs).unwrap();
|
||||||
|
phone.sync(&[], 5);
|
||||||
|
|
||||||
|
phone.sync(&[&sent], 30);
|
||||||
|
|
||||||
|
let taken = crate::prefs::load(&phone.store.dirs).unwrap();
|
||||||
|
assert_eq!(taken.volume, 80.0);
|
||||||
|
assert!(!taken.muted);
|
||||||
|
assert!(taken.subtitles, "the laptop's later choice wins");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_newer_change_wins_whichever_device_syncs_first() {
|
||||||
|
let laptop = Device::new("laptop");
|
||||||
|
let phone = Device::new("phone");
|
||||||
|
laptop.sync(&[], 1);
|
||||||
|
phone.sync(&[], 1);
|
||||||
|
let mut appearance = crate::appearance::load(&laptop.store.dirs).unwrap();
|
||||||
|
appearance.gradients = false;
|
||||||
|
crate::appearance::save(&laptop.store.dirs, &appearance).unwrap();
|
||||||
|
let (from_laptop, _) = laptop.sync(&[], 10);
|
||||||
|
appearance.gradients = true;
|
||||||
|
appearance.accent = crate::appearance::Accent::Peach;
|
||||||
|
crate::appearance::save(&phone.store.dirs, &appearance).unwrap();
|
||||||
|
let (from_phone, _) = phone.sync(&[&from_laptop], 20);
|
||||||
|
laptop.sync(&[&from_phone], 30);
|
||||||
|
|
||||||
|
for device in [&laptop, &phone] {
|
||||||
|
let seen = crate::appearance::load(&device.store.dirs).unwrap();
|
||||||
|
assert_eq!(seen.accent, crate::appearance::Accent::Peach);
|
||||||
|
assert!(seen.gradients);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unchanged_device_sends_the_same_registers_again() {
|
||||||
|
let laptop = Device::new("laptop");
|
||||||
|
laptop.store.add("Anime", LINK, None).unwrap();
|
||||||
|
let (first, _) = laptop.sync(&[], 10);
|
||||||
|
let (second, changes) = laptop.sync(&[&first], 20);
|
||||||
|
assert_eq!(first, second);
|
||||||
|
assert!(changes.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_share_whose_secret_cannot_be_read_is_not_removed_elsewhere() {
|
||||||
|
let laptop = Device::new("laptop");
|
||||||
|
laptop.store.add("Anime", LINK, None).unwrap();
|
||||||
|
laptop.sync(&[], 10);
|
||||||
|
laptop.store.secrets.delete("share-ABCDEFGHIJ").unwrap();
|
||||||
|
let (sent, _) = laptop.sync(&[], 20);
|
||||||
|
assert!(sent["share/share-ABCDEFGHIJ"].value.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_hand_picked_match_and_a_shows_track_choice_travel() {
|
||||||
|
let laptop = Device::new("laptop");
|
||||||
|
let phone = Device::new("phone");
|
||||||
|
laptop.sync(&[], 1);
|
||||||
|
let metadata = TitleMetadata {
|
||||||
|
provider: ProviderId::AniList,
|
||||||
|
remote_id: "21".to_owned(),
|
||||||
|
name: "One Piece".to_owned(),
|
||||||
|
original_name: None,
|
||||||
|
overview: None,
|
||||||
|
year: Some(1999),
|
||||||
|
kind: crate::library::TitleKind::Series,
|
||||||
|
poster_url: None,
|
||||||
|
backdrop_url: None,
|
||||||
|
rating: None,
|
||||||
|
genres: Vec::new(),
|
||||||
|
episodes: None,
|
||||||
|
url: None,
|
||||||
|
details: None,
|
||||||
|
};
|
||||||
|
laptop
|
||||||
|
.catalog
|
||||||
|
.lock()
|
||||||
|
.set_metadata(&MetadataRecord {
|
||||||
|
title_key: "one piece".to_owned(),
|
||||||
|
provider: ProviderId::AniList,
|
||||||
|
metadata: Some(metadata),
|
||||||
|
fetched_at: 5,
|
||||||
|
manual: true,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let tracks = TitleTrackPrefs {
|
||||||
|
audio_language: Some("jpn".to_owned()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
laptop
|
||||||
|
.catalog
|
||||||
|
.lock()
|
||||||
|
.set_title_track_prefs("one piece", &tracks)
|
||||||
|
.unwrap();
|
||||||
|
let (sent, _) = laptop.sync(&[], 10);
|
||||||
|
|
||||||
|
let (_, changes) = phone.sync(&[&sent], 20);
|
||||||
|
|
||||||
|
assert_eq!(changes.titles, 2);
|
||||||
|
let catalog = phone.catalog.lock();
|
||||||
|
let record = catalog.metadata("one piece").unwrap().unwrap();
|
||||||
|
assert!(record.manual);
|
||||||
|
assert_eq!(record.metadata.unwrap().remote_id, "21");
|
||||||
|
assert_eq!(
|
||||||
|
catalog.title_track_prefs("one piece").unwrap(),
|
||||||
|
Some(tracks)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_synced_share_that_names_another_id_is_refused() {
|
||||||
|
let phone = Device::new("phone");
|
||||||
|
let mut forged = Registers::new();
|
||||||
|
forged.insert(
|
||||||
|
"share/share-OTHER".to_owned(),
|
||||||
|
Register {
|
||||||
|
value: Some(serde_json::json!({
|
||||||
|
"name": "x", "token": "ABCDEFGHIJ", "url": LINK
|
||||||
|
})),
|
||||||
|
updated_at: 10,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let (registers, changes) = phone.sync(&[&forged], 20);
|
||||||
|
assert!(changes.shares_added.is_empty());
|
||||||
|
assert!(!registers.contains_key("share/share-OTHER"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,15 +14,28 @@
|
|||||||
//! A library is not one folder. [`SharedLibrary`] opens every configured share
|
//! A library is not one folder. [`SharedLibrary`] opens every configured share
|
||||||
//! and merges them into one catalog, so the app has a single browsable view
|
//! and merges them into one catalog, so the app has a single browsable view
|
||||||
//! regardless of how many links it was given.
|
//! regardless of how many links it was given.
|
||||||
|
//!
|
||||||
|
//! ## Folders from a signed-in account
|
||||||
|
//!
|
||||||
|
//! A share can also be a folder of the viewer's own Drive — My Files, another
|
||||||
|
//! device, or something shared with them — opened through the account signed
|
||||||
|
//! in by [`crate::account`] instead of a link. It has no secrets of its own:
|
||||||
|
//! the account session is the credential, and without it such a share simply
|
||||||
|
//! fails to open, the same as a revoked link. [`ShareClient`] puts both kinds
|
||||||
|
//! behind one surface so the crawl, the stream layer and the thumbnail path
|
||||||
|
//! do not care which one they hold.
|
||||||
|
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use proton_drive_rs::{Node, ProtonDrivePublicLinkClient};
|
use proton_drive_rs::{
|
||||||
|
Node, ProtonDriveClient, ProtonDrivePublicLinkClient, RevisionReader, ThumbnailType,
|
||||||
|
};
|
||||||
use proton_sdk::config::ProtonClientConfiguration;
|
use proton_sdk::config::ProtonClientConfiguration;
|
||||||
use proton_sdk::ids::NodeUid;
|
use proton_sdk::ids::{LinkId, NodeUid, VolumeId};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::account::Account;
|
||||||
use crate::config::{AppDirs, read_json, write_json};
|
use crate::config::{AppDirs, read_json, write_json};
|
||||||
use crate::error::{Error, Result};
|
use crate::error::{Error, Result};
|
||||||
|
|
||||||
@@ -42,19 +55,26 @@ pub trait SecretStore: Send + Sync {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The desktop OS credential store.
|
/// The desktop OS credential store.
|
||||||
|
///
|
||||||
|
/// A value too long for one entry is split across several; see
|
||||||
|
/// [`ENTRY_UNITS`].
|
||||||
#[cfg(not(target_os = "android"))]
|
#[cfg(not(target_os = "android"))]
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
pub struct KeyringSecretStore;
|
pub struct KeyringSecretStore;
|
||||||
|
|
||||||
|
/// One credential-store entry per key, with no splitting.
|
||||||
#[cfg(not(target_os = "android"))]
|
#[cfg(not(target_os = "android"))]
|
||||||
impl KeyringSecretStore {
|
struct KeyringEntries;
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
|
impl KeyringEntries {
|
||||||
fn entry(key: &str) -> Result<keyring::Entry> {
|
fn entry(key: &str) -> Result<keyring::Entry> {
|
||||||
Ok(keyring::Entry::new(KEYRING_SERVICE, key)?)
|
Ok(keyring::Entry::new(KEYRING_SERVICE, key)?)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(target_os = "android"))]
|
#[cfg(not(target_os = "android"))]
|
||||||
impl SecretStore for KeyringSecretStore {
|
impl SecretStore for KeyringEntries {
|
||||||
fn set(&self, key: &str, value: &str) -> Result<()> {
|
fn set(&self, key: &str, value: &str) -> Result<()> {
|
||||||
Self::entry(key)?.set_password(value)?;
|
Self::entry(key)?.set_password(value)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -76,6 +96,205 @@ impl SecretStore for KeyringSecretStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
|
impl SecretStore for KeyringSecretStore {
|
||||||
|
fn set(&self, key: &str, value: &str) -> Result<()> {
|
||||||
|
chunked::set(&KeyringEntries, key, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get(&self, key: &str) -> Result<Option<String>> {
|
||||||
|
chunked::get(&KeyringEntries, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete(&self, key: &str) -> Result<()> {
|
||||||
|
chunked::delete(&KeyringEntries, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The most UTF-16 code units one credential-store entry is given.
|
||||||
|
///
|
||||||
|
/// Windows caps a credential's blob at 2560 bytes, and `keyring` stores it as
|
||||||
|
/// UTF-16: 1280 units. A share secret is far below that, but the account
|
||||||
|
/// session — tokens, scopes, the mailbox password and a salt per key — can
|
||||||
|
/// pass it, and failing to store a refreshed token signs the viewer out at
|
||||||
|
/// the next launch. The margin is for the header.
|
||||||
|
#[cfg(any(not(target_os = "android"), test))]
|
||||||
|
const ENTRY_UNITS: usize = 1200;
|
||||||
|
|
||||||
|
/// Splitting a long secret across several entries of an underlying store.
|
||||||
|
///
|
||||||
|
/// A short value is one entry, exactly as before splitting existed, so every
|
||||||
|
/// secret already stored still reads. A long one is written as `key#0`,
|
||||||
|
/// `key#1`, … with `key` itself holding [`HEADER`] and the count. The parts go
|
||||||
|
/// first and the header last, so a reader never finds a header naming parts
|
||||||
|
/// that are not there yet.
|
||||||
|
#[cfg(any(not(target_os = "android"), test))]
|
||||||
|
mod chunked {
|
||||||
|
use super::{ENTRY_UNITS, SecretStore};
|
||||||
|
use crate::error::Result;
|
||||||
|
|
||||||
|
/// Marks a split value. A short value that happens to start with it is
|
||||||
|
/// split too, so it cannot be mistaken for a header.
|
||||||
|
const HEADER: &str = "pstr-chunked:";
|
||||||
|
|
||||||
|
fn part(key: &str, index: usize) -> String {
|
||||||
|
format!("{key}#{index}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parts_of(header: &str) -> Option<usize> {
|
||||||
|
header.strip_prefix(HEADER)?.parse().ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `value` cut into pieces of at most [`ENTRY_UNITS`] UTF-16 units, never
|
||||||
|
/// inside a character.
|
||||||
|
fn split(value: &str) -> Vec<&str> {
|
||||||
|
let mut pieces = Vec::new();
|
||||||
|
let mut start = 0;
|
||||||
|
let mut units = 0;
|
||||||
|
for (at, ch) in value.char_indices() {
|
||||||
|
if units + ch.len_utf16() > ENTRY_UNITS {
|
||||||
|
pieces.push(&value[start..at]);
|
||||||
|
start = at;
|
||||||
|
units = 0;
|
||||||
|
}
|
||||||
|
units += ch.len_utf16();
|
||||||
|
}
|
||||||
|
pieces.push(&value[start..]);
|
||||||
|
pieces
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn set(store: &dyn SecretStore, key: &str, value: &str) -> Result<()> {
|
||||||
|
let before = store.get(key)?.as_deref().and_then(parts_of).unwrap_or(0);
|
||||||
|
let fits = value.encode_utf16().count() <= ENTRY_UNITS && !value.starts_with(HEADER);
|
||||||
|
let written = if fits {
|
||||||
|
store.set(key, value)?;
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
let pieces = split(value);
|
||||||
|
for (index, piece) in pieces.iter().enumerate() {
|
||||||
|
store.set(&part(key, index), piece)?;
|
||||||
|
}
|
||||||
|
store.set(key, &format!("{HEADER}{}", pieces.len()))?;
|
||||||
|
pieces.len()
|
||||||
|
};
|
||||||
|
for index in written..before {
|
||||||
|
store.delete(&part(key, index))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn get(store: &dyn SecretStore, key: &str) -> Result<Option<String>> {
|
||||||
|
let Some(value) = store.get(key)? else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let Some(count) = parts_of(&value) else {
|
||||||
|
return Ok(Some(value));
|
||||||
|
};
|
||||||
|
let mut joined = String::new();
|
||||||
|
for index in 0..count {
|
||||||
|
// A part gone missing is a value that no longer exists whole;
|
||||||
|
// half of a token is worse than none.
|
||||||
|
let Some(piece) = store.get(&part(key, index))? else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
joined.push_str(&piece);
|
||||||
|
}
|
||||||
|
Ok(Some(joined))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn delete(store: &dyn SecretStore, key: &str) -> Result<()> {
|
||||||
|
let count = store.get(key)?.as_deref().and_then(parts_of).unwrap_or(0);
|
||||||
|
store.delete(key)?;
|
||||||
|
for index in 0..count {
|
||||||
|
store.delete(&part(key, index))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use parking_lot::Mutex;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// An underlying store that refuses what Windows would.
|
||||||
|
#[derive(Default)]
|
||||||
|
struct Limited(Mutex<BTreeMap<String, String>>);
|
||||||
|
|
||||||
|
impl SecretStore for Limited {
|
||||||
|
fn set(&self, key: &str, value: &str) -> Result<()> {
|
||||||
|
assert!(
|
||||||
|
value.encode_utf16().count() <= 1280,
|
||||||
|
"{key} is too long for one entry"
|
||||||
|
);
|
||||||
|
self.0.lock().insert(key.to_owned(), value.to_owned());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
fn get(&self, key: &str) -> Result<Option<String>> {
|
||||||
|
Ok(self.0.lock().get(key).cloned())
|
||||||
|
}
|
||||||
|
fn delete(&self, key: &str) -> Result<()> {
|
||||||
|
self.0.lock().remove(key);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_short_secret_is_one_entry_as_it_always_was() {
|
||||||
|
let store = Limited::default();
|
||||||
|
set(&store, "share-a", "fragment").unwrap();
|
||||||
|
assert_eq!(store.0.lock().len(), 1);
|
||||||
|
assert_eq!(get(&store, "share-a").unwrap().as_deref(), Some("fragment"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_secret_longer_than_an_entry_round_trips() {
|
||||||
|
let store = Limited::default();
|
||||||
|
let long: String = "aé😀".repeat(1500);
|
||||||
|
set(&store, "proton-account", &long).unwrap();
|
||||||
|
assert!(store.0.lock().len() > 2);
|
||||||
|
assert_eq!(get(&store, "proton-account").unwrap(), Some(long));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_shorter_value_leaves_no_parts_of_the_longer_one_behind() {
|
||||||
|
let store = Limited::default();
|
||||||
|
set(&store, "k", &"x".repeat(5000)).unwrap();
|
||||||
|
set(&store, "k", &"y".repeat(1300)).unwrap();
|
||||||
|
assert_eq!(store.0.lock().len(), 3);
|
||||||
|
set(&store, "k", "short").unwrap();
|
||||||
|
assert_eq!(store.0.lock().len(), 1);
|
||||||
|
assert_eq!(get(&store, "k").unwrap().as_deref(), Some("short"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_value_that_looks_like_a_header_is_not_taken_for_one() {
|
||||||
|
let store = Limited::default();
|
||||||
|
set(&store, "k", "pstr-chunked:3").unwrap();
|
||||||
|
assert_eq!(get(&store, "k").unwrap().as_deref(), Some("pstr-chunked:3"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deleting_a_split_secret_deletes_every_part() {
|
||||||
|
let store = Limited::default();
|
||||||
|
set(&store, "k", &"x".repeat(5000)).unwrap();
|
||||||
|
delete(&store, "k").unwrap();
|
||||||
|
assert!(store.0.lock().is_empty());
|
||||||
|
assert_eq!(get(&store, "k").unwrap(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_split_secret_missing_a_part_reads_as_absent() {
|
||||||
|
let store = Limited::default();
|
||||||
|
set(&store, "k", &"x".repeat(5000)).unwrap();
|
||||||
|
store.delete("k#1").unwrap();
|
||||||
|
assert_eq!(get(&store, "k").unwrap(), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// What the app records about a share, minus its secrets.
|
/// What the app records about a share, minus its secrets.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct Share {
|
pub struct Share {
|
||||||
@@ -92,22 +311,63 @@ pub struct Share {
|
|||||||
/// rather than after it fails.
|
/// rather than after it fails.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub has_custom_password: bool,
|
pub has_custom_password: bool,
|
||||||
|
/// Set when this share is a folder of the signed-in account rather than a
|
||||||
|
/// public link; `token` is then empty and there are no secrets.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub folder: Option<AccountFolder>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A folder of the signed-in account, kept as a library share.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct AccountFolder {
|
||||||
|
pub volume_id: String,
|
||||||
|
pub link_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AccountFolder {
|
||||||
|
pub fn uid(&self) -> NodeUid {
|
||||||
|
NodeUid::new(
|
||||||
|
VolumeId::from(self.volume_id.clone()),
|
||||||
|
LinkId::from(self.link_id.clone()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The share id this folder is kept under.
|
||||||
|
///
|
||||||
|
/// Derived, like a link's `share-{token}`, so the same folder added on two
|
||||||
|
/// devices is the same share — which is what lets watch history recorded
|
||||||
|
/// against it on one device land on the other. Hashed rather than spliced
|
||||||
|
/// in because Proton ids are base64 and may carry characters a catalog key
|
||||||
|
/// is better off without.
|
||||||
|
pub fn share_id(&self) -> String {
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
let mut hash = Sha256::new();
|
||||||
|
hash.update(self.volume_id.as_bytes());
|
||||||
|
hash.update([0]);
|
||||||
|
hash.update(self.link_id.as_bytes());
|
||||||
|
let digest = hash.finalize();
|
||||||
|
let hex: String = digest[..12]
|
||||||
|
.iter()
|
||||||
|
.map(|byte| format!("{byte:02x}"))
|
||||||
|
.collect();
|
||||||
|
format!("drive-{hex}")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The secrets for one share, as held in the credential store.
|
/// The secrets for one share, as held in the credential store.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
struct ShareSecrets {
|
pub(crate) struct ShareSecrets {
|
||||||
/// The full share URL, fragment included.
|
/// The full share URL, fragment included.
|
||||||
url: String,
|
pub(crate) url: String,
|
||||||
/// The custom password, when the link has one.
|
/// The custom password, when the link has one.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
custom_password: Option<String>,
|
pub(crate) custom_password: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The configured shares, and their secrets.
|
/// The configured shares, and their secrets.
|
||||||
pub struct ShareStore {
|
pub struct ShareStore {
|
||||||
dirs: AppDirs,
|
pub(crate) dirs: AppDirs,
|
||||||
secrets: Arc<dyn SecretStore>,
|
pub(crate) secrets: Arc<dyn SecretStore>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ShareStore {
|
impl ShareStore {
|
||||||
@@ -136,9 +396,9 @@ impl ShareStore {
|
|||||||
|
|
||||||
let mut shares = self.list()?;
|
let mut shares = self.list()?;
|
||||||
if shares.iter().any(|share| share.id == id) {
|
if shares.iter().any(|share| share.id == id) {
|
||||||
return Err(Error::Config(format!(
|
return Err(Error::Config(
|
||||||
"a share for token {token} is already configured"
|
"that link is already in the library".to_owned(),
|
||||||
)));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let secrets = ShareSecrets {
|
let secrets = ShareSecrets {
|
||||||
@@ -154,6 +414,78 @@ impl ShareStore {
|
|||||||
name: name.to_string(),
|
name: name.to_string(),
|
||||||
token,
|
token,
|
||||||
has_custom_password: secrets.custom_password.is_some(),
|
has_custom_password: secrets.custom_password.is_some(),
|
||||||
|
folder: None,
|
||||||
|
};
|
||||||
|
shares.push(share.clone());
|
||||||
|
write_json(&self.dirs.shares_file(), &shares)?;
|
||||||
|
Ok(share)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`Self::add`], but only once the link has been opened with the
|
||||||
|
/// password given.
|
||||||
|
///
|
||||||
|
/// A link that is mistyped, expired or behind the wrong password used to
|
||||||
|
/// be stored anyway, so the first try failed on the crawl and every retry
|
||||||
|
/// failed on "already configured" — the only way out was to remove a share
|
||||||
|
/// that had never worked. Opening first means a refused link leaves
|
||||||
|
/// nothing behind, and the client it opened is handed back for the crawl.
|
||||||
|
pub async fn add_verified(
|
||||||
|
&self,
|
||||||
|
name: &str,
|
||||||
|
url: &str,
|
||||||
|
custom_password: Option<&str>,
|
||||||
|
) -> Result<(Share, ShareClient)> {
|
||||||
|
let token = token_from_url(url)?;
|
||||||
|
let id = format!("share-{token}");
|
||||||
|
if self.list()?.iter().any(|share| share.id == id) {
|
||||||
|
return Err(Error::Config(
|
||||||
|
"that link is already in the library".to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let custom_password = custom_password.filter(|password| !password.is_empty());
|
||||||
|
let client = open_link(url, custom_password).await?;
|
||||||
|
let share = self.add(name, url, custom_password)?;
|
||||||
|
Ok((share, client))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record a share another installation has, with the secrets it opens
|
||||||
|
/// with, replacing what this one holds under the same id. See
|
||||||
|
/// [`crate::profile`].
|
||||||
|
pub(crate) fn put_synced(&self, share: Share, secrets: Option<&ShareSecrets>) -> Result<()> {
|
||||||
|
if let Some(secrets) = secrets {
|
||||||
|
self.store_secrets(&share.id, secrets)?;
|
||||||
|
}
|
||||||
|
let mut shares = self.list()?;
|
||||||
|
match shares.iter_mut().find(|existing| existing.id == share.id) {
|
||||||
|
Some(existing) => *existing = share,
|
||||||
|
None => shares.push(share),
|
||||||
|
}
|
||||||
|
write_json(&self.dirs.shares_file(), &shares)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A share's secrets, for syncing them to another installation.
|
||||||
|
pub(crate) fn secrets_of(&self, id: &str) -> Result<ShareSecrets> {
|
||||||
|
self.load_secrets(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record a folder of the signed-in account as a share.
|
||||||
|
///
|
||||||
|
/// Nothing goes in the credential store: the folder opens through the
|
||||||
|
/// account's session, which [`crate::account::AccountStore`] keeps.
|
||||||
|
pub fn add_folder(&self, name: &str, folder: AccountFolder) -> Result<Share> {
|
||||||
|
let id = folder.share_id();
|
||||||
|
let mut shares = self.list()?;
|
||||||
|
if shares.iter().any(|share| share.id == id) {
|
||||||
|
return Err(Error::Config(
|
||||||
|
"that folder is already in the library".to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let share = Share {
|
||||||
|
id,
|
||||||
|
name: name.to_string(),
|
||||||
|
token: String::new(),
|
||||||
|
has_custom_password: false,
|
||||||
|
folder: Some(folder),
|
||||||
};
|
};
|
||||||
shares.push(share.clone());
|
shares.push(share.clone());
|
||||||
write_json(&self.dirs.shares_file(), &shares)?;
|
write_json(&self.dirs.shares_file(), &shares)?;
|
||||||
@@ -184,6 +516,11 @@ impl ShareStore {
|
|||||||
.iter_mut()
|
.iter_mut()
|
||||||
.find(|share| share.id == id)
|
.find(|share| share.id == id)
|
||||||
.ok_or_else(|| Error::NotFound(format!("no share with id {id}")))?;
|
.ok_or_else(|| Error::NotFound(format!("no share with id {id}")))?;
|
||||||
|
if share.folder.is_some() {
|
||||||
|
return Err(Error::Config(
|
||||||
|
"that share is a folder of your account and has no link".to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
if share.token != token {
|
if share.token != token {
|
||||||
return Err(Error::Config(
|
return Err(Error::Config(
|
||||||
"that link is for a different share; add it as a new one".to_owned(),
|
"that link is for a different share; add it as a new one".to_owned(),
|
||||||
@@ -221,20 +558,22 @@ impl ShareStore {
|
|||||||
self.secrets.delete(id)
|
self.secrets.delete(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Open a share as a visitor.
|
/// Open a share: a link as a visitor, a folder through `account`.
|
||||||
pub async fn open(&self, share: &Share) -> Result<ProtonDrivePublicLinkClient> {
|
pub async fn open(&self, share: &Share, account: Option<&Account>) -> Result<ShareClient> {
|
||||||
|
if let Some(folder) = &share.folder {
|
||||||
|
let account = account.ok_or_else(|| {
|
||||||
|
Error::SignedOut(format!(
|
||||||
|
"sign in to Proton to open {}, a folder of your account",
|
||||||
|
share.name
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
return Ok(ShareClient::Account {
|
||||||
|
client: account.drive().clone(),
|
||||||
|
root: folder.uid(),
|
||||||
|
});
|
||||||
|
}
|
||||||
let secrets = self.load_secrets(&share.id)?;
|
let secrets = self.load_secrets(&share.id)?;
|
||||||
let client = ProtonDrivePublicLinkClient::open(
|
open_link(&secrets.url, secrets.custom_password.as_deref()).await
|
||||||
client_configuration(),
|
|
||||||
&secrets.url,
|
|
||||||
secrets.custom_password.as_deref(),
|
|
||||||
)
|
|
||||||
.await?
|
|
||||||
// The SDK default is sized for a background sync daemon. A player that
|
|
||||||
// seeks wants more blocks in flight; at 4 MiB each this is a 192 MiB
|
|
||||||
// ceiling, which is unremarkable for a desktop app.
|
|
||||||
.with_max_inflight_blocks(48);
|
|
||||||
Ok(client)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn store_secrets(&self, id: &str, secrets: &ShareSecrets) -> Result<()> {
|
fn store_secrets(&self, id: &str, secrets: &ShareSecrets) -> Result<()> {
|
||||||
@@ -254,9 +593,109 @@ impl ShareStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Open a public link as a visitor.
|
||||||
|
async fn open_link(url: &str, custom_password: Option<&str>) -> Result<ShareClient> {
|
||||||
|
let client = ProtonDrivePublicLinkClient::open(client_configuration(), url, custom_password)
|
||||||
|
.await?
|
||||||
|
// The SDK default is sized for a background sync daemon. A player that
|
||||||
|
// seeks wants more blocks in flight; at 4 MiB each this is a 192 MiB
|
||||||
|
// ceiling, which is unremarkable for a desktop app.
|
||||||
|
.with_max_inflight_blocks(48);
|
||||||
|
Ok(ShareClient::PublicLink(Box::new(client)))
|
||||||
|
}
|
||||||
|
|
||||||
/// Several opened shares, presented as one library.
|
/// Several opened shares, presented as one library.
|
||||||
pub struct SharedLibrary {
|
pub struct SharedLibrary {
|
||||||
clients: BTreeMap<String, ProtonDrivePublicLinkClient>,
|
clients: BTreeMap<String, ShareClient>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One opened share, whichever kind it is.
|
||||||
|
///
|
||||||
|
/// Cheap to clone: both clients share their caches and sessions between
|
||||||
|
/// clones.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub enum ShareClient {
|
||||||
|
/// A public link, opened as a visitor. Boxed: the visitor client is
|
||||||
|
/// several times the size of the account variant, and libraries are
|
||||||
|
/// mostly links, so every map entry would otherwise pay for it.
|
||||||
|
PublicLink(Box<ProtonDrivePublicLinkClient>),
|
||||||
|
/// A folder of the signed-in account.
|
||||||
|
Account {
|
||||||
|
client: ProtonDriveClient,
|
||||||
|
root: NodeUid,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ShareClient {
|
||||||
|
/// The share's top folder.
|
||||||
|
pub async fn root_node(&self) -> proton_sdk::error::Result<Node> {
|
||||||
|
match self {
|
||||||
|
Self::PublicLink(client) => client.get_root_node().await,
|
||||||
|
Self::Account { client, root } => client.get_node(root).await?.ok_or_else(|| {
|
||||||
|
proton_sdk::error::ProtonError::invalid_operation(
|
||||||
|
"the folder is no longer in your Drive",
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn enumerate_folder_children_node_uids(
|
||||||
|
&self,
|
||||||
|
folder: &NodeUid,
|
||||||
|
) -> proton_sdk::error::Result<Vec<NodeUid>> {
|
||||||
|
match self {
|
||||||
|
Self::PublicLink(client) => client.enumerate_folder_children_node_uids(folder).await,
|
||||||
|
Self::Account { client, .. } => {
|
||||||
|
client.enumerate_folder_children_node_uids(folder).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn enumerate_nodes(&self, uids: &[NodeUid]) -> proton_sdk::error::Result<Vec<Node>> {
|
||||||
|
match self {
|
||||||
|
Self::PublicLink(client) => client.enumerate_nodes(uids).await,
|
||||||
|
Self::Account { client, .. } => client.enumerate_nodes(uids).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn open_revision(&self, uid: &NodeUid) -> proton_sdk::error::Result<RevisionReader> {
|
||||||
|
match self {
|
||||||
|
Self::PublicLink(client) => client.open_revision(uid).await,
|
||||||
|
Self::Account { client, .. } => client.open_revision(uid).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn download_range(
|
||||||
|
&self,
|
||||||
|
uid: &NodeUid,
|
||||||
|
offset: u64,
|
||||||
|
length: u64,
|
||||||
|
) -> proton_sdk::error::Result<Vec<u8>> {
|
||||||
|
match self {
|
||||||
|
Self::PublicLink(client) => client.download_range(uid, offset, length).await,
|
||||||
|
Self::Account { client, .. } => client.download_range(uid, offset, length).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn download_thumbnail(
|
||||||
|
&self,
|
||||||
|
uid: &NodeUid,
|
||||||
|
kind: ThumbnailType,
|
||||||
|
) -> proton_sdk::error::Result<Option<Vec<u8>>> {
|
||||||
|
match self {
|
||||||
|
Self::PublicLink(client) => client.download_thumbnail(uid, kind).await,
|
||||||
|
Self::Account { client, .. } => client.download_thumbnail(uid, kind).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replay a visitor handshake. An account session refreshes its own
|
||||||
|
/// tokens when one expires, so there is nothing to do for a folder.
|
||||||
|
pub async fn refresh_session(&self) -> proton_sdk::error::Result<()> {
|
||||||
|
match self {
|
||||||
|
Self::PublicLink(client) => client.refresh_session().await,
|
||||||
|
Self::Account { .. } => Ok(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SharedLibrary {
|
impl SharedLibrary {
|
||||||
@@ -265,8 +704,14 @@ impl SharedLibrary {
|
|||||||
/// A share that fails to open is reported and skipped rather than failing
|
/// A share that fails to open is reported and skipped rather than failing
|
||||||
/// the whole library — one revoked link should not make the other three
|
/// the whole library — one revoked link should not make the other three
|
||||||
/// unwatchable.
|
/// unwatchable.
|
||||||
pub async fn open_all(store: &ShareStore) -> Result<(Self, Vec<(Share, Error)>)> {
|
///
|
||||||
Self::open_all_reusing(store, BTreeMap::new()).await
|
/// Folders of the account open through `account`; without one they are
|
||||||
|
/// reported as failures like any other share that will not open.
|
||||||
|
pub async fn open_all(
|
||||||
|
store: &ShareStore,
|
||||||
|
account: Option<&Account>,
|
||||||
|
) -> Result<(Self, Vec<(Share, Error)>)> {
|
||||||
|
Self::open_all_reusing(store, BTreeMap::new(), account).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Open every configured share, reusing the clients in `reusable`.
|
/// Open every configured share, reusing the clients in `reusable`.
|
||||||
@@ -278,7 +723,8 @@ impl SharedLibrary {
|
|||||||
/// link.
|
/// link.
|
||||||
pub async fn open_all_reusing(
|
pub async fn open_all_reusing(
|
||||||
store: &ShareStore,
|
store: &ShareStore,
|
||||||
mut reusable: BTreeMap<String, ProtonDrivePublicLinkClient>,
|
mut reusable: BTreeMap<String, ShareClient>,
|
||||||
|
account: Option<&Account>,
|
||||||
) -> Result<(Self, Vec<(Share, Error)>)> {
|
) -> Result<(Self, Vec<(Share, Error)>)> {
|
||||||
let mut clients = BTreeMap::new();
|
let mut clients = BTreeMap::new();
|
||||||
let mut failures = Vec::new();
|
let mut failures = Vec::new();
|
||||||
@@ -288,7 +734,7 @@ impl SharedLibrary {
|
|||||||
clients.insert(share.id, client);
|
clients.insert(share.id, client);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
match store.open(&share).await {
|
match store.open(&share, account).await {
|
||||||
Ok(client) => {
|
Ok(client) => {
|
||||||
clients.insert(share.id.clone(), client);
|
clients.insert(share.id.clone(), client);
|
||||||
}
|
}
|
||||||
@@ -305,19 +751,22 @@ impl SharedLibrary {
|
|||||||
/// The excluded share is the one whose credentials or membership just
|
/// The excluded share is the one whose credentials or membership just
|
||||||
/// changed; leaving it out is what makes a removed share's live client
|
/// changed; leaving it out is what makes a removed share's live client
|
||||||
/// unreachable rather than merely stale.
|
/// unreachable rather than merely stale.
|
||||||
pub fn reusable_clients(
|
///
|
||||||
&self,
|
/// Folders of the account are never handed back: they carry the session
|
||||||
dropping: &str,
|
/// they were opened with, and the account may have signed out or changed
|
||||||
) -> BTreeMap<String, ProtonDrivePublicLinkClient> {
|
/// since.
|
||||||
|
pub fn reusable_clients(&self, dropping: &str) -> BTreeMap<String, ShareClient> {
|
||||||
self.clients
|
self.clients
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|(id, _)| id.as_str() != dropping)
|
.filter(|(id, client)| {
|
||||||
|
id.as_str() != dropping && matches!(client, ShareClient::PublicLink(_))
|
||||||
|
})
|
||||||
.map(|(id, client)| (id.clone(), client.clone()))
|
.map(|(id, client)| (id.clone(), client.clone()))
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The client for one share.
|
/// The client for one share.
|
||||||
pub fn client(&self, share_id: &str) -> Option<&ProtonDrivePublicLinkClient> {
|
pub fn client(&self, share_id: &str) -> Option<&ShareClient> {
|
||||||
self.clients.get(share_id)
|
self.clients.get(share_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -377,7 +826,7 @@ impl SharedLibrary {
|
|||||||
.client(share_id)
|
.client(share_id)
|
||||||
.ok_or_else(|| Error::NotFound(format!("share {share_id} is not open")))?;
|
.ok_or_else(|| Error::NotFound(format!("share {share_id} is not open")))?;
|
||||||
|
|
||||||
let root = client.get_root_node().await?;
|
let root = client.root_node().await?;
|
||||||
let mut level = vec![root.uid.clone()];
|
let mut level = vec![root.uid.clone()];
|
||||||
let mut found = vec![root];
|
let mut found = vec![root];
|
||||||
|
|
||||||
@@ -433,7 +882,7 @@ const APP_VERSION: &str = concat!("external-drive-stream@", env!("CARGO_PKG_VERS
|
|||||||
const USER_AGENT: &str = concat!("proton-stream/", env!("CARGO_PKG_VERSION"));
|
const USER_AGENT: &str = concat!("proton-stream/", env!("CARGO_PKG_VERSION"));
|
||||||
|
|
||||||
/// The API configuration every visitor client is built from.
|
/// The API configuration every visitor client is built from.
|
||||||
fn client_configuration() -> ProtonClientConfiguration {
|
pub(crate) fn client_configuration() -> ProtonClientConfiguration {
|
||||||
ProtonClientConfiguration::new(APP_VERSION).with_user_agent(USER_AGENT)
|
ProtonClientConfiguration::new(APP_VERSION).with_user_agent(USER_AGENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -421,6 +421,22 @@ impl MetadataService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Pin `title` to the entry the viewer picked, and enrich it.
|
||||||
|
///
|
||||||
|
/// Both halves, always: the point of choosing an entry by hand is usually
|
||||||
|
/// that the episode names were wrong too, so a choice that stored only the
|
||||||
|
/// title record left the episodes, the backdrop and the franchise chain of
|
||||||
|
/// the *old* match — or none — until some later match run got round to it.
|
||||||
|
/// A failed enrichment still keeps the choice, as in [`Self::work`].
|
||||||
|
pub async fn choose(&self, title: &Title, found: TitleMetadata) -> Outcome {
|
||||||
|
let enrichment = self.enrich_or_log(title, &found).await;
|
||||||
|
Outcome {
|
||||||
|
title_key: title.key.clone(),
|
||||||
|
record: Some(self.chosen(title.key.clone(), found)),
|
||||||
|
enrichment,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn enrich_or_log(&self, title: &Title, found: &TitleMetadata) -> Option<Enrichment> {
|
async fn enrich_or_log(&self, title: &Title, found: &TitleMetadata) -> Option<Enrichment> {
|
||||||
self.enrich(title, found)
|
self.enrich(title, found)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -42,4 +42,4 @@ pub use proton_sdk::ids::NodeUid;
|
|||||||
pub use reader::{FileBlocks, LibraryOpener, RevisionBlocks};
|
pub use reader::{FileBlocks, LibraryOpener, RevisionBlocks};
|
||||||
pub use ring::{DEFAULT_RING_BYTES, RingStats};
|
pub use ring::{DEFAULT_RING_BYTES, RingStats};
|
||||||
pub use source::{RevisionOpener, StreamConfig, StreamSource};
|
pub use source::{RevisionOpener, StreamConfig, StreamSource};
|
||||||
pub use stream::{DEFAULT_READAHEAD_BLOCKS, StreamStats, VideoStream};
|
pub use stream::{COPY_BLOCKS_IN_FLIGHT, DEFAULT_READAHEAD_BLOCKS, StreamStats, VideoStream};
|
||||||
@@ -13,7 +13,6 @@ use std::sync::Arc;
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use proton_drive_rs::ProtonDrivePublicLinkClient;
|
|
||||||
use proton_sdk::error::ProtonError;
|
use proton_sdk::error::ProtonError;
|
||||||
use proton_sdk::ids::NodeUid;
|
use proton_sdk::ids::NodeUid;
|
||||||
|
|
||||||
@@ -159,7 +158,7 @@ impl LibraryOpener {
|
|||||||
Self { library }
|
Self { library }
|
||||||
}
|
}
|
||||||
|
|
||||||
fn client(&self, share_id: &str) -> Result<&ProtonDrivePublicLinkClient> {
|
fn client(&self, share_id: &str) -> Result<&pstr_core::ShareClient> {
|
||||||
self.library
|
self.library
|
||||||
.client(share_id)
|
.client(share_id)
|
||||||
.ok_or_else(|| Error::NotFound(format!("share {share_id} is not open")))
|
.ok_or_else(|| Error::NotFound(format!("share {share_id} is not open")))
|
||||||
|
|||||||
@@ -63,6 +63,12 @@ const MAX_CONCURRENT_BLOCKS_PER_READ: usize = 4;
|
|||||||
/// bandwidth on cancelled prefetches. Well past a 1080p bitrate either way.
|
/// bandwidth on cancelled prefetches. Well past a 1080p bitrate either way.
|
||||||
pub const DEFAULT_READAHEAD_BLOCKS: usize = 12;
|
pub const DEFAULT_READAHEAD_BLOCKS: usize = 12;
|
||||||
|
|
||||||
|
/// How many blocks of one offline copy are fetched at once, for
|
||||||
|
/// [`VideoStream::blocks_in_order`]. The same as one read is allowed, for the
|
||||||
|
/// same reason: enough to hide a round trip, few enough that a couple of
|
||||||
|
/// copies running together do not swamp a player.
|
||||||
|
pub const COPY_BLOCKS_IN_FLIGHT: usize = 4;
|
||||||
|
|
||||||
/// How far a read may land from where the previous one ended and still count as
|
/// How far a read may land from where the previous one ended and still count as
|
||||||
/// a continuation rather than a seek.
|
/// a continuation rather than a seek.
|
||||||
///
|
///
|
||||||
@@ -299,6 +305,42 @@ impl VideoStream {
|
|||||||
Ok(buf)
|
Ok(buf)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Every block from `first` on, fetched `in_flight` at a time and yielded
|
||||||
|
/// in file order.
|
||||||
|
///
|
||||||
|
/// What an offline copy reads through. File order is what a partial file's
|
||||||
|
/// "its length is a block boundary" resume rule needs, and a few blocks in
|
||||||
|
/// flight is what keeps a copy from running one round trip per block. Use
|
||||||
|
/// it on a stream from [`crate::StreamSource::open_for_copy`]: a playback
|
||||||
|
/// stream would add its own read-ahead on top.
|
||||||
|
pub fn blocks_in_order(
|
||||||
|
&self,
|
||||||
|
first: usize,
|
||||||
|
in_flight: usize,
|
||||||
|
) -> futures::stream::BoxStream<'static, Result<Vec<u8>>> {
|
||||||
|
let start: u64 = self.block_sizes()[..first.min(self.block_sizes().len())]
|
||||||
|
.iter()
|
||||||
|
.sum();
|
||||||
|
let ranges: Vec<(u64, u64)> = self
|
||||||
|
.block_sizes()
|
||||||
|
.iter()
|
||||||
|
.skip(first)
|
||||||
|
.scan(start, |offset, size| {
|
||||||
|
let range = (*offset, *size);
|
||||||
|
*offset += size;
|
||||||
|
Some(range)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let stream = self.clone();
|
||||||
|
stream::iter(ranges)
|
||||||
|
.map(move |(offset, size)| {
|
||||||
|
let stream = stream.clone();
|
||||||
|
async move { stream.read_range(offset, size).await }
|
||||||
|
})
|
||||||
|
.buffered(in_flight.max(1))
|
||||||
|
.boxed()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn stats(&self) -> StreamStats {
|
pub fn stats(&self) -> StreamStats {
|
||||||
StreamStats {
|
StreamStats {
|
||||||
ring: self.inner.ring.stats(),
|
ring: self.inner.ring.stats(),
|
||||||
|
|||||||
@@ -55,15 +55,15 @@ installs the signed minified APK and runs instrumentation against it, which is
|
|||||||
the only path that exercises R8 — the step that has broken this app five distinct
|
the only path that exercises R8 — the step that has broken this app five distinct
|
||||||
ways that a debug build cannot reproduce. Without the `ANDROID_RELEASE_*`
|
ways that a debug build cannot reproduce. Without the `ANDROID_RELEASE_*`
|
||||||
variables below, drop `--release` and the debug APK is used instead. The matrix — phone and tablet layouts, rotation,
|
variables below, drop `--release` and the debug APK is used instead. The matrix — phone and tablet layouts, rotation,
|
||||||
process recreation, playback and background controls, Picture-in-Picture,
|
process recreation, playback and background controls,
|
||||||
download cancellation/resume, and an offline launch with networking disabled —
|
download cancellation/resume, and an offline launch with networking disabled —
|
||||||
is `scripts/android-acceptance.sh`; `--list` prints every case and what it
|
is `scripts/android-acceptance.sh`; `--list` prints every case and what it
|
||||||
needs, and `PSTR_ACCEPTANCE_ONLY=name` runs one. Five cases need a real share,
|
needs, and `PSTR_ACCEPTANCE_ONLY=name` runs one. Five cases need a real share,
|
||||||
given as `PSTR_ACCEPTANCE_SHARE_URL` and `PSTR_ACCEPTANCE_SHARE_PASSWORD`; they
|
given as `PSTR_ACCEPTANCE_SHARE_URL` and `PSTR_ACCEPTANCE_SHARE_PASSWORD`; they
|
||||||
reach the device as instrumentation arguments and are stored nowhere. Without
|
reach the device as instrumentation arguments and are stored nowhere. Without
|
||||||
them those cases report `skip`. `picture-in-picture` is still `pending`, so the
|
them those cases report `skip`. There is no Picture-in-Picture: it was removed
|
||||||
run tells you the matrix is incomplete — but no longer because of B40, which is
|
after a PiP window that never sized its video right proved less use than
|
||||||
fixed; the case now just needs writing. `docs/TESTING.md` has the layer-by-layer
|
background audio and the lock-screen transport. `docs/TESTING.md` has the layer-by-layer
|
||||||
detail, including why lint runs against a baseline.
|
detail, including why lint runs against a baseline.
|
||||||
|
|
||||||
Requires JDK 17. `build-android.sh` locates it, because a newer JVM does not
|
Requires JDK 17. `build-android.sh` locates it, because a newer JVM does not
|
||||||
@@ -110,6 +110,6 @@ ABIs, plus a source archive and revision record, under
|
|||||||
packages the staged libraries, and compiles `pstr_mpv`, the JNI/EGL adapter. It
|
packages the staged libraries, and compiles `pstr_mpv`, the JNI/EGL adapter. It
|
||||||
feeds libmpv through the Rust stream C ABI; decrypted bytes remain in native
|
feeds libmpv through the Rust stream C ABI; decrypted bytes remain in native
|
||||||
memory. `PlaybackService` owns the mpv core for background audio and media
|
memory. `PlaybackService` owns the mpv core for background audio and media
|
||||||
controls, while the activity supplies the current `Surface` and enters PiP.
|
controls, while the activity supplies the current `Surface`.
|
||||||
Do not publish an Android binary until this exact pipeline is verified on real
|
Do not publish an Android binary until this exact pipeline is verified on real
|
||||||
arm64 hardware and an x86_64 emulator.
|
arm64 hardware and an x86_64 emulator.
|
||||||
@@ -104,6 +104,36 @@ an edge case.
|
|||||||
holds only the id, display name and token.
|
holds only the id, display name and token.
|
||||||
- Nothing this app holds can write to the share: a viewer link grants
|
- Nothing this app holds can write to the share: a viewer link grants
|
||||||
`MemberRole::Viewer`, and the SDK's public-link write side is unported anyway.
|
`MemberRole::Viewer`, and the SDK's public-link write side is unported anyway.
|
||||||
|
- Signing in to a Proton account is optional (`pstr-core::account`). The
|
||||||
|
session — tokens, and per account key the passphrase that unlocks it,
|
||||||
|
derived at sign-in — is one blob in the same credential store, under
|
||||||
|
`proton-account`. The mailbox password itself is never stored. Only TOTP
|
||||||
|
second factors are spoken; an account whose only one is a security key is
|
||||||
|
refused with a reason before the code prompt. Refresh tokens are single-use, so
|
||||||
|
every refresh is written back. On the desktop the blob can outgrow one
|
||||||
|
credential entry — Windows caps one at 1280 UTF-16 units — so
|
||||||
|
`KeyringSecretStore` splits a long value across `key#0`, `key#1`, … under a
|
||||||
|
header in `key`. The account writes exactly one kind of thing: the
|
||||||
|
watch-history files on its own `proton-stream` device (`pstr-core::sync`),
|
||||||
|
including trashing the file of an installation quiet for 90 days once its
|
||||||
|
history is merged into this one's. Folders of the account added as shares
|
||||||
|
are only ever read.
|
||||||
|
- The same file carries the rest of what makes every device look alike
|
||||||
|
(`pstr-core::profile`): the shares, with each link and custom password so
|
||||||
|
another device can open them; the playback, appearance and metadata settings
|
||||||
|
except volume and mute; the TMDB key; hand-picked matches; per-show track
|
||||||
|
choices. Each is a register merged per key, newer wins, a removed share being
|
||||||
|
a register with no value. `sync-state.json` holds the registers as last
|
||||||
|
synced, and a sync stamps whatever differs from it, so nothing that writes a
|
||||||
|
setting has to know about sync. A first sync stamps what it finds at zero, so
|
||||||
|
a fresh install's defaults never outrank the library everyone else agreed on.
|
||||||
|
Automatic matches are not synced: every device with enrichment on finds them
|
||||||
|
again. The front end crawls what arrived (`uncrawled_shares` covers a sync
|
||||||
|
that ran in the background) and drops what was removed with its files.
|
||||||
|
- A CAPTCHA in front of a sign-in is shown in a webview, because the page
|
||||||
|
reports success by posting a message to its host. On the desktop that is a
|
||||||
|
child process of the app (`pstr-app::verify`, wry): WebKitGTK on Linux,
|
||||||
|
WebView2 on Windows. It may only navigate to `https://*.proton.me`.
|
||||||
- Signature verification is absent on the visitor path by design — an anonymous
|
- Signature verification is absent on the visitor path by design — an anonymous
|
||||||
visitor cannot read `core/v4/keys/all` to resolve a signer. Decryption still
|
visitor cannot read `core/v4/keys/all` to resolve a signer. Decryption still
|
||||||
proves the bytes were encrypted to the node key. Nodes come back with nothing
|
proves the bytes were encrypted to the node key. Nodes come back with nothing
|
||||||
@@ -118,7 +148,11 @@ an edge case.
|
|||||||
| Share list | config dir, `shares.json` | **No** — atomic writes, never overwritten when unparseable |
|
| Share list | config dir, `shares.json` | **No** — atomic writes, never overwritten when unparseable |
|
||||||
| Share secrets | OS credential store | **No** |
|
| Share secrets | OS credential store | **No** |
|
||||||
| 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 and removing the share; with an account, mirrored to its Drive |
|
||||||
|
| Proton session | OS credential store, `proton-account` | No — sign in again |
|
||||||
|
| Sync identity | config dir, `sync.json` | A new id if missing — the old history file is still read |
|
||||||
|
| Sync state | config dir, `sync-state.json` | Yes — a missing one is a first sync, which takes everything other devices have |
|
||||||
|
| Watch history and profile in Drive | device `proton-stream`, `watch-history/<id>.json` | One file per installation; merged per episode and per register, newer wins. Holds share links and passwords, end-to-end encrypted by Drive. A file unwritten for 90 days is retired by the next installation to sync, after merging it; a live installation whose file was retired writes it again |
|
||||||
| 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; stored scaled to 640 px |
|
| 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 |
|
| Provider artwork | cache dir, `posters/` | Yes — refetched by URL; stored scaled to 640 px |
|
||||||
|
|||||||
@@ -146,6 +146,80 @@ and [B44](#b44--r8-broke-the-app-under-instrumentation-in-five-places).
|
|||||||
|
|
||||||
## Fixed
|
## Fixed
|
||||||
|
|
||||||
|
### B64 — Android offline downloads looked stuck
|
||||||
|
|
||||||
|
**Symptom.** A queued episode, and more so a queued season, sat at a progress
|
||||||
|
bar that did not move for long stretches, then jumped.
|
||||||
|
|
||||||
|
**Cause.** Three, compounding. WorkManager starts every queued worker whose
|
||||||
|
constraints are met, so "Download season" was a dozen transfers at once, each
|
||||||
|
with a share of the link. Each read through the *playback* stream, so behind
|
||||||
|
every block it read sat twelve blocks of read-ahead competing for the same
|
||||||
|
bandwidth — and filling the shared memory ring and the disk block cache, a
|
||||||
|
second copy of every byte. And progress was reported once per completed
|
||||||
|
in-order block, so the first report waited on all of that. The desktop had none
|
||||||
|
of these: three downloads at most, a copy stream with no read-ahead, four
|
||||||
|
blocks in flight.
|
||||||
|
|
||||||
|
**Fix.** The Android engine allows two downloads to transfer at once; the rest
|
||||||
|
wait for a slot, reported as queued (the worker no longer marks itself running
|
||||||
|
before its first progress report, and its notification says "Waiting to
|
||||||
|
download"). Downloads open through `StreamSource::open_for_copy` and read with
|
||||||
|
`VideoStream::blocks_in_order` at `COPY_BLOCKS_IN_FLIGHT`, now shared with the
|
||||||
|
desktop engine. The rows show a smoothed transfer rate beside the bytes, so a
|
||||||
|
slow download reads as slow rather than stuck.
|
||||||
|
|
||||||
|
**Verified.** Workspace gate and the Android host check pass, with
|
||||||
|
`TransferRateTest` covering the rate. Not yet observed on a device.
|
||||||
|
|
||||||
|
### B63 — a taskbar pin on Windows lost its icon
|
||||||
|
|
||||||
|
**Symptom.** Proton Stream pinned to the Windows taskbar showed its icon at
|
||||||
|
first, then after a while — reliably after an update — the generic program
|
||||||
|
icon.
|
||||||
|
|
||||||
|
**Cause.** Two, each enough on its own. `proton-stream.exe` carried no icon
|
||||||
|
resource: the window sets one at runtime, which is what a pin shows while the
|
||||||
|
icon cache still holds it, but a pin made from the running window points at
|
||||||
|
the exe, and once the cache is rebuilt the exe has nothing to give. A pin made
|
||||||
|
from the Start Menu shortcut instead took the shortcut's `Icon`, which Windows
|
||||||
|
Installer keeps in `C:\Windows\Installer\{ProductCode}`. Every upgrade has a
|
||||||
|
new ProductCode and deletes the old product's folder, icon included.
|
||||||
|
|
||||||
|
**Fix.** `crates/pstr-app/build.rs` compiles `proton-stream.rc`, which embeds
|
||||||
|
`packaging/windows/proton-stream.ico`, into the GUI exe: `rc.exe` on MSVC,
|
||||||
|
mingw's `windres` on the cross build. Both installer definitions drop the
|
||||||
|
shortcut's `Icon` (and the WiX one `Advertise`), so the shortcut shows the
|
||||||
|
exe's icon too. A pin that already lost its icon keeps the dead reference;
|
||||||
|
unpin and pin again once.
|
||||||
|
|
||||||
|
**Verified.** `objdump -p` on the cross-built exe lists the `RT_ICON` entries,
|
||||||
|
all seven sizes. `msiinfo export … Shortcut` on a wixl build has an empty
|
||||||
|
`Icon_` column. Not yet checked on a Windows taskbar across an upgrade.
|
||||||
|
|
||||||
|
### B62 — an MSI upgrade left the app without libmpv
|
||||||
|
|
||||||
|
**Symptom.** Installing a new `.msi` over an old one ended with an app that
|
||||||
|
would not start for want of mpv. Installing the same `.msi` a second time
|
||||||
|
fixed it.
|
||||||
|
|
||||||
|
**Cause.** Both installers remove the old product early — the wixl one at
|
||||||
|
`RemoveExistingProducts` after `InstallInitialize`, WiX's `MajorUpgrade` by
|
||||||
|
default after `InstallValidate` — but costing runs before either, at
|
||||||
|
`CostFinalize`. Costing found the old `libmpv-2.dll` on disk with the same
|
||||||
|
version as the new one, since mpv is pinned, and decided not to copy it. Then
|
||||||
|
removing the old product deleted it. The second install found no DLL on disk,
|
||||||
|
so it copied one.
|
||||||
|
|
||||||
|
**Fix.** `REINSTALLMODE=amus` in both installer definitions, which copies every
|
||||||
|
file whatever is on disk. Every file is private to the install directory, so
|
||||||
|
the forced overwrite can downgrade nothing shared.
|
||||||
|
|
||||||
|
**Verified.** `msiinfo export … InstallExecuteSequence` on a wixl build shows
|
||||||
|
`FileCost` 900 and `CostFinalize` 1000 ahead of `RemoveExistingProducts` 1501,
|
||||||
|
and `msiinfo export … Property` has `REINSTALLMODE amus`. Not yet checked as an
|
||||||
|
upgrade on Windows.
|
||||||
|
|
||||||
### B61 — later seasons were captioned with season one's episode names
|
### B61 — later seasons were captioned with season one's episode names
|
||||||
|
|
||||||
**Symptom.** On a 42-title AniList library, Attack on Titan seasons 2–4 and
|
**Symptom.** On a 42-title AniList library, Attack on Titan seasons 2–4 and
|
||||||
|
|||||||
@@ -161,7 +161,10 @@ reads an external subtitle through the same protocol); the work is in
|
|||||||
## Design decisions already taken
|
## Design decisions already taken
|
||||||
|
|
||||||
- **Fat client, no server.** Each viewer's app talks to Proton directly.
|
- **Fat client, no server.** Each viewer's app talks to Proton directly.
|
||||||
- **Public links only**, but several, merged into one catalog. No account login.
|
- **Public links first**, several, merged into one catalog. A Proton account
|
||||||
|
is optional: it adds folders of the viewer's own Drive as shares, and watch
|
||||||
|
history synced through a `proton-stream` device in that Drive — one file per
|
||||||
|
installation, so no two devices ever write the same file.
|
||||||
- **Embedded libmpv**, not a bundled decoder stack. HEVC 10-bit, HDR and ASS
|
- **Embedded libmpv**, not a bundled decoder stack. HEVC 10-bit, HDR and ASS
|
||||||
subtitles with hardware decode, for free.
|
subtitles with hardware decode, for free.
|
||||||
- **No transcoding.** mpv demuxes and decodes natively; there is no remux or
|
- **No transcoding.** mpv demuxes and decodes natively; there is no remux or
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
# iOS Development
|
||||||
|
|
||||||
|
The iOS client lives in `ios/`. It is a SwiftUI port of the Android client, screen
|
||||||
|
for screen and string for string, over the same Rust engine: `crates/pstr-android`
|
||||||
|
builds unchanged for `aarch64-apple-ios` as a static library, and UniFFI writes its
|
||||||
|
Swift bindings (module `PstrBridge`). It targets iOS 17 and newer on iPhone and
|
||||||
|
iPad. Keep reusable behaviour in the Rust crates; when Android changes, the iOS
|
||||||
|
file of the same name is the one to change with it.
|
||||||
|
|
||||||
|
| Android | iOS |
|
||||||
|
|---|---|
|
||||||
|
| `ui/AppViewModel.kt` | `App/AppModel.swift` |
|
||||||
|
| `ui/ProtonStreamApp.kt` | `App/ProtonStreamApp.swift` (`RootView`) |
|
||||||
|
| `native/NativeRuntime.kt`, `native/KeystoreSecretStore.kt` | `App/NativeRuntime.swift`, `App/KeychainSecretStore.swift` |
|
||||||
|
| `settings/SettingsStore.kt` | `App/SettingsStore.swift` (same keys, in `UserDefaults`) |
|
||||||
|
| `ui/*Screen.kt`, `ui/AccountUi.kt`, `ui/LanguagePicker.kt` | `UI/*View.swift`, `UI/AccountUI.swift`, `UI/LanguagePicker.swift` |
|
||||||
|
| `ui/RemoteArtwork.kt`, `ui/theme/*` | `UI/RemoteArtwork.swift`, `UI/Theme/*` |
|
||||||
|
| `download/*` | `Download/*` |
|
||||||
|
| `playback/PlayerScreen.kt`, `NativeMpvHost.kt`, `PlaybackService.kt`, `StreamSession.kt` | `Player/PlayerView.swift`, `Player/PlayerHost.swift` |
|
||||||
|
| `cpp/pstr_mpv.cpp` | `Player/MpvPlayer.swift`, `Player/StreamProtocol.swift` |
|
||||||
|
|
||||||
|
Playback is libmpv from [MPVKit](https://github.com/mpvkit/MPVKit)'s GPL build,
|
||||||
|
rendering through MoltenVK into a `CAMetalLayer`, with the `pstr://` stream protocol
|
||||||
|
reading decrypted blocks straight out of Rust as on Android.
|
||||||
|
|
||||||
|
## Toolchain
|
||||||
|
|
||||||
|
macOS with Xcode 16 or newer, XcodeGen, and Rust 1.96 with the iOS targets:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew install xcodegen
|
||||||
|
rustup target add aarch64-apple-ios aarch64-apple-ios-sim
|
||||||
|
```
|
||||||
|
|
||||||
|
The Xcode project and `Info.plist` are generated from `ios/project.yml` and are not
|
||||||
|
committed, nor are the bindings under `ios/build/generated`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scripts/build-ios.sh test # simulator static library, bindings, unit tests
|
||||||
|
scripts/build-ios.sh all # device static library, bindings, dist/proton-stream-<version>-ios.ipa
|
||||||
|
```
|
||||||
|
|
||||||
|
`all` is `rust`, `bindings` and `ipa` in order; each can be run on its own. After
|
||||||
|
`bindings` and an `xcodegen generate` in `ios/`, the project opens in Xcode as usual,
|
||||||
|
where a run on a device needs a development team set on the target.
|
||||||
|
|
||||||
|
The `.ipa` is unsigned. SideStore and AltStore re-sign what they install with the
|
||||||
|
viewer's own Apple ID, which is also why the app has no extensions: a free Apple ID
|
||||||
|
gets few app IDs, and each extension costs one.
|
||||||
|
|
||||||
|
`.github/workflows/ios.yml` runs the tests and builds the `.ipa` on `macos-15` for
|
||||||
|
every push to `main` and every pull request that touches the iOS sources or the Rust
|
||||||
|
crates under them. `release.yml` calls it too, and publishes the `.ipa` next to the
|
||||||
|
other artifacts.
|
||||||
|
|
||||||
|
`scripts/render-ios-icon.sh` renders `ios/ProtonStream/Resources/AppIcon.svg`, which
|
||||||
|
is Android's adaptive launcher icon with both layers on one canvas, into the asset
|
||||||
|
catalog.
|
||||||
|
|
||||||
|
## Where iOS differs
|
||||||
|
|
||||||
|
Each of these is the platform's doing, not a choice to diverge.
|
||||||
|
|
||||||
|
- **No Picture-in-Picture.** libmpv draws into a Metal layer, and AVKit's
|
||||||
|
Picture-in-Picture only takes frames from `AVPlayerLayer` or
|
||||||
|
`AVSampleBufferDisplayLayer`. Background audio and the lock-screen transport work
|
||||||
|
as on Android.
|
||||||
|
- **No share sheet.** A share extension would cost a second app ID. A link reaches
|
||||||
|
the app through its URL scheme — `protonstream://add?url=<link>`, or the link with
|
||||||
|
`https` swapped for `protonstream` — or through **Paste link** in the Add share
|
||||||
|
form, which finds a link anywhere in the clipboard's text.
|
||||||
|
- **Downloads run while the app does.** A transfer is a Rust task, not a
|
||||||
|
`URLSession` task, so iOS has nothing to hand it to in the background. A download
|
||||||
|
keeps going for the grace iOS gives a backgrounded app, then stops at a block
|
||||||
|
boundary and is queued again; it resumes from its `.part` file when the app next
|
||||||
|
comes forward. "Download on Wi-Fi only" stops transfers on a cellular or
|
||||||
|
otherwise expensive network rather than holding them back.
|
||||||
|
- **Sync runs when the app leaves the screen**, inside the same grace, in
|
||||||
|
place of Android's `WatchSyncWorker`, and every five minutes while it is open.
|
||||||
|
- **Background audio off pauses** the episode when the app leaves the screen, where
|
||||||
|
Android stops it.
|
||||||
|
- **Presses are drawn by hand.** Compose's ripple shows every press; SwiftUI's
|
||||||
|
`.plain` button style and `onTapGesture` show none, which on a slow iPad reads as
|
||||||
|
a tap that missed. `PressableStyle` and `tapAndHold` (`UI/Theme/Accent.swift`)
|
||||||
|
stand in for the ripple, and a settings page goes back by an edge strip of its
|
||||||
|
own, as a title page does, where Android has the system back.
|
||||||
|
- **Nothing is backed up**, as on Android (`allowBackup="false"`). The share
|
||||||
|
secrets are this-device-only Keychain items, so a restored share list would
|
||||||
|
arrive without them, and a restored `sync.json` would give two devices one sync
|
||||||
|
identity.
|
||||||
@@ -255,10 +255,9 @@ three dispositions, and the third is the one to read:
|
|||||||
| `pending` | specified but not implemented. Never passes, and the summary reports the matrix as incomplete until it is gone |
|
| `pending` | specified but not implemented. Never passes, and the summary reports the matrix as incomplete until it is gone |
|
||||||
| `skip` | needs a share that was not configured |
|
| `skip` | needs a share that was not configured |
|
||||||
|
|
||||||
Today `process-recreation` is `xfail` against B35, and `picture-in-picture` is
|
Today `process-recreation` is `xfail` against B35 — asserting today's behaviour
|
||||||
`pending` against B40 — asserting today's behaviour would encode the bug rather
|
would encode the bug rather than catch it. The `picture-in-picture` case went
|
||||||
than catch it. **The matrix is not complete, and the runner says so on every
|
with Picture-in-Picture itself, so nothing is `pending`.
|
||||||
run.**
|
|
||||||
|
|
||||||
The five live cases need a share, given as environment variables that reach the
|
The five live cases need a share, given as environment variables that reach the
|
||||||
device as instrumentation arguments and are written nowhere:
|
device as instrumentation arguments and are written nowhere:
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# proton-stream for iOS license
|
||||||
|
|
||||||
|
SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
Copyright (c) proton-stream contributors.
|
||||||
|
|
||||||
|
The iOS application under this directory is free software: you may
|
||||||
|
redistribute it and/or modify it under the terms of the GNU General Public
|
||||||
|
License as published by the Free Software Foundation, either version 3 of the
|
||||||
|
License, or (at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||||
|
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. See the [GNU GPL version 3](https://www.gnu.org/licenses/gpl-3.0.html)
|
||||||
|
for the complete license text.
|
||||||
|
|
||||||
|
This licensing choice applies to the iOS application and its distribution,
|
||||||
|
including the combined native player. Rust crates outside `ios/` retain the
|
||||||
|
licenses declared in their manifests; the repository root `LICENSE` remains the
|
||||||
|
MIT license for those components.
|
||||||
@@ -1,140 +1,825 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Observation
|
import Observation
|
||||||
|
import UIKit
|
||||||
|
|
||||||
/// The app's one Rust engine and the screen state read from it.
|
/// The Proton account: where a sign-in stands, and how sync is doing.
|
||||||
|
struct AccountUiState: Equatable {
|
||||||
|
/// Nil until the bridge has said.
|
||||||
|
var state: AccountState?
|
||||||
|
/// A sign-in step is out and not answered yet.
|
||||||
|
var busy = false
|
||||||
|
/// Why the last sign-in step was refused.
|
||||||
|
var error: String?
|
||||||
|
/// The CAPTCHA page Proton wants solved before it accepts the sign-in.
|
||||||
|
var verificationUrl: String?
|
||||||
|
/// When watch history last synced.
|
||||||
|
var syncedAt: Date?
|
||||||
|
/// Positions the last sync took from other devices.
|
||||||
|
var applied = 0
|
||||||
|
/// Why the last sync failed, until one succeeds.
|
||||||
|
var syncError: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One episode's watch state as it was before a change the viewer can undo.
|
||||||
|
/// Nil `state` means it had never been played.
|
||||||
|
struct WatchSnapshot: Equatable {
|
||||||
|
let shareId: String
|
||||||
|
let linkId: String
|
||||||
|
let state: WatchStateRecord?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The app's screen state and every action on it: Android's `AppViewModel`,
|
||||||
|
/// field for field and message for message.
|
||||||
///
|
///
|
||||||
/// Rust owns the Proton sessions, SQLite and every decrypted byte; this only
|
/// Rust owns the Proton sessions, SQLite and every decrypted byte; this asks it
|
||||||
/// asks it questions off the main thread and keeps the answers for SwiftUI.
|
/// questions off the main thread and keeps the answers for SwiftUI.
|
||||||
@MainActor
|
@MainActor
|
||||||
@Observable
|
@Observable
|
||||||
final class AppModel {
|
final class AppModel {
|
||||||
let engine: AndroidEngine?
|
var loading = true
|
||||||
private(set) var startupError: String?
|
var refreshing = false
|
||||||
|
private(set) var query = ""
|
||||||
var titles: [TitleRecord] = []
|
var titles: [TitleRecord] = []
|
||||||
|
/// The grid: `titles` ordered, each franchise folded into one tile when
|
||||||
|
/// `grouped`, and the shelves above it. Nil until the bridge has answered,
|
||||||
|
/// when the grid falls back to `titles` as they are.
|
||||||
|
var arrangement: ArrangementRecord?
|
||||||
|
var sort: LibrarySort = .name
|
||||||
|
var grouped = true
|
||||||
var shares: [ShareRecord] = []
|
var shares: [ShareRecord] = []
|
||||||
var search = ""
|
var offline: [OfflineRecord] = []
|
||||||
var crawling = false
|
var metadataSettings = MetadataSettingsRecord(enabled: false, provider: .aniList, language: "en", ready: true)
|
||||||
var error: String?
|
var storage = StorageUsageRecord(offlineBytes: 0, offlineCount: 0, partialBytes: 0, cacheBytes: 0)
|
||||||
|
/// What the snackbar says.
|
||||||
|
var message: String?
|
||||||
|
/// What is running in the background right now, said in a banner for as
|
||||||
|
/// long as it runs: an add, a crawl, a match, a sync. Without it a
|
||||||
|
/// minute-long crawl looked like a tap that did nothing.
|
||||||
|
private(set) var activity: String?
|
||||||
|
/// What Undo on `message` puts back, when the message offers one.
|
||||||
|
var undo: [WatchSnapshot]?
|
||||||
|
var account = AccountUiState()
|
||||||
|
|
||||||
init() {
|
@ObservationIgnored private var searchTask: Task<Void, Never>?
|
||||||
do {
|
@ObservationIgnored private var started = false
|
||||||
engine = try AndroidEngine(paths: Self.paths(), secrets: KeychainSecretStore())
|
/// Launch and the first `.active` both ask; one crawl answers.
|
||||||
} catch {
|
@ObservationIgnored private var catchingUp = false
|
||||||
engine = nil
|
/// Every banner line whose work is still running, oldest first.
|
||||||
startupError = String(describing: error)
|
@ObservationIgnored private var activities: [(token: Int, line: String)] = []
|
||||||
|
@ObservationIgnored private var nextActivity = 0
|
||||||
|
|
||||||
|
/// Five minutes, as on Android: cheap when nothing changed — one folder
|
||||||
|
/// listing, and no upload.
|
||||||
|
private static let syncInterval: Duration = .seconds(5 * 60)
|
||||||
|
|
||||||
|
func start() {
|
||||||
|
if started { return }
|
||||||
|
started = true
|
||||||
|
reload()
|
||||||
|
enteredForeground()
|
||||||
|
DownloadCoordinator.shared.onFinished = { [weak self] in self?.reload() }
|
||||||
|
DownloadCoordinator.shared.resumeQueued()
|
||||||
|
Task {
|
||||||
|
let state = (try? await run { $0.accountState() }) ?? .signedOut
|
||||||
|
account.state = state
|
||||||
|
// While the app is alive, on top of after sign-in and whenever the
|
||||||
|
// player closes.
|
||||||
|
while !Task.isCancelled {
|
||||||
|
await syncWatchHistory()
|
||||||
|
try? await Task.sleep(for: Self.syncInterval)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Application Support survives updates and is backed up, which is what
|
|
||||||
/// the watch history wants. The block cache goes under Caches, which iOS
|
|
||||||
/// may purge and never backs up.
|
|
||||||
private static func paths() throws -> AndroidPaths {
|
|
||||||
let files = FileManager.default
|
|
||||||
let support = try files.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
|
||||||
let caches = try files.url(for: .cachesDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
|
||||||
let config = support.appendingPathComponent("config", isDirectory: true)
|
|
||||||
let data = support.appendingPathComponent("data", isDirectory: true)
|
|
||||||
let cache = caches.appendingPathComponent("proton-stream", isDirectory: true)
|
|
||||||
for directory in [config, data, cache] {
|
|
||||||
try files.createDirectory(at: directory, withIntermediateDirectories: true)
|
|
||||||
}
|
|
||||||
return AndroidPaths(config: config.path, data: data.path, cache: cache.path)
|
|
||||||
}
|
|
||||||
|
|
||||||
struct NoEngine: Error, CustomStringConvertible {
|
|
||||||
var description: String { "the Rust engine did not start" }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Run a blocking bridge call off the main thread. The synchronous exports
|
/// Run a blocking bridge call off the main thread. The synchronous exports
|
||||||
/// take the catalog lock and touch SQLite, which a scroll must not wait on.
|
/// take the catalog lock and touch SQLite, which a scroll must not wait on.
|
||||||
func run<T>(_ work: @escaping (AndroidEngine) throws -> T) async throws -> T {
|
nonisolated func run<T: Sendable>(_ work: @escaping @Sendable (AndroidEngine) throws -> T) async throws -> T {
|
||||||
guard let engine else { throw NoEngine() }
|
try await Task.detached(priority: .userInitiated) {
|
||||||
return try await Task.detached(priority: .userInitiated) { try work(engine) }.value
|
try work(try NativeRuntime.blockingEngine())
|
||||||
|
}.value
|
||||||
}
|
}
|
||||||
|
|
||||||
func report(_ error: Error) {
|
// MARK: - Library
|
||||||
self.error = String(describing: error)
|
|
||||||
|
/// Reorder the grid, or fold or unfold its franchises.
|
||||||
|
func arrange(_ sort: LibrarySort, _ grouped: Bool) {
|
||||||
|
self.sort = sort
|
||||||
|
self.grouped = grouped
|
||||||
|
Task { await reloadLibrary(query) }
|
||||||
}
|
}
|
||||||
|
|
||||||
func reload() async {
|
func search(_ query: String) {
|
||||||
let needle = search.trimmingCharacters(in: .whitespaces)
|
self.query = query
|
||||||
do {
|
searchTask?.cancel()
|
||||||
let (titles, shares) = try await run { engine in
|
searchTask = Task {
|
||||||
(try engine.library(search: needle.isEmpty ? nil : needle), try engine.shares())
|
try? await Task.sleep(for: .milliseconds(300))
|
||||||
|
if Task.isCancelled { return }
|
||||||
|
await reloadLibrary(query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func refresh() {
|
||||||
|
crawl(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recrawl one share rather than the whole library: a share whose link has
|
||||||
|
/// expired makes a whole-library refresh fail, and must not leave no way to
|
||||||
|
/// refresh the shares that still work.
|
||||||
|
func refreshShare(_ id: String) {
|
||||||
|
crawl(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func crawl(_ shareId: String?) {
|
||||||
|
Task { await crawlNow(shareId) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The crawl itself, for pull to refresh, which holds its spinner until
|
||||||
|
/// this returns.
|
||||||
|
func crawlNow(_ shareId: String?) async {
|
||||||
|
let name = shareId.flatMap { id in shares.first { $0.id == id }?.name }
|
||||||
|
await busy(name.map { "Reading \($0)…" } ?? "Refreshing the library…") {
|
||||||
|
message = nil
|
||||||
|
do {
|
||||||
|
try await NativeRuntime.engine().crawl(shareId: shareId)
|
||||||
|
} catch {
|
||||||
|
message = errorMessage(error)
|
||||||
}
|
}
|
||||||
self.titles = titles
|
}
|
||||||
self.shares = shares
|
await reloadNow()
|
||||||
} catch {
|
// Not awaited: pull to refresh lets go once the crawl is in, and the
|
||||||
report(error)
|
// banner carries the lookup.
|
||||||
|
Task { await matchAfterCrawl() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Look up whatever a crawl brought that has no answer yet: a new show, or
|
||||||
|
/// a new episode its stored guide cannot name. When everything already
|
||||||
|
/// has one the bridge asks nothing, so this is cheap after every crawl.
|
||||||
|
private func matchAfterCrawl() async {
|
||||||
|
if metadataSettings.enabled, metadataSettings.ready {
|
||||||
|
await match(force: false, quiet: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func crawl(shareId: String? = nil) async {
|
/// Run enrichment again. With `force` every title is looked up afresh,
|
||||||
guard let engine, !crawling else { return }
|
/// including ones that already matched.
|
||||||
crawling = true
|
func matchTitles(force: Bool) {
|
||||||
defer { crawling = false }
|
Task { await match(force: force) }
|
||||||
do {
|
|
||||||
try await engine.crawl(shareId: shareId)
|
|
||||||
} catch {
|
|
||||||
report(error)
|
|
||||||
}
|
|
||||||
await reload()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func addShare(name: String, url: String, password: String?) async {
|
/// With `quiet`, a run nobody asked for says nothing when it found
|
||||||
|
/// nothing to do.
|
||||||
|
private func match(force: Bool, quiet: Bool = false) async {
|
||||||
|
await busy("Looking up titles…") {
|
||||||
|
do {
|
||||||
|
let summary = try await NativeRuntime.engine().matchTitles(force: force)
|
||||||
|
let found = summary.matched + summary.unmatched + summary.failed + summary.episodes > 0
|
||||||
|
if !quiet || found {
|
||||||
|
message = Self.describe(summary)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
message = errorMessage(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Say `what` in the activity banner while `work` runs.
|
||||||
|
///
|
||||||
|
/// The banner shows the newest line still running. Work overlaps — a sync
|
||||||
|
/// starts during a crawl and ends after it — so each line is removed by
|
||||||
|
/// itself rather than by putting back whatever was showing before, which
|
||||||
|
/// once brought back a finished crawl's line and spun it forever.
|
||||||
|
private func busy(_ what: String, _ work: () async -> Void) async {
|
||||||
|
let token = nextActivity
|
||||||
|
nextActivity += 1
|
||||||
|
activities.append((token, what))
|
||||||
|
showActivity()
|
||||||
|
await work()
|
||||||
|
activities.removeAll { $0.token == token }
|
||||||
|
showActivity()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showActivity() {
|
||||||
|
activity = activities.last?.line
|
||||||
|
refreshing = activity != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Shares
|
||||||
|
|
||||||
|
/// Add a link, and return why it was refused, if it was.
|
||||||
|
///
|
||||||
|
/// The link is opened before anything is stored, so the form can stay up
|
||||||
|
/// with the reason under it and a retry is a retry — not "already in the
|
||||||
|
/// library" for a share that never opened. Only the new share is crawled:
|
||||||
|
/// another share that fails to open must not make this one look broken.
|
||||||
|
func addShare(name: String, url: String, password: String?) async -> String? {
|
||||||
|
let password = password.flatMap { $0.trimmingCharacters(in: .whitespaces).isEmpty ? nil : $0 }
|
||||||
|
let share: ShareRecord
|
||||||
do {
|
do {
|
||||||
let share = try await run { try $0.addShare(name: name, url: url, customPassword: password) }
|
share = try await NativeRuntime.engine().addShare(name: name, url: url, customPassword: password)
|
||||||
await crawl(shareId: share.id)
|
|
||||||
} catch {
|
} catch {
|
||||||
report(error)
|
return errorMessage(error)
|
||||||
|
}
|
||||||
|
reload()
|
||||||
|
Task { await crawlAdded([share.id], announce: true) }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Crawl shares that are new here — added on this device or by sync —
|
||||||
|
/// then match them when enrichment is on.
|
||||||
|
private func crawlAdded(_ ids: [String], announce: Bool) async {
|
||||||
|
guard !ids.isEmpty else { return }
|
||||||
|
let before = Set(titles.map(\.key))
|
||||||
|
var failed: [String] = []
|
||||||
|
for id in ids {
|
||||||
|
let name = shares.first { $0.id == id }?.name ?? "the new share"
|
||||||
|
await busy("Reading \(name)…") {
|
||||||
|
do {
|
||||||
|
try await NativeRuntime.engine().crawl(shareId: id)
|
||||||
|
} catch {
|
||||||
|
failed.append("\(name): \(errorMessage(error))")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await reloadNow()
|
||||||
|
if !failed.isEmpty {
|
||||||
|
message = failed.joined(separator: "\n")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await matchAfterCrawl()
|
||||||
|
if announce {
|
||||||
|
let added = Set(titles.map(\.key)).subtracting(before).count
|
||||||
|
message = added == 1 ? "1 title added" : "\(added) titles added"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func repairShare(id: String, url: String, password: String?) async {
|
/// Re-supply the link behind a share whose stored secret cannot be read.
|
||||||
do {
|
/// Re-entering the link rewrites the secret and leaves the catalog and the
|
||||||
_ = try await run { try $0.repairShare(shareId: id, url: url, customPassword: password) }
|
/// offline files alone.
|
||||||
await crawl(shareId: id)
|
func repairShare(id: String, url: String, password: String?) {
|
||||||
} catch {
|
Task {
|
||||||
report(error)
|
let name = shares.first { $0.id == id }?.name ?? "the share"
|
||||||
|
await busy("Reading \(name) again…") {
|
||||||
|
do {
|
||||||
|
let password = password.flatMap { $0.trimmingCharacters(in: .whitespaces).isEmpty ? nil : $0 }
|
||||||
|
_ = try await run { try $0.repairShare(shareId: id, url: url, customPassword: password) }
|
||||||
|
try await NativeRuntime.engine().crawl(shareId: id)
|
||||||
|
} catch {
|
||||||
|
reportError(error)
|
||||||
|
}
|
||||||
|
await reloadNow()
|
||||||
|
}
|
||||||
|
await matchAfterCrawl()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func removeShare(id: String) async {
|
func removeShare(_ id: String) {
|
||||||
do {
|
Task {
|
||||||
try await run { try $0.removeShare(shareId: id) }
|
let name = shares.first { $0.id == id }?.name ?? "the share"
|
||||||
} catch {
|
await busy("Removing \(name)…") {
|
||||||
report(error)
|
do {
|
||||||
|
// Wait for the transfers to stop before Rust removes catalog
|
||||||
|
// and files. Rust also rejects any late publication.
|
||||||
|
await DownloadCoordinator.shared.cancelShare(id)
|
||||||
|
let store = DownloadStateStore.shared
|
||||||
|
let engine = try await NativeRuntime.engine()
|
||||||
|
for download in store.records() where download.shareId == id {
|
||||||
|
try await engine.removeOfflineEpisode(shareId: download.shareId, linkId: download.linkId)
|
||||||
|
}
|
||||||
|
store.removeShare(id)
|
||||||
|
try await run { try $0.removeShare(shareId: id) }
|
||||||
|
} catch {
|
||||||
|
message = errorMessage(error)
|
||||||
|
}
|
||||||
|
await reloadNow()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
await reload()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Marking seen puts the position at the duration where one is known, and
|
func dismissMessage() {
|
||||||
/// unwatching rewinds, as on desktop and Android.
|
message = nil
|
||||||
func setWatched(_ episode: EpisodeRecord, _ watched: Bool) async {
|
undo = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Account
|
||||||
|
|
||||||
|
/// Start signing in, or start again with `verificationToken` once the
|
||||||
|
/// CAPTCHA from a first try is solved.
|
||||||
|
func signIn(_ username: String, _ password: String, verificationToken: String? = nil) {
|
||||||
|
signInStep { try await $0.signIn(username: username, password: password, verificationToken: verificationToken) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func submitSecondFactor(_ code: String) {
|
||||||
|
signInStep { try await $0.submitSecondFactor(code: code) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func submitMailboxPassword(_ password: String) {
|
||||||
|
signInStep(keepStepOnFailure: true) { try await $0.submitMailboxPassword(password: password) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancelSignIn() {
|
||||||
|
account = AccountUiState(state: .signedOut)
|
||||||
|
Task { try? await run { $0.cancelSignIn() } }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The CAPTCHA page closed without a token.
|
||||||
|
func dismissVerification() {
|
||||||
|
account.verificationUrl = nil
|
||||||
|
account.busy = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func signInStep(keepStepOnFailure: Bool = false, _ step: @escaping (AndroidEngine) async throws -> SignInOutcome) {
|
||||||
|
account.busy = true
|
||||||
|
account.error = nil
|
||||||
|
account.verificationUrl = nil
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let outcome = try await step(NativeRuntime.engine())
|
||||||
|
switch outcome {
|
||||||
|
case let .done(username):
|
||||||
|
account = AccountUiState(state: .signedIn(username: username))
|
||||||
|
message = "Signed in as \(username)"
|
||||||
|
await syncWatchHistory()
|
||||||
|
reload()
|
||||||
|
case .secondFactor:
|
||||||
|
account.state = .secondFactor
|
||||||
|
account.busy = false
|
||||||
|
case .mailboxPassword:
|
||||||
|
account.state = .mailboxPassword
|
||||||
|
account.busy = false
|
||||||
|
case let .humanVerification(url):
|
||||||
|
account.verificationUrl = url
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
account.busy = false
|
||||||
|
account.error = errorMessage(error)
|
||||||
|
// A refused code spends the half-made session.
|
||||||
|
if !keepStepOnFailure { account.state = .signedOut }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func signOut() {
|
||||||
|
Task {
|
||||||
|
await busy("Signing out…") {
|
||||||
|
do {
|
||||||
|
try await NativeRuntime.engine().signOut()
|
||||||
|
} catch {
|
||||||
|
reportError(error)
|
||||||
|
}
|
||||||
|
account = AccountUiState(state: .signedOut)
|
||||||
|
await reloadNow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sync now, from a tap: says so when it is done.
|
||||||
|
func syncNow() {
|
||||||
|
Task {
|
||||||
|
var synced = false
|
||||||
|
await busy("Syncing with your other devices…") { synced = await syncWatchHistory() }
|
||||||
|
guard synced else { return }
|
||||||
|
let applied = account.applied
|
||||||
|
message = applied > 0 ? "Synced, \(applied) positions from other devices" : "Everything is up to date"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pull other devices' positions, shares and settings in and push this
|
||||||
|
/// one's out. Quiet: it runs on a timer, and a failure is shown on the
|
||||||
|
/// account card rather than as a snackbar every five minutes of an offline
|
||||||
|
/// evening. Returns whether it synced.
|
||||||
|
@discardableResult
|
||||||
|
func syncWatchHistory() async -> Bool {
|
||||||
|
guard case .signedIn = account.state else { return false }
|
||||||
do {
|
do {
|
||||||
try await run { engine in
|
guard let report = try await NativeRuntime.engine().syncWatchHistory() else { return false }
|
||||||
let duration = try engine.watchState(shareId: episode.shareId, linkId: episode.linkId)?.durationSecs
|
account.syncedAt = Date()
|
||||||
try engine.saveWatchState(
|
account.applied = Int(report.applied)
|
||||||
shareId: episode.shareId, linkId: episode.linkId,
|
account.syncError = nil
|
||||||
positionSecs: watched ? duration ?? 0 : 0,
|
await takeSynced(report)
|
||||||
durationSecs: duration, watched: watched
|
return true
|
||||||
|
} catch {
|
||||||
|
let state = try? await run { $0.accountState() }
|
||||||
|
if let state { account.state = state }
|
||||||
|
account.syncError = errorMessage(error)
|
||||||
|
// Signed out by the engine: the session ended, and that is worth
|
||||||
|
// interrupting for, unlike a dropped connection.
|
||||||
|
if state == .signedOut { reportError(error) }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What another device changed: its shares crawled or dropped here, its
|
||||||
|
/// settings repainted.
|
||||||
|
private func takeSynced(_ report: SyncRecord) async {
|
||||||
|
for id in report.sharesRemoved {
|
||||||
|
await DownloadCoordinator.shared.cancelShare(id)
|
||||||
|
DownloadStateStore.shared.removeShare(id)
|
||||||
|
}
|
||||||
|
if report.settingsChanged {
|
||||||
|
await AppearanceState.shared.reload()
|
||||||
|
}
|
||||||
|
if report.applied > 0 || report.titles > 0 || report.settingsChanged || !report.sharesRemoved.isEmpty {
|
||||||
|
await reloadNow()
|
||||||
|
}
|
||||||
|
// A crawl takes longer than the grace iOS gives a backgrounded app.
|
||||||
|
// What is left uncrawled is picked up on the way back.
|
||||||
|
if !report.sharesAdded.isEmpty, UIApplication.shared.applicationState != .background {
|
||||||
|
await crawlAdded(report.sharesAdded, announce: false)
|
||||||
|
message = report.sharesAdded.count == 1 ? "A share arrived from another device" : "\(report.sharesAdded.count) shares arrived from other devices"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// On launch and back on screen: crawl shares the catalog has nothing of —
|
||||||
|
/// brought by a sync that ran in the background, or an add whose crawl
|
||||||
|
/// never finished.
|
||||||
|
func enteredForeground() {
|
||||||
|
guard !catchingUp else { return }
|
||||||
|
catchingUp = true
|
||||||
|
Task {
|
||||||
|
defer { catchingUp = false }
|
||||||
|
guard let ids = try? await run({ try $0.uncrawledShares() }), !ids.isEmpty else { return }
|
||||||
|
await crawlAdded(ids, announce: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Leaving the app: push where playback stopped, inside the grace iOS
|
||||||
|
/// gives a backgrounded app — what `WatchSyncWorker` does on Android.
|
||||||
|
func syncInBackground() {
|
||||||
|
guard case .signedIn = account.state else { return }
|
||||||
|
let task = BackgroundTask(name: "watch-history-sync")
|
||||||
|
Task {
|
||||||
|
await syncWatchHistory()
|
||||||
|
task.end()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// After the player closes: where it stopped is what another device wants next.
|
||||||
|
func playerClosed() {
|
||||||
|
reload()
|
||||||
|
Task { await syncWatchHistory() }
|
||||||
|
}
|
||||||
|
|
||||||
|
func addAccountFolder(name: String, volumeId: String, linkId: String) {
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let share = try await run { try $0.addAccountFolder(name: name, volumeId: volumeId, linkId: linkId) }
|
||||||
|
reload()
|
||||||
|
await crawlAdded([share.id], announce: true)
|
||||||
|
} catch {
|
||||||
|
reportError(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Watch state
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
func setTitleWatched(_ title: TitleRecord, _ watched: Bool) {
|
||||||
|
showWatched(title.playlist, watched)
|
||||||
|
changeWatch(title.playlist, "\(title.displayName) marked \(watched ? "watched" : "unwatched")") { engine, episode, before in
|
||||||
|
let duration = before?.durationSecs
|
||||||
|
try engine.saveWatchState(
|
||||||
|
shareId: episode.shareId,
|
||||||
|
linkId: episode.linkId,
|
||||||
|
positionSecs: watched ? duration ?? 0 : 0,
|
||||||
|
durationSecs: duration,
|
||||||
|
watched: watched
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Off Continue watching by forgetting where the episode stopped — what the
|
||||||
|
/// desktop does — with an Undo that puts the position back.
|
||||||
|
func forgetPosition(_ title: TitleRecord, _ episode: EpisodeRecord) {
|
||||||
|
changeWatch([episode], "\(title.displayName) removed from Continue watching") { engine, target, before in
|
||||||
|
try engine.saveWatchState(shareId: target.shareId, linkId: target.linkId, positionSecs: 0, durationSecs: before?.durationSecs, watched: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Off the history page: the position and the watched mark both go, which
|
||||||
|
/// is "never played", with an Undo.
|
||||||
|
func removeFromHistory(_ title: TitleRecord, _ episode: EpisodeRecord) {
|
||||||
|
changeWatch([episode], "\(episode.label) of \(title.displayName) removed from history") { engine, target, before in
|
||||||
|
try engine.saveWatchState(shareId: target.shareId, linkId: target.linkId, positionSecs: 0, durationSecs: before?.durationSecs, watched: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Put back what the last undoable change replaced.
|
||||||
|
func performUndo() {
|
||||||
|
guard let snapshots = undo else { return }
|
||||||
|
message = nil
|
||||||
|
undo = nil
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
try await run { engine in
|
||||||
|
for snapshot in snapshots {
|
||||||
|
try engine.saveWatchState(
|
||||||
|
shareId: snapshot.shareId,
|
||||||
|
linkId: snapshot.linkId,
|
||||||
|
positionSecs: snapshot.state?.positionSecs ?? 0,
|
||||||
|
durationSecs: snapshot.state?.durationSecs,
|
||||||
|
watched: snapshot.state?.watched ?? false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
reportError(error)
|
||||||
|
}
|
||||||
|
reload()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func changeWatch(
|
||||||
|
_ episodes: [EpisodeRecord],
|
||||||
|
_ text: String,
|
||||||
|
_ change: @escaping @Sendable (AndroidEngine, EpisodeRecord, WatchStateRecord?) throws -> Void
|
||||||
|
) {
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let snapshots = try await run { engine in
|
||||||
|
try episodes.map { episode in
|
||||||
|
let before = try engine.watchState(shareId: episode.shareId, linkId: episode.linkId)
|
||||||
|
try change(engine, episode, before)
|
||||||
|
return WatchSnapshot(shareId: episode.shareId, linkId: episode.linkId, state: before)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
message = text
|
||||||
|
undo = snapshots
|
||||||
|
} catch {
|
||||||
|
reportError(error)
|
||||||
|
}
|
||||||
|
reload()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func reportError(_ error: Error) {
|
||||||
|
message = errorMessage(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func report(_ text: String) {
|
||||||
|
message = text
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Metadata
|
||||||
|
|
||||||
|
/// Store the enrichment settings, and return why not, if they were not.
|
||||||
|
///
|
||||||
|
/// The page shows the new settings as soon as they are stored; the match
|
||||||
|
/// run that follows can take minutes and says so in the activity banner.
|
||||||
|
/// Before, the page only changed once that run had finished, so a save
|
||||||
|
/// looked like it had done nothing.
|
||||||
|
func saveMetadataSettings(enabled: Bool, provider: MetadataProvider, language: String, apiKey: String) async -> String? {
|
||||||
|
do {
|
||||||
|
metadataSettings = try await run { engine in
|
||||||
|
if provider == .tmdb, !apiKey.trimmingCharacters(in: .whitespaces).isEmpty {
|
||||||
|
try engine.setMetadataApiKey(provider: provider, key: apiKey)
|
||||||
|
}
|
||||||
|
try engine.setMetadataSettings(settings: MetadataSettingsRecord(enabled: enabled, provider: provider, language: language, ready: true))
|
||||||
|
return try engine.metadataSettings()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return errorMessage(error)
|
||||||
|
}
|
||||||
|
await reloadNow()
|
||||||
|
if enabled {
|
||||||
|
Task { await match(force: false) }
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func reloadAfterMetadataChange() { reload() }
|
||||||
|
|
||||||
|
/// What an enrichment pass did, in one line. Failures are reported even
|
||||||
|
/// when most titles matched.
|
||||||
|
static func describe(_ summary: MatchSummary) -> String {
|
||||||
|
if summary.matched == 0, summary.unmatched == 0, summary.failed == 0 {
|
||||||
|
return "Everything is already matched"
|
||||||
|
}
|
||||||
|
var parts = ["\(summary.matched) matched"]
|
||||||
|
if summary.unmatched > 0 { parts.append("\(summary.unmatched) not found") }
|
||||||
|
if summary.episodes > 0 { parts.append("\(summary.episodes) episodes named") }
|
||||||
|
if summary.failed > 0 { parts.append("\(summary.failed) failed") }
|
||||||
|
return parts.joined(separator: ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Offline
|
||||||
|
|
||||||
|
func removeOffline(_ file: OfflineRecord) {
|
||||||
|
Task {
|
||||||
|
await busy("Deleting the download…") {
|
||||||
|
do {
|
||||||
|
try await NativeRuntime.engine().removeOfflineEpisode(shareId: file.shareId, linkId: file.linkId)
|
||||||
|
} catch {
|
||||||
|
message = errorMessage(error)
|
||||||
|
}
|
||||||
|
await reloadNow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func pauseDownload(_ download: RetainedDownload) { DownloadCoordinator.shared.pause(download) }
|
||||||
|
|
||||||
|
func resumeDownload(_ download: RetainedDownload) { DownloadCoordinator.shared.resume(download) }
|
||||||
|
|
||||||
|
func deletePartial(_ download: RetainedDownload) {
|
||||||
|
Task {
|
||||||
|
await DownloadCoordinator.shared.cancelAndWait(download)
|
||||||
|
do {
|
||||||
|
try await NativeRuntime.engine().removeOfflineEpisode(shareId: download.shareId, linkId: download.linkId)
|
||||||
|
DownloadStateStore.shared.remove(download.shareId, download.linkId)
|
||||||
|
} catch {
|
||||||
|
reportError(error)
|
||||||
|
}
|
||||||
|
reload()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeAllOffline() {
|
||||||
|
Task {
|
||||||
|
await busy("Deleting every download…") {
|
||||||
|
await DownloadCoordinator.shared.cancelAll()
|
||||||
|
do {
|
||||||
|
try await NativeRuntime.engine().removeAllOffline()
|
||||||
|
DownloadStateStore.shared.clear()
|
||||||
|
} catch {
|
||||||
|
reportError(error)
|
||||||
|
}
|
||||||
|
await reloadNow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearBlockCache() {
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let reclaimed = try await run { try $0.clearBlockCache() }
|
||||||
|
message = "Reclaimed \(formatBytes(reclaimed)) of cache"
|
||||||
|
} catch {
|
||||||
|
reportError(error)
|
||||||
|
}
|
||||||
|
reload()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Progress
|
||||||
|
|
||||||
|
/// Persist where an episode was left. Runs on its own task rather than the
|
||||||
|
/// player's, so a save issued on the way out is not cancelled by the
|
||||||
|
/// player going away.
|
||||||
|
func saveProgress(_ episode: EpisodeRecord, position: Double, duration: Double, watched: Bool) {
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
try await run { engine in
|
||||||
|
try engine.saveWatchState(
|
||||||
|
shareId: episode.shareId,
|
||||||
|
linkId: episode.linkId,
|
||||||
|
positionSecs: min(max(position, 0), duration),
|
||||||
|
durationSecs: duration,
|
||||||
|
watched: watched
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
reportError(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark an episode seen, or unseen, without playing it.
|
||||||
|
///
|
||||||
|
/// Unwatching rewinds, matching the desktop client. Marking one seen puts
|
||||||
|
/// the position at its duration where one is known, so the progress bar
|
||||||
|
/// agrees with the tick.
|
||||||
|
func setWatched(_ episode: EpisodeRecord, _ watched: Bool) {
|
||||||
|
showWatched([episode], watched)
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
try await run { engine in
|
||||||
|
let duration = try engine.watchState(shareId: episode.shareId, linkId: episode.linkId)?.durationSecs
|
||||||
|
try engine.saveWatchState(
|
||||||
|
shareId: episode.shareId,
|
||||||
|
linkId: episode.linkId,
|
||||||
|
positionSecs: watched ? duration ?? 0 : 0,
|
||||||
|
durationSecs: duration,
|
||||||
|
watched: watched
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
reportError(error)
|
||||||
|
}
|
||||||
|
reload()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw a watched mark the moment it is tapped, before the bridge has
|
||||||
|
/// stored it. The reload that shows the stored state is a second away on
|
||||||
|
/// an old iPad, and a tick that does not appear gets tapped again. That
|
||||||
|
/// reload still follows, and puts back whatever was really stored.
|
||||||
|
private func showWatched(_ targets: [EpisodeRecord], _ watched: Bool) {
|
||||||
|
let keys = Set(targets.map { "\($0.shareId)/\($0.linkId)" })
|
||||||
|
titles = titles.map { title in
|
||||||
|
guard title.playlist.contains(where: { keys.contains("\($0.shareId)/\($0.linkId)") }) else { return title }
|
||||||
|
var title = title
|
||||||
|
title.seasons = title.seasons.map { season in
|
||||||
|
var season = season
|
||||||
|
season.episodes = season.episodes.map { episode in
|
||||||
|
guard keys.contains("\(episode.shareId)/\(episode.linkId)") else { return episode }
|
||||||
|
var episode = episode
|
||||||
|
episode.watched = watched
|
||||||
|
if !watched {
|
||||||
|
episode.progress = nil
|
||||||
|
episode.resumeAt = nil
|
||||||
|
}
|
||||||
|
return episode
|
||||||
|
}
|
||||||
|
return season
|
||||||
|
}
|
||||||
|
title.watchedCount = UInt64(title.playlist.filter(\.watched).count)
|
||||||
|
return title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Reload
|
||||||
|
|
||||||
|
func reload() {
|
||||||
|
Task { await reloadNow() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `reload`, for a caller that goes on to read what it loaded.
|
||||||
|
func reloadNow() async {
|
||||||
|
let query = query.trimmingCharacters(in: .whitespaces).isEmpty ? nil : self.query
|
||||||
|
let sort = sort
|
||||||
|
let grouped = grouped
|
||||||
|
do {
|
||||||
|
let result = try await run { engine in
|
||||||
|
// Here rather than inside `library()`, which is read-only
|
||||||
|
// and cached: a full reload is the one place that has
|
||||||
|
// already paid for a walk of the offline files.
|
||||||
|
_ = try engine.pruneOfflineFiles()
|
||||||
|
return Reloaded(
|
||||||
|
shares: try engine.shares(),
|
||||||
|
titles: try engine.library(search: query),
|
||||||
|
arrangement: try engine.arrangement(search: query, sort: sort, grouped: grouped),
|
||||||
|
offline: try engine.offlineFiles(),
|
||||||
|
metadataSettings: try engine.metadataSettings(),
|
||||||
|
storage: try engine.storageUsage()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
loading = false
|
||||||
|
shares = result.shares
|
||||||
|
titles = result.titles
|
||||||
|
arrangement = result.arrangement
|
||||||
|
offline = result.offline
|
||||||
|
metadataSettings = result.metadataSettings
|
||||||
|
storage = result.storage
|
||||||
} catch {
|
} catch {
|
||||||
report(error)
|
loading = false
|
||||||
|
message = errorMessage(error)
|
||||||
}
|
}
|
||||||
await reload()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Episodes started but not finished, most recent first.
|
private func reloadLibrary(_ query: String) async {
|
||||||
var continueWatching: [(title: TitleRecord, episode: EpisodeRecord)] {
|
let search = query.trimmingCharacters(in: .whitespaces).isEmpty ? nil : query
|
||||||
titles
|
let sort = sort
|
||||||
.flatMap { title in
|
let grouped = grouped
|
||||||
title.seasons.flatMap(\.episodes)
|
do {
|
||||||
.filter { $0.resumeAt != nil && !$0.watched }
|
let (titles, arrangement) = try await run { engine in
|
||||||
.map { (title: title, episode: $0) }
|
(try engine.library(search: search), try engine.arrangement(search: search, sort: sort, grouped: grouped))
|
||||||
}
|
}
|
||||||
.sorted { $0.episode.lastPlayed > $1.episode.lastPlayed }
|
self.titles = titles
|
||||||
|
self.arrangement = arrangement
|
||||||
|
} catch {
|
||||||
|
message = errorMessage(error)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The grace iOS gives an app leaving the screen, ended exactly once —
|
||||||
|
/// whether by the work finishing or by the time running out.
|
||||||
|
@MainActor
|
||||||
|
final class BackgroundTask {
|
||||||
|
private var identifier = UIBackgroundTaskIdentifier.invalid
|
||||||
|
|
||||||
|
init(name: String) {
|
||||||
|
identifier = UIApplication.shared.beginBackgroundTask(withName: name) { [weak self] in
|
||||||
|
MainActor.assumeIsolated { self?.end() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func end() {
|
||||||
|
guard identifier != .invalid else { return }
|
||||||
|
UIApplication.shared.endBackgroundTask(identifier)
|
||||||
|
identifier = .invalid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct Reloaded: @unchecked Sendable {
|
||||||
|
let shares: [ShareRecord]
|
||||||
|
let titles: [TitleRecord]
|
||||||
|
let arrangement: ArrangementRecord
|
||||||
|
let offline: [OfflineRecord]
|
||||||
|
let metadataSettings: MetadataSettingsRecord
|
||||||
|
let storage: StorageUsageRecord
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// 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 let shareLinkPattern = #"https://drive\.proton\.me/urls/[^\s"'<>()\[\]]+"#
|
||||||
|
|
||||||
|
/// The first share link in `text`, if any — for the clipboard.
|
||||||
|
func shareLink(in text: String) -> String? {
|
||||||
|
text.range(of: shareLinkPattern, options: .regularExpression).map { String(text[$0]) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The share link a URL opened in this app carries, if any.
|
||||||
|
///
|
||||||
|
/// iOS hands an app no `https://drive.proton.me` link — Universal Links belong
|
||||||
|
/// to the domain's owner — so a link arrives through the app's own scheme:
|
||||||
|
/// `protonstream://add?url=<link>`, or the link itself with its scheme swapped,
|
||||||
|
/// `protonstream://drive.proton.me/urls/…#…`.
|
||||||
|
func shareLink(from url: URL) -> String? {
|
||||||
|
guard url.scheme?.lowercased() == "protonstream" else { return nil }
|
||||||
|
if url.host == "add" {
|
||||||
|
let value = URLComponents(url: url, resolvingAgainstBaseURL: false)?
|
||||||
|
.queryItems?.first { $0.name == "url" }?.value
|
||||||
|
return value.flatMap(wholeShareLink)
|
||||||
|
}
|
||||||
|
let text = url.absoluteString
|
||||||
|
let rest = text[text.index(text.startIndex, offsetBy: "protonstream://".count)...]
|
||||||
|
return wholeShareLink("https://\(rest)")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `value` when the whole of it is a share link — what `ACTION_VIEW` accepts on
|
||||||
|
/// Android — rather than a link somewhere inside it.
|
||||||
|
private func wholeShareLink(_ value: String) -> String? {
|
||||||
|
guard let range = value.range(of: shareLinkPattern, options: .regularExpression),
|
||||||
|
range == value.startIndex ..< value.endIndex
|
||||||
|
else { return nil }
|
||||||
|
return value
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Process-wide owner of the Rust engine; records and decrypted media remain
|
||||||
|
/// app-private. The counterpart of Android's `NativeRuntime`.
|
||||||
|
enum NativeRuntime {
|
||||||
|
private static let lock = NSLock()
|
||||||
|
private static var instance: AndroidEngine?
|
||||||
|
|
||||||
|
/// The engine, built on first use off the main thread: construction opens
|
||||||
|
/// SQLite, spins a Tokio runtime and reads the Keychain.
|
||||||
|
static func engine() async throws -> AndroidEngine {
|
||||||
|
try await Task.detached(priority: .userInitiated) { try blockingEngine() }.value
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The engine from a thread that may block — a bridge callback, a
|
||||||
|
/// background task. Never call this on the main thread.
|
||||||
|
static func blockingEngine() throws -> AndroidEngine {
|
||||||
|
try lock.withLock {
|
||||||
|
if let instance { return instance }
|
||||||
|
let engine = try AndroidEngine(paths: try paths(), secrets: KeychainSecretStore())
|
||||||
|
// The budget is this app's setting, so the engine starts on the
|
||||||
|
// default and is told the chosen one before anything streams.
|
||||||
|
engine.setStreamCacheBudget(bytes: SettingsStore.gibToBytes(SettingsStore().cacheBudgetGib))
|
||||||
|
instance = engine
|
||||||
|
return engine
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The stored palette, without building the engine.
|
||||||
|
///
|
||||||
|
/// Callable from the main thread, and meant to be: it is the only palette
|
||||||
|
/// read early enough to decide what the first frame is painted in, and
|
||||||
|
/// `engine()` is not. This reads one small JSON file and resolves it.
|
||||||
|
static func initialPalette() -> PaletteRecord? {
|
||||||
|
guard let paths = try? paths() else { return nil }
|
||||||
|
return storedPalette(paths: paths)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Application Support for the catalog and the configuration, Caches for
|
||||||
|
/// the block cache, which iOS may purge.
|
||||||
|
///
|
||||||
|
/// Neither the catalog nor the configuration is backed up, as on Android
|
||||||
|
/// (`allowBackup="false"`): the share secrets live in the Keychain as
|
||||||
|
/// this-device-only items, so a restored share list would arrive without
|
||||||
|
/// them, and a restored `sync.json` would give two phones one sync identity
|
||||||
|
/// writing one file — the case `pstr-core::sync` exists to prevent.
|
||||||
|
private static func paths() throws -> AndroidPaths {
|
||||||
|
let files = FileManager.default
|
||||||
|
let support = try files.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
||||||
|
let caches = try files.url(for: .cachesDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
||||||
|
var config = support.appendingPathComponent("config", isDirectory: true)
|
||||||
|
var data = support.appendingPathComponent("data", isDirectory: true)
|
||||||
|
let cache = caches.appendingPathComponent("stream", isDirectory: true)
|
||||||
|
for directory in [config, data, cache] {
|
||||||
|
try files.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||||
|
}
|
||||||
|
var excluded = URLResourceValues()
|
||||||
|
excluded.isExcludedFromBackup = true
|
||||||
|
try config.setResourceValues(excluded)
|
||||||
|
try data.setResourceValues(excluded)
|
||||||
|
return AndroidPaths(config: config.path, data: data.path, cache: cache.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a bridge failure says, without Swift's type name around it.
|
||||||
|
///
|
||||||
|
/// `BridgeError`'s own description is `String(reflecting:)`, which reads
|
||||||
|
/// `PstrBridge.BridgeError.Failure(reason: "…")` in a snackbar.
|
||||||
|
func errorMessage(_ error: Error) -> String {
|
||||||
|
if case let BridgeError.Failure(reason) = error { return reason }
|
||||||
|
let text = error.localizedDescription
|
||||||
|
return text.isEmpty ? "Unexpected error" : text
|
||||||
|
}
|
||||||
@@ -1,48 +1,370 @@
|
|||||||
import AVFoundation
|
import AVFoundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct ProtonStreamApp: App {
|
struct ProtonStreamApp: App {
|
||||||
|
@UIApplicationDelegateAdaptor(AppDelegate.self) private var delegate
|
||||||
@State private var model = AppModel()
|
@State private var model = AppModel()
|
||||||
|
@State private var appearance = AppearanceState.shared
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
// `.playback` keeps the audio going with the screen locked or the app
|
// `.playback` keeps the audio going with the screen locked or the app
|
||||||
// in the background, which `UIBackgroundModes: audio` then permits.
|
// in the background, which `UIBackgroundModes: audio` then permits.
|
||||||
try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback)
|
try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback)
|
||||||
|
// The palette read before the first frame, so the first frame is
|
||||||
|
// already painted in it.
|
||||||
|
AppearanceState.shared.seed(NativeRuntime.initialPalette())
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
|
let scheme = appearance.scheme
|
||||||
RootView()
|
RootView()
|
||||||
.environment(model)
|
.environment(model)
|
||||||
.preferredColorScheme(.dark)
|
.environment(\.scheme, scheme)
|
||||||
|
.preferredColorScheme(scheme.colorScheme)
|
||||||
|
.tint(scheme.primary)
|
||||||
|
.font(.inter(.bodyLarge))
|
||||||
|
.background(scheme.background.ignoresSafeArea())
|
||||||
|
.task { await appearance.load() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class AppDelegate: NSObject, UIApplicationDelegate {
|
||||||
|
func application(_: UIApplication, supportedInterfaceOrientationsFor _: UIWindow?) -> UIInterfaceOrientationMask {
|
||||||
|
MainActor.assumeIsolated { OrientationLock.mask }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// From this width the library and an open title share the window.
|
||||||
|
private let twoPaneWidth: CGFloat = 840
|
||||||
|
|
||||||
|
/// From this width the destinations are a rail down the side, not a bar.
|
||||||
|
private let railWidth: CGFloat = 600
|
||||||
|
|
||||||
|
/// How much room the floating mini transport needs at the foot of a page.
|
||||||
|
private let miniTransportInset: CGFloat = 88
|
||||||
|
|
||||||
|
enum Destination: String, CaseIterable {
|
||||||
|
case library, history, shares, downloads, settings
|
||||||
|
|
||||||
|
var label: String {
|
||||||
|
switch self {
|
||||||
|
case .library: "Library"
|
||||||
|
case .history: "History"
|
||||||
|
case .shares: "Shares"
|
||||||
|
case .downloads: "Downloads"
|
||||||
|
case .settings: "Settings"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var icon: String {
|
||||||
|
switch self {
|
||||||
|
case .library: "play.rectangle.on.rectangle.fill"
|
||||||
|
case .history: "clock.arrow.circlepath"
|
||||||
|
case .shares: "square.and.arrow.up"
|
||||||
|
case .downloads: "arrow.down.circle"
|
||||||
|
case .settings: "gearshape"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct RootView: View {
|
struct RootView: View {
|
||||||
@Environment(AppModel.self) private var model
|
@Environment(AppModel.self) private var model
|
||||||
|
@Environment(\.scheme) private var scheme
|
||||||
|
@Environment(\.scenePhase) private var phase
|
||||||
|
// Keys, not records: what has to survive is *which* title, and a record is
|
||||||
|
// no longer current after the library reloads. Everything else is derived,
|
||||||
|
// so a reload that renames a season updates the open screen.
|
||||||
|
@SceneStorage("destination") private var destination: Destination = .library
|
||||||
|
@SceneStorage("selectedTitle") private var selectedTitleKey: String?
|
||||||
|
@State private var incomingLink: String?
|
||||||
|
@State private var host = PlayerHost.shared
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
if let failure = model.startupError {
|
GeometryReader { geometry in
|
||||||
ContentUnavailableView("Could not start", systemImage: "exclamationmark.triangle", description: Text(failure))
|
let wide = geometry.size.width >= railWidth
|
||||||
} else {
|
ZStack(alignment: .bottom) {
|
||||||
TabView {
|
HStack(spacing: 0) {
|
||||||
NavigationStack { LibraryView() }
|
if wide { NavigationRail(destination: $destination, onSelect: select) }
|
||||||
.tabItem { Label("Library", systemImage: "play.rectangle.on.rectangle") }
|
page(width: geometry.size.width)
|
||||||
NavigationStack { SharesView() }
|
.safeAreaInset(edge: .bottom, spacing: 0) {
|
||||||
.tabItem { Label("Shares", systemImage: "link") }
|
VStack(spacing: 8) {
|
||||||
NavigationStack { SettingsView() }
|
if host.isOpen && host.minimized {
|
||||||
.tabItem { Label("Settings", systemImage: "gearshape") }
|
MiniTransport(host: host)
|
||||||
|
}
|
||||||
|
if !wide { NavigationBar(destination: $destination, onSelect: select) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
ActivityBanner()
|
||||||
|
Snackbar()
|
||||||
|
}
|
||||||
|
.padding(.bottom, (wide ? 16 : 72) + (host.isOpen && host.minimized ? miniTransportInset : 0))
|
||||||
|
.animation(.easeInOut(duration: 0.2), value: model.activity)
|
||||||
}
|
}
|
||||||
.task { await model.reload() }
|
.background(scheme.background.ignoresSafeArea())
|
||||||
.alert("Something went wrong", isPresented: .init(
|
}
|
||||||
get: { model.error != nil },
|
.overlay {
|
||||||
set: { if !$0 { model.error = nil } }
|
// Over the shell rather than instead of it: the page behind stays
|
||||||
)) {
|
// where it was, so leaving the player finds the title page as it was.
|
||||||
Button("OK", role: .cancel) {}
|
if host.isOpen && !host.minimized {
|
||||||
} message: {
|
Group {
|
||||||
Text(model.error ?? "")
|
if let player = host.player {
|
||||||
|
PlayerView(host: host, player: player)
|
||||||
|
} else {
|
||||||
|
ZStack(alignment: .topTrailing) {
|
||||||
|
Color.black.ignoresSafeArea()
|
||||||
|
Text(host.playbackError ?? "Playback is unavailable: libmpv did not start.")
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
IconButton("xmark", "Close", tint: .white, action: host.close)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.transition(.move(edge: .bottom))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.animation(.easeInOut(duration: 0.25), value: host.isOpen && !host.minimized)
|
||||||
|
.onAppear {
|
||||||
|
model.start()
|
||||||
|
host.onSaveProgress = { [model] episode, position, duration, watched in
|
||||||
|
model.saveProgress(episode, position: position, duration: duration, watched: watched)
|
||||||
|
}
|
||||||
|
host.onClosed = { [model] in model.playerClosed() }
|
||||||
|
}
|
||||||
|
.onChange(of: model.titles) { _, titles in host.update(titles: titles) }
|
||||||
|
.onChange(of: phase) { _, phase in
|
||||||
|
switch phase {
|
||||||
|
case .background:
|
||||||
|
host.enteredBackground()
|
||||||
|
model.syncInBackground()
|
||||||
|
case .active:
|
||||||
|
host.enteredForeground()
|
||||||
|
model.enteredForeground()
|
||||||
|
DownloadCoordinator.shared.resumeQueued()
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A link opened in this app: show the Add form with it filled in. A
|
||||||
|
// playing episode keeps going in the mini transport rather than
|
||||||
|
// covering the form.
|
||||||
|
.onOpenURL { url in
|
||||||
|
guard let link = shareLink(from: url) else { return }
|
||||||
|
incomingLink = link
|
||||||
|
destination = .shares
|
||||||
|
selectedTitleKey = nil
|
||||||
|
if host.isOpen { host.minimized = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func select(_ target: Destination) {
|
||||||
|
destination = target
|
||||||
|
selectedTitleKey = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func play(_ title: TitleRecord, _ index: Int) {
|
||||||
|
host.play(title, at: index)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder private func page(width: CGFloat) -> some View {
|
||||||
|
switch destination {
|
||||||
|
case .library:
|
||||||
|
libraryPage(width: width)
|
||||||
|
case .history:
|
||||||
|
HistoryView(onPlay: play, onTitle: { title in
|
||||||
|
destination = .library
|
||||||
|
selectedTitleKey = title.key
|
||||||
|
})
|
||||||
|
case .shares:
|
||||||
|
SharesView(incomingLink: $incomingLink)
|
||||||
|
case .downloads:
|
||||||
|
DownloadsView()
|
||||||
|
case .settings:
|
||||||
|
SettingsView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Side by side where there is room for both: on a tablet the library
|
||||||
|
/// stays in view while a title is open, so moving between titles is one
|
||||||
|
/// tap rather than back and in again.
|
||||||
|
@ViewBuilder private func libraryPage(width: CGFloat) -> some View {
|
||||||
|
let selected = selectedTitleKey.flatMap { key in model.titles.first { $0.key == key } }
|
||||||
|
let library = LibraryView(onResume: play, onTitle: { title in withAnimation(.easeOut(duration: 0.25)) { selectedTitleKey = title.key } })
|
||||||
|
if width >= twoPaneWidth {
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
library.frame(width: width * 0.45)
|
||||||
|
Rectangle().fill(scheme.outlineVariant).frame(width: 1).ignoresSafeArea()
|
||||||
|
Group {
|
||||||
|
if let selected { titlePane(selected) } else { EmptyState("Choose a title", "Its seasons and episodes open here.") }
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// The library stays underneath the title page rather than being
|
||||||
|
// rebuilt, so going back finds the grid scrolled where it was.
|
||||||
|
ZStack {
|
||||||
|
library
|
||||||
|
.opacity(selected == nil ? 1 : 0)
|
||||||
|
.allowsHitTesting(selected == nil)
|
||||||
|
.accessibilityHidden(selected != nil)
|
||||||
|
if let selected {
|
||||||
|
titlePane(selected)
|
||||||
|
.transition(.asymmetric(insertion: .move(edge: .trailing), removal: .opacity.combined(with: .scale(scale: 0.9))))
|
||||||
|
.zIndex(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func titlePane(_ selected: TitleRecord) -> some View {
|
||||||
|
TitleView(
|
||||||
|
title: selected,
|
||||||
|
onPlay: { _, index in host.play(selected, at: index) },
|
||||||
|
onBack: { withAnimation(.easeOut(duration: 0.25)) { selectedTitleKey = nil } },
|
||||||
|
franchise: selected.franchise.compactMap { key in model.titles.first { $0.key == key } },
|
||||||
|
onOpenTitle: { selectedTitleKey = $0.key }
|
||||||
|
)
|
||||||
|
.id(selected.key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The destinations along the foot of a phone, the selected one in an accent
|
||||||
|
/// pill — the desktop's rule is that the accent marks the thing you are on.
|
||||||
|
private struct NavigationBar: View {
|
||||||
|
@Binding var destination: Destination
|
||||||
|
let onSelect: (Destination) -> Void
|
||||||
|
@Environment(\.scheme) private var scheme
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
ForEach(Destination.allCases, id: \.self) { item in
|
||||||
|
let selected = item == destination
|
||||||
|
Button { onSelect(item) } label: {
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
Image(systemName: item.icon)
|
||||||
|
.font(.system(size: 18))
|
||||||
|
.foregroundStyle(selected ? scheme.onPrimary : scheme.onSurfaceVariant)
|
||||||
|
.frame(width: 56, height: 30)
|
||||||
|
.background(selected ? scheme.primary : .clear, in: Capsule())
|
||||||
|
Text(item.label)
|
||||||
|
.textStyle(.labelMedium, semibold: selected)
|
||||||
|
.foregroundStyle(selected ? scheme.onSurface : scheme.onSurfaceVariant)
|
||||||
|
.lineLimit(1)
|
||||||
|
.minimumScaleFactor(0.8)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.top, 10)
|
||||||
|
.padding(.bottom, 6)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.pressable)
|
||||||
|
.accessibilityLabel(item.label)
|
||||||
|
.accessibilityAddTraits(selected ? .isSelected : [])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(scheme.surfaceContainer.ignoresSafeArea(edges: .bottom))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same destinations down the side of a wide window.
|
||||||
|
private struct NavigationRail: View {
|
||||||
|
@Binding var destination: Destination
|
||||||
|
let onSelect: (Destination) -> Void
|
||||||
|
@Environment(\.scheme) private var scheme
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
ForEach(Destination.allCases, id: \.self) { item in
|
||||||
|
let selected = item == destination
|
||||||
|
Button { onSelect(item) } label: {
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
Image(systemName: item.icon)
|
||||||
|
.font(.system(size: 18))
|
||||||
|
.foregroundStyle(selected ? scheme.onPrimary : scheme.onSurfaceVariant)
|
||||||
|
.frame(width: 56, height: 32)
|
||||||
|
.background(selected ? scheme.primary : .clear, in: Capsule())
|
||||||
|
Text(item.label)
|
||||||
|
.textStyle(.labelMedium, semibold: selected)
|
||||||
|
.foregroundStyle(selected ? scheme.onSurface : scheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
.frame(width: 80)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.pressable)
|
||||||
|
.accessibilityLabel(item.label)
|
||||||
|
.accessibilityAddTraits(selected ? .isSelected : [])
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.top, 24)
|
||||||
|
.background(scheme.surface.ignoresSafeArea())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the app is busy with, for as long as it is: a spinner and one line.
|
||||||
|
/// Not dismissible and not timed — it goes when the work does.
|
||||||
|
private struct ActivityBanner: View {
|
||||||
|
@Environment(AppModel.self) private var model
|
||||||
|
@Environment(\.scheme) private var scheme
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if let activity = model.activity {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
ProgressView().controlSize(.small).tint(scheme.primary)
|
||||||
|
Text(activity)
|
||||||
|
.textStyle(.bodyMedium)
|
||||||
|
.foregroundStyle(scheme.onSurface)
|
||||||
|
.lineLimit(2)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.background(scheme.surfaceContainerHighest, in: RoundedRectangle(cornerRadius: Corner.tight))
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||||
|
.accessibilityElement(children: .combine)
|
||||||
|
.accessibilityAddTraits(.updatesFrequently)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The app's one line of feedback: what just happened, with Undo where it can
|
||||||
|
/// be taken back.
|
||||||
|
private struct Snackbar: View {
|
||||||
|
@Environment(AppModel.self) private var model
|
||||||
|
@Environment(\.scheme) private var scheme
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if let message = model.message {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Text(message)
|
||||||
|
.textStyle(.bodyMedium)
|
||||||
|
.foregroundStyle(scheme.inverseOnSurface)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
if model.undo != nil {
|
||||||
|
Button("Undo", action: model.performUndo)
|
||||||
|
.textStyle(.labelLarge)
|
||||||
|
.foregroundStyle(scheme.inversePrimary)
|
||||||
|
.buttonStyle(.pressable)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 14)
|
||||||
|
.background(scheme.inverseSurface, in: RoundedRectangle(cornerRadius: Corner.tight))
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||||
|
.onTapGesture(perform: model.dismissMessage)
|
||||||
|
// Long with an Undo to reach for, short without — Material's two
|
||||||
|
// snackbar durations.
|
||||||
|
.task(id: message) {
|
||||||
|
try? await Task.sleep(for: .seconds(model.undo != nil ? 10 : 4))
|
||||||
|
if !Task.isCancelled, model.message == message { model.dismissMessage() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// The settings that are this front end's alone.
|
||||||
|
///
|
||||||
|
/// Playback preferences — volume, languages, autoplay, auto-skip — deliberately
|
||||||
|
/// do *not* live here: they are `pstr_core::prefs`, reached over the bridge, so
|
||||||
|
/// there is one definition of them rather than one per front end. Keys and
|
||||||
|
/// defaults are Android's `SettingsStore`.
|
||||||
|
struct SettingsStore {
|
||||||
|
private let defaults = UserDefaults.standard
|
||||||
|
|
||||||
|
var wifiOnly: Bool {
|
||||||
|
get { bool(Key.wifiOnly, default: true) }
|
||||||
|
nonmutating set { defaults.set(newValue, forKey: Key.wifiOnly) }
|
||||||
|
}
|
||||||
|
|
||||||
|
var backgroundAudio: Bool {
|
||||||
|
get { bool(Key.backgroundAudio, default: true) }
|
||||||
|
nonmutating set { defaults.set(newValue, forKey: Key.backgroundAudio) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether mpv may decode in hardware. Off is the way out for a file
|
||||||
|
/// VideoToolbox shows green or torn frames for; it costs battery, so it is
|
||||||
|
/// on unless the viewer turns it off. Read at each load, so a change applies
|
||||||
|
/// from the next file.
|
||||||
|
var hardwareDecoding: Bool {
|
||||||
|
get { bool(Key.hardwareDecoding, default: true) }
|
||||||
|
nonmutating set { defaults.set(newValue, forKey: Key.hardwareDecoding) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The streaming cache's budget, in GiB. See `cacheBudgetChoices`.
|
||||||
|
var cacheBudgetGib: Int {
|
||||||
|
get { defaults.object(forKey: Key.cacheBudgetGib) as? Int ?? Self.defaultCacheBudgetGib }
|
||||||
|
nonmutating set { defaults.set(newValue, forKey: Key.cacheBudgetGib) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The desktop's range, in the steps a phone's storage is bought in.
|
||||||
|
static let cacheBudgetChoices = [1, 2, 4, 8, 16]
|
||||||
|
|
||||||
|
/// `DiskCacheConfig::DEFAULT_BUDGET_BYTES`, so an unset choice changes nothing.
|
||||||
|
static let defaultCacheBudgetGib = 4
|
||||||
|
|
||||||
|
static func gibToBytes(_ gib: Int) -> UInt64 { UInt64(gib) * 1024 * 1024 * 1024 }
|
||||||
|
|
||||||
|
private func bool(_ key: String, default value: Bool) -> Bool {
|
||||||
|
defaults.object(forKey: key) as? Bool ?? value
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum Key {
|
||||||
|
static let wifiOnly = "wifi_only"
|
||||||
|
static let backgroundAudio = "background_audio"
|
||||||
|
static let hardwareDecoding = "hardware_decoding"
|
||||||
|
static let cacheBudgetGib = "cache_budget_gib"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
import Foundation
|
||||||
|
import Network
|
||||||
|
import Observation
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// Offline downloads: the queue, the retries and the network policy.
|
||||||
|
///
|
||||||
|
/// Rust owns the block-aligned transfer and the catalog mutation, as on
|
||||||
|
/// Android; this owns what WorkManager owns there. iOS has no equivalent of a
|
||||||
|
/// foreground worker for a transfer that is not a `URLSession` task, so a
|
||||||
|
/// download runs while the app does, plus the grace `beginBackgroundTask`
|
||||||
|
/// grants once it leaves the screen. One cut short is put back in the queue,
|
||||||
|
/// and the `.part` file resumes at its last whole block on the next launch.
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
final class DownloadCoordinator {
|
||||||
|
static let shared = DownloadCoordinator()
|
||||||
|
|
||||||
|
/// Every retained download, sorted by label. Re-read after each write.
|
||||||
|
private(set) var records: [RetainedDownload] = []
|
||||||
|
|
||||||
|
/// A download finished or was removed: the catalog has changed.
|
||||||
|
@ObservationIgnored var onFinished: (() -> Void)?
|
||||||
|
|
||||||
|
@ObservationIgnored private let store = DownloadStateStore.shared
|
||||||
|
@ObservationIgnored private var running: [String: Transfer] = [:]
|
||||||
|
@ObservationIgnored private let monitor = NWPathMonitor()
|
||||||
|
@ObservationIgnored private var path: NWPath?
|
||||||
|
@ObservationIgnored private var refreshQueued = false
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
store.onChange = { [weak self] in
|
||||||
|
Task { @MainActor in self?.scheduleRefresh() }
|
||||||
|
}
|
||||||
|
records = store.records()
|
||||||
|
monitor.pathUpdateHandler = { [weak self] path in
|
||||||
|
Task { @MainActor in self?.pathChanged(path) }
|
||||||
|
}
|
||||||
|
monitor.start(queue: DispatchQueue(label: "io.narl.protonstream.network"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Free space to leave behind a download, for the catalog and the caches.
|
||||||
|
private static let storageHeadroom: Int64 = 256 * 1024 * 1024
|
||||||
|
private static let maxAttempts = 3
|
||||||
|
|
||||||
|
func enqueue(_ episode: EpisodeRecord) { enqueue([episode]) }
|
||||||
|
|
||||||
|
func enqueue(_ episodes: [EpisodeRecord]) {
|
||||||
|
let wanted = episodes.filter { !$0.offline }
|
||||||
|
if wanted.isEmpty { return }
|
||||||
|
// Space is checked once for the whole request, against its total.
|
||||||
|
// Queueing a season that cannot fit and discovering it episode by
|
||||||
|
// episode at ENOSPC is how the app fills its own database's disk.
|
||||||
|
let room = Self.roomFor(wanted.reduce(Int64(0)) { $0 + Int64($1.size ?? 0) })
|
||||||
|
let downloads = wanted.map { episode in
|
||||||
|
RetainedDownload(
|
||||||
|
shareId: episode.shareId,
|
||||||
|
volumeId: episode.volumeId,
|
||||||
|
linkId: episode.linkId,
|
||||||
|
label: episode.label,
|
||||||
|
total: Int64(episode.size ?? 0),
|
||||||
|
status: room ? RetainedDownload.statusQueued : RetainedDownload.statusFailed,
|
||||||
|
error: room ? nil : "Not enough free space"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// An episode already in flight keeps its record and its transfer.
|
||||||
|
store.putAll(downloads.filter { running[$0.key] == nil })
|
||||||
|
if room { downloads.forEach(start) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func resume(_ download: RetainedDownload) {
|
||||||
|
var queued = download
|
||||||
|
queued.status = RetainedDownload.statusQueued
|
||||||
|
queued.error = nil
|
||||||
|
guard let transfer = running[download.key] else {
|
||||||
|
store.put(queued)
|
||||||
|
start(queued)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Still running: nothing to resume. Still winding down from a pause:
|
||||||
|
// let it record that first, or its last write lands over this one.
|
||||||
|
guard transfer.observer.stopped else { return }
|
||||||
|
Task {
|
||||||
|
await transfer.task.value
|
||||||
|
store.put(queued)
|
||||||
|
start(queued)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func pause(_ download: RetainedDownload) { setStatus(download, RetainedDownload.statusPaused) }
|
||||||
|
|
||||||
|
func cancel(_ download: RetainedDownload) { setStatus(download, RetainedDownload.statusCancelled) }
|
||||||
|
|
||||||
|
/// Stop one download and wait for its transfer to let go of the `.part`
|
||||||
|
/// file, so the caller can remove it.
|
||||||
|
func cancelAndWait(_ download: RetainedDownload) async {
|
||||||
|
cancel(download)
|
||||||
|
await running[download.key]?.task.value
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop every download of one share, and wait for them.
|
||||||
|
func cancelShare(_ shareId: String) async {
|
||||||
|
let transfers = running.values.filter { $0.download.shareId == shareId }
|
||||||
|
for transfer in transfers { stop(transfer.download.key, as: RetainedDownload.statusCancelled) }
|
||||||
|
for transfer in transfers { await transfer.task.value }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop everything, and wait.
|
||||||
|
func cancelAll() async {
|
||||||
|
let transfers = Array(running.values)
|
||||||
|
for transfer in transfers { stop(transfer.download.key, as: RetainedDownload.statusCancelled) }
|
||||||
|
for transfer in transfers { await transfer.task.value }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start whatever is queued — on launch, back in the foreground, and when
|
||||||
|
/// the network comes back. A download the system cut short is queued, not
|
||||||
|
/// failed, so it is picked up here.
|
||||||
|
func resumeQueued() {
|
||||||
|
for download in store.records() where download.status == RetainedDownload.statusQueued
|
||||||
|
|| download.status == RetainedDownload.statusRunning {
|
||||||
|
start(download)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-apply "Wi-Fi only" to what is queued and running: a season queued
|
||||||
|
/// before the setting was turned on must not then download over cellular.
|
||||||
|
func applyNetworkPolicy() {
|
||||||
|
if allowed {
|
||||||
|
resumeQueued()
|
||||||
|
} else {
|
||||||
|
for key in Array(running.keys) { stop(key, as: RetainedDownload.statusQueued) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Running
|
||||||
|
|
||||||
|
private final class Transfer {
|
||||||
|
let download: RetainedDownload
|
||||||
|
let observer: Observer
|
||||||
|
var task: Task<Void, Never>!
|
||||||
|
|
||||||
|
init(download: RetainedDownload, observer: Observer) {
|
||||||
|
self.download = download
|
||||||
|
self.observer = observer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the network policy lets a transfer run now.
|
||||||
|
private var allowed: Bool {
|
||||||
|
guard let path, path.status == .satisfied else { return false }
|
||||||
|
return !SettingsStore().wifiOnly || !(path.isExpensive || path.usesInterfaceType(.cellular))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func pathChanged(_ path: NWPath) {
|
||||||
|
self.path = path
|
||||||
|
applyNetworkPolicy()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func start(_ download: RetainedDownload) {
|
||||||
|
guard running[download.key] == nil, allowed else { return }
|
||||||
|
let observer = Observer(store: store, download: download)
|
||||||
|
let transfer = Transfer(download: download, observer: observer)
|
||||||
|
running[download.key] = transfer
|
||||||
|
transfer.task = Task { [weak self] in
|
||||||
|
await self?.run(transfer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ask a transfer to stop at its next block boundary. `status` is what its
|
||||||
|
/// record says afterwards; nil leaves that to whoever called.
|
||||||
|
private func stop(_ key: String, as status: String?) {
|
||||||
|
guard let transfer = running[key] else { return }
|
||||||
|
transfer.observer.stop(as: status)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setStatus(_ download: RetainedDownload, _ status: String) {
|
||||||
|
store.update(download.shareId, download.linkId) { stored in
|
||||||
|
var changed = stored ?? download
|
||||||
|
changed.status = status
|
||||||
|
changed.error = nil
|
||||||
|
return changed
|
||||||
|
}
|
||||||
|
stop(download.key, as: status)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func run(_ transfer: Transfer) async {
|
||||||
|
let download = transfer.download
|
||||||
|
let observer = transfer.observer
|
||||||
|
let background = UIApplication.shared.beginBackgroundTask(withName: "offline-\(download.linkId)") {
|
||||||
|
// Out of background time: stop at the next block and queue it, so
|
||||||
|
// the next launch carries on from the `.part` file.
|
||||||
|
observer.stop(as: RetainedDownload.statusQueued)
|
||||||
|
}
|
||||||
|
defer {
|
||||||
|
running[download.key] = nil
|
||||||
|
UIApplication.shared.endBackgroundTask(background)
|
||||||
|
}
|
||||||
|
for attempt in 1 ... Self.maxAttempts {
|
||||||
|
// A pause or a cancel that landed before this got going.
|
||||||
|
if let stored = store.get(download.shareId, download.linkId),
|
||||||
|
stored.status == RetainedDownload.statusPaused || stored.status == RetainedDownload.statusCancelled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Queued, not running: the engine lets two downloads transfer at
|
||||||
|
// once and holds the rest back, and only the first progress report
|
||||||
|
// means this one has a slot.
|
||||||
|
store.update(download.shareId, download.linkId) { stored in
|
||||||
|
var current = stored ?? download
|
||||||
|
current.status = RetainedDownload.statusQueued
|
||||||
|
current.error = nil
|
||||||
|
current.bytesPerSecond = 0
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
let engine = try await NativeRuntime.engine()
|
||||||
|
_ = try await engine.downloadEpisode(
|
||||||
|
shareId: download.shareId,
|
||||||
|
volumeId: download.volumeId,
|
||||||
|
linkId: download.linkId,
|
||||||
|
observer: observer
|
||||||
|
)
|
||||||
|
store.remove(download.shareId, download.linkId)
|
||||||
|
onFinished?()
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
if let status = observer.stoppedAs {
|
||||||
|
// A pause is also a stop. Under the lock, so the record
|
||||||
|
// read here is the one the write lands on.
|
||||||
|
store.update(download.shareId, download.linkId) { stored in
|
||||||
|
guard var current = stored, current.status != RetainedDownload.statusPaused else { return nil }
|
||||||
|
current.status = status
|
||||||
|
current.bytesPerSecond = 0
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if observer.stopped { return }
|
||||||
|
let message = errorMessage(error)
|
||||||
|
if attempt < Self.maxAttempts {
|
||||||
|
store.update(download.shareId, download.linkId) { stored in
|
||||||
|
var current = stored ?? download
|
||||||
|
current.status = RetainedDownload.statusQueued
|
||||||
|
current.error = message
|
||||||
|
current.bytesPerSecond = 0
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
// WorkManager's exponential backoff, from 30 s.
|
||||||
|
try? await Task.sleep(for: .seconds(30 * (1 << (attempt - 1))))
|
||||||
|
if observer.stopped { return }
|
||||||
|
} else {
|
||||||
|
store.update(download.shareId, download.linkId) { stored in
|
||||||
|
var current = stored ?? download
|
||||||
|
current.status = RetainedDownload.statusFailed
|
||||||
|
current.error = message
|
||||||
|
current.bytesPerSecond = 0
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scheduleRefresh() {
|
||||||
|
// Progress lands a dozen times a second on a fast link; one re-read
|
||||||
|
// per frame is plenty.
|
||||||
|
if refreshQueued { return }
|
||||||
|
refreshQueued = true
|
||||||
|
Task { @MainActor in
|
||||||
|
refreshQueued = false
|
||||||
|
records = store.records()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `bytes` plus working room will fit where offline files are kept.
|
||||||
|
/// An unknown size — zero — is allowed through.
|
||||||
|
private static func roomFor(_ bytes: Int64) -> Bool {
|
||||||
|
if bytes <= 0 { return true }
|
||||||
|
let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
||||||
|
guard let values = try? support.resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey]),
|
||||||
|
let available = values.volumeAvailableCapacityForImportantUsage
|
||||||
|
else { return true }
|
||||||
|
return available - bytes >= storageHeadroom
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The bridge's view of one transfer: where it is, and whether to stop.
|
||||||
|
private final class Observer: DownloadObserver, @unchecked Sendable {
|
||||||
|
private let store: DownloadStateStore
|
||||||
|
private let download: RetainedDownload
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var rate = TransferRate()
|
||||||
|
private var reportedAt: Int64 = 0
|
||||||
|
private var reportedPercent = -1
|
||||||
|
private var stopRequested = false
|
||||||
|
private var stopStatus: String?
|
||||||
|
|
||||||
|
/// Shortest gap between two identical-looking progress reports.
|
||||||
|
private static let progressIntervalMs: Int64 = 1000
|
||||||
|
|
||||||
|
init(store: DownloadStateStore, download: RetainedDownload) {
|
||||||
|
self.store = store
|
||||||
|
self.download = download
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop(as status: String?) {
|
||||||
|
lock.withLock {
|
||||||
|
stopRequested = true
|
||||||
|
if let status { stopStatus = status }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var stopped: Bool { lock.withLock { stopRequested } }
|
||||||
|
var stoppedAs: String? { lock.withLock { stopStatus } }
|
||||||
|
|
||||||
|
/// Called once per 4 MiB block. Nothing the viewer can see changes that
|
||||||
|
/// often, so a report is only made when the percentage moves or a second
|
||||||
|
/// has passed. The final one is never suppressed.
|
||||||
|
func onProgress(downloaded: UInt64, total: UInt64) throws {
|
||||||
|
let percent = total == 0 ? 0 : Int(downloaded * 100 / total)
|
||||||
|
let now = Int64(ProcessInfo.processInfo.systemUptime * 1000)
|
||||||
|
let complete = total > 0 && downloaded >= total
|
||||||
|
let report: Int64? = lock.withLock {
|
||||||
|
let bytesPerSecond = rate.sample(Int64(downloaded), now: now)
|
||||||
|
if !complete, percent == reportedPercent, now - reportedAt < Self.progressIntervalMs { return nil }
|
||||||
|
reportedAt = now
|
||||||
|
reportedPercent = percent
|
||||||
|
return bytesPerSecond
|
||||||
|
}
|
||||||
|
guard let bytesPerSecond = report else { return }
|
||||||
|
store.update(download.shareId, download.linkId) { [download] stored in
|
||||||
|
var current = stored ?? download
|
||||||
|
current.downloaded = Int64(downloaded)
|
||||||
|
current.total = Int64(total)
|
||||||
|
current.bytesPerSecond = bytesPerSecond
|
||||||
|
if current.status != RetainedDownload.statusPaused, current.status != RetainedDownload.statusCancelled {
|
||||||
|
current.status = RetainedDownload.statusRunning
|
||||||
|
}
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isCancelled() throws -> Bool { stopped }
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Durable user-facing state for one offline download, kept until it finishes
|
||||||
|
/// or the viewer deletes it. The fields and their JSON names are Android's
|
||||||
|
/// `RetainedDownload`.
|
||||||
|
struct RetainedDownload: Equatable, Hashable, Identifiable, Sendable {
|
||||||
|
var shareId: String
|
||||||
|
var volumeId: String
|
||||||
|
var linkId: String
|
||||||
|
var label: String
|
||||||
|
var downloaded: Int64 = 0
|
||||||
|
var total: Int64 = 0
|
||||||
|
var status: String = RetainedDownload.statusQueued
|
||||||
|
var error: String?
|
||||||
|
/// Recent transfer rate while running; 0 when unknown or not running.
|
||||||
|
var bytesPerSecond: Int64 = 0
|
||||||
|
|
||||||
|
var key: String { downloadKey(shareId, linkId) }
|
||||||
|
var id: String { key }
|
||||||
|
|
||||||
|
static let statusQueued = "queued"
|
||||||
|
static let statusRunning = "running"
|
||||||
|
static let statusPaused = "paused"
|
||||||
|
static let statusFailed = "failed"
|
||||||
|
static let statusCancelled = "cancelled"
|
||||||
|
}
|
||||||
|
|
||||||
|
func downloadKey(_ shareId: String, _ linkId: String) -> String { "\(shareId)\u{1f}\(linkId)" }
|
||||||
|
|
||||||
|
/// The retained downloads, as one JSON file of `key → record` beside the
|
||||||
|
/// catalog — Android keeps the same records in SharedPreferences.
|
||||||
|
///
|
||||||
|
/// Every write goes through one lock: the transfer's progress reports and the
|
||||||
|
/// viewer's pause are concurrent writers to the same record, and a whole-record
|
||||||
|
/// `put` from a screen's stale copy would both lose that progress and race the
|
||||||
|
/// transfer's own read-modify-write.
|
||||||
|
final class DownloadStateStore: @unchecked Sendable {
|
||||||
|
static let shared = DownloadStateStore(file: DownloadStateStore.defaultFile)
|
||||||
|
|
||||||
|
/// Called after every write, on whichever thread wrote.
|
||||||
|
var onChange: (() -> Void)?
|
||||||
|
|
||||||
|
private let file: URL
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var entries: [String: String]?
|
||||||
|
|
||||||
|
init(file: URL) {
|
||||||
|
self.file = file
|
||||||
|
}
|
||||||
|
|
||||||
|
func records() -> [RetainedDownload] {
|
||||||
|
lock.withLock { loaded().values.compactMap(Self.decode) }
|
||||||
|
.sorted { $0.label.lowercased() < $1.label.lowercased() }
|
||||||
|
}
|
||||||
|
|
||||||
|
func get(_ shareId: String, _ linkId: String) -> RetainedDownload? {
|
||||||
|
lock.withLock { loaded()[downloadKey(shareId, linkId)].flatMap(Self.decode) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func put(_ record: RetainedDownload) {
|
||||||
|
edit { $0[record.key] = Self.encode(record) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read-modify-write one record, excluding every other writer. Returning
|
||||||
|
/// nil from `change` leaves the record alone.
|
||||||
|
func update(_ shareId: String, _ linkId: String, _ change: (RetainedDownload?) -> RetainedDownload?) {
|
||||||
|
edit { entries in
|
||||||
|
let key = downloadKey(shareId, linkId)
|
||||||
|
if let changed = change(entries[key].flatMap(Self.decode)) {
|
||||||
|
entries[changed.key] = Self.encode(changed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write a whole queue in one go: queueing a season is one write, not one
|
||||||
|
/// per episode.
|
||||||
|
func putAll(_ records: [RetainedDownload]) {
|
||||||
|
edit { entries in
|
||||||
|
for record in records { entries[record.key] = Self.encode(record) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func remove(_ shareId: String, _ linkId: String) {
|
||||||
|
edit { $0[downloadKey(shareId, linkId)] = nil }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forget every retained download. Callers stop the transfers first.
|
||||||
|
func clear() {
|
||||||
|
edit { $0.removeAll() }
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeShare(_ shareId: String) {
|
||||||
|
edit { entries in
|
||||||
|
entries = entries.filter { Self.decode($0.value)?.shareId != shareId }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func edit(_ change: (inout [String: String]) -> Void) {
|
||||||
|
lock.withLock {
|
||||||
|
var current = loaded()
|
||||||
|
change(¤t)
|
||||||
|
entries = current
|
||||||
|
// Atomic, so a kill mid-write leaves the last good file.
|
||||||
|
if let data = try? JSONEncoder().encode(current) {
|
||||||
|
try? data.write(to: file, options: [.atomic])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onChange?()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Under `lock`.
|
||||||
|
private func loaded() -> [String: String] {
|
||||||
|
if let entries { return entries }
|
||||||
|
let read = (try? Data(contentsOf: file)).flatMap { try? JSONDecoder().decode([String: String].self, from: $0) } ?? [:]
|
||||||
|
entries = read
|
||||||
|
return read
|
||||||
|
}
|
||||||
|
|
||||||
|
static func encode(_ record: RetainedDownload) -> String {
|
||||||
|
var json: [String: Any] = [
|
||||||
|
"share": record.shareId,
|
||||||
|
"volume": record.volumeId,
|
||||||
|
"link": record.linkId,
|
||||||
|
"label": record.label,
|
||||||
|
"downloaded": record.downloaded,
|
||||||
|
"total": record.total,
|
||||||
|
"status": record.status,
|
||||||
|
"rate": record.bytesPerSecond,
|
||||||
|
]
|
||||||
|
if let error = record.error { json["error"] = error }
|
||||||
|
let data = (try? JSONSerialization.data(withJSONObject: json, options: [.sortedKeys])) ?? Data()
|
||||||
|
return String(decoding: data, as: UTF8.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A row that does not decode is skipped rather than failing the list.
|
||||||
|
static func decode(_ encoded: String) -> RetainedDownload? {
|
||||||
|
guard let object = try? JSONSerialization.jsonObject(with: Data(encoded.utf8)),
|
||||||
|
let json = object as? [String: Any],
|
||||||
|
let share = json["share"] as? String,
|
||||||
|
let volume = json["volume"] as? String,
|
||||||
|
let link = json["link"] as? String
|
||||||
|
else { return nil }
|
||||||
|
let error = (json["error"] as? String).flatMap { $0.trimmingCharacters(in: .whitespaces).isEmpty ? nil : $0 }
|
||||||
|
return RetainedDownload(
|
||||||
|
shareId: share,
|
||||||
|
volumeId: volume,
|
||||||
|
linkId: link,
|
||||||
|
label: json["label"] as? String ?? link,
|
||||||
|
downloaded: (json["downloaded"] as? NSNumber)?.int64Value ?? 0,
|
||||||
|
total: (json["total"] as? NSNumber)?.int64Value ?? 0,
|
||||||
|
status: json["status"] as? String ?? RetainedDownload.statusQueued,
|
||||||
|
error: error,
|
||||||
|
bytesPerSecond: (json["rate"] as? NSNumber)?.int64Value ?? 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// In a directory of its own, excluded from backup like the catalog: an
|
||||||
|
/// atomic write replaces the file, so the flag has to sit on its parent.
|
||||||
|
private static var defaultFile: URL {
|
||||||
|
let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
||||||
|
var directory = support.appendingPathComponent("state", isDirectory: true)
|
||||||
|
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||||
|
var excluded = URLResourceValues()
|
||||||
|
excluded.isExcludedFromBackup = true
|
||||||
|
try? directory.setResourceValues(excluded)
|
||||||
|
return directory.appendingPathComponent("offline-downloads.json")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bytes per second over roughly the last few seconds.
|
||||||
|
///
|
||||||
|
/// Progress arrives once per 4 MiB block, so on a slow link the bar moves in
|
||||||
|
/// steps seconds apart; a rate beside it is what tells a slow download from a
|
||||||
|
/// stuck one. Smoothed, because one block that took a little longer than the
|
||||||
|
/// last would otherwise halve the figure for a moment.
|
||||||
|
struct TransferRate {
|
||||||
|
private var lastBytes: Int64 = -1
|
||||||
|
private var lastAt: Int64 = 0
|
||||||
|
private var smoothed = 0.0
|
||||||
|
|
||||||
|
/// Record `bytes` transferred at `now` (milliseconds) and return the rate.
|
||||||
|
mutating func sample(_ bytes: Int64, now: Int64) -> Int64 {
|
||||||
|
if lastBytes < 0 || bytes < lastBytes {
|
||||||
|
lastBytes = bytes
|
||||||
|
lastAt = now
|
||||||
|
return Int64(smoothed)
|
||||||
|
}
|
||||||
|
let elapsed = now - lastAt
|
||||||
|
if elapsed <= 0 { return Int64(smoothed) }
|
||||||
|
let instant = Double(bytes - lastBytes) * 1000 / Double(elapsed)
|
||||||
|
smoothed = smoothed > 0 ? 0.7 * smoothed + 0.3 * instant : instant
|
||||||
|
lastBytes = bytes
|
||||||
|
lastAt = now
|
||||||
|
return Int64(smoothed)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,16 @@ final class MpvMetalLayer: CAMetalLayer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Why the open file ended, mirroring `pstr_player::EndReason` so every client
|
||||||
|
/// agrees on what "finished" means.
|
||||||
|
///
|
||||||
|
/// Only `eof` is an episode watched to the end. Treating the others as one — a
|
||||||
|
/// stop, a shutdown, a failed demux — is what marks an episode that never
|
||||||
|
/// played as watched and autoplays past it.
|
||||||
|
enum EndReason {
|
||||||
|
case eof, stopped, quit, failed, other
|
||||||
|
}
|
||||||
|
|
||||||
struct MpvTrack: Identifiable, Hashable {
|
struct MpvTrack: Identifiable, Hashable {
|
||||||
let id: Int64
|
let id: Int64
|
||||||
let type: String
|
let type: String
|
||||||
@@ -25,51 +35,93 @@ struct MpvTrack: Identifiable, Hashable {
|
|||||||
let selected: Bool
|
let selected: Bool
|
||||||
|
|
||||||
var label: String {
|
var label: String {
|
||||||
let parts = [title, language.uppercased()].filter { !$0.isEmpty }
|
let parts = [language, title].filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty }
|
||||||
return parts.isEmpty ? "Track \(id)" : parts.joined(separator: " · ")
|
return parts.isEmpty ? "\(type.prefix(1).uppercased() + type.dropFirst()) \(id)" : parts.joined(separator: " — ")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One libmpv core rendering into a Metal layer. Every public method runs on
|
/// One libmpv core rendering into a Metal layer, for the life of the app.
|
||||||
/// the main thread; mpv's events are read on a thread of their own and applied
|
///
|
||||||
/// back on the main thread.
|
/// Every public method runs on the main thread; mpv's events are read on a
|
||||||
|
/// thread of their own and applied back on the main thread. The state mirrors
|
||||||
|
/// what `pstr_mpv.cpp` publishes on Android, field for field.
|
||||||
@MainActor
|
@MainActor
|
||||||
@Observable
|
@Observable
|
||||||
final class MpvPlayer {
|
final class MpvPlayer {
|
||||||
enum EndReason { case eof, stopped, failed }
|
|
||||||
|
|
||||||
private(set) var position: Double = 0
|
private(set) var position: Double = 0
|
||||||
private(set) var duration: Double = 0
|
private(set) var duration: Double = 0
|
||||||
|
private(set) var volume: Double = 100
|
||||||
private(set) var paused = false
|
private(set) var paused = false
|
||||||
|
private(set) var muted = false
|
||||||
|
/// The file ended, and why. Nil while one is open.
|
||||||
|
private(set) var endReason: EndReason?
|
||||||
|
/// mpv has run out of buffered data and stopped to refill — not the same
|
||||||
|
/// as `paused`, which is the viewer's doing.
|
||||||
private(set) var buffering = false
|
private(set) var buffering = false
|
||||||
|
/// How full the demuxer cache is while `buffering`, 0–100.
|
||||||
|
private(set) var cachePercent: Double = 0
|
||||||
|
/// A seek has been issued but playback has not resumed at the new position.
|
||||||
|
private(set) var seeking = false
|
||||||
|
/// How far the demuxer has read ahead, in seconds into the file.
|
||||||
|
private(set) var cachedUntil: Double = 0
|
||||||
|
/// The picture's display size, or zero until mpv has decoded enough.
|
||||||
|
private(set) var videoWidth: Double = 0
|
||||||
|
private(set) var videoHeight: Double = 0
|
||||||
private(set) var tracks: [MpvTrack] = []
|
private(set) var tracks: [MpvTrack] = []
|
||||||
private(set) var chapters: [ChapterRecord] = []
|
private(set) var chapters: [ChapterRecord] = []
|
||||||
private(set) var failure: String?
|
/// Which episode the readings above describe, or nil between `loadfile`
|
||||||
/// Between `loadfile` and mpv's `FILE_LOADED`. Everything mpv reports in
|
/// and mpv's `START_FILE`. Everything that *writes* per-episode state —
|
||||||
/// that window still describes the *outgoing* file, including the
|
/// the resume position above all — must check it: one core serves every
|
||||||
/// `END_FILE(stop)` stopping it emits.
|
/// episode, and a reading attributed to the wrong one resumes the next
|
||||||
private(set) var loading = false
|
/// episode where the last one stopped.
|
||||||
|
private(set) var media: String?
|
||||||
|
/// What mpv last complained about, until the viewer dismisses it.
|
||||||
|
var problem: String?
|
||||||
|
/// Bumped by every load, so a reader can tell "episode two, second zero"
|
||||||
|
/// from a leftover reading of episode one.
|
||||||
|
private(set) var generation = 0
|
||||||
|
|
||||||
|
/// Whether the picture is stopped for a reason the viewer did not choose.
|
||||||
|
var stalled: Bool { (buffering || seeking) && !paused && endReason == nil }
|
||||||
|
|
||||||
/// Called once per file that ends, never for the one a load replaced.
|
/// Called once per file that ends, never for the one a load replaced.
|
||||||
@ObservationIgnored var onEnded: ((EndReason) -> Void)?
|
@ObservationIgnored var onEnded: ((EndReason, String) -> Void)?
|
||||||
|
/// Called on every position change, for what has to react to the clock.
|
||||||
|
@ObservationIgnored var onTick: (() -> Void)?
|
||||||
|
/// Called when the duration or the pause state changes: what the lock
|
||||||
|
/// screen shows, and whether the screen may sleep.
|
||||||
|
@ObservationIgnored var onStateChanged: (() -> Void)?
|
||||||
|
|
||||||
@ObservationIgnored let layer = MpvMetalLayer()
|
@ObservationIgnored let layer = MpvMetalLayer()
|
||||||
@ObservationIgnored private var mpv: OpaquePointer?
|
@ObservationIgnored private var mpv: OpaquePointer?
|
||||||
|
/// Between `loadfile` and `START_FILE`. Everything mpv reports in that
|
||||||
|
/// window still describes the *outgoing* file, including the
|
||||||
|
/// `END_FILE(stop)` that stopping it emits.
|
||||||
|
@ObservationIgnored private var loading = false
|
||||||
|
@ObservationIgnored private var pendingMedia: String?
|
||||||
|
|
||||||
init() {
|
/// A core, or nil when libmpv would not start.
|
||||||
|
static func create() -> MpvPlayer? {
|
||||||
|
let player = MpvPlayer()
|
||||||
|
return player.mpv == nil ? nil : player
|
||||||
|
}
|
||||||
|
|
||||||
|
private init() {
|
||||||
layer.framebufferOnly = true
|
layer.framebufferOnly = true
|
||||||
layer.backgroundColor = UIColor.black.cgColor
|
layer.backgroundColor = UIColor.black.cgColor
|
||||||
guard let mpv = mpv_create() else {
|
// For the frames between a resize and mpv's next one: a drawable of
|
||||||
failure = "libmpv did not start"
|
// the old shape is letterboxed rather than stretched.
|
||||||
return
|
layer.contentsGravity = .resizeAspect
|
||||||
}
|
guard let mpv = mpv_create() else { return }
|
||||||
var wid = layer
|
// mpv takes the layer's address as an int64, not a Swift reference.
|
||||||
|
var wid = Int64(Int(bitPattern: Unmanaged.passUnretained(layer).toOpaque()))
|
||||||
mpv_set_option(mpv, "wid", MPV_FORMAT_INT64, &wid)
|
mpv_set_option(mpv, "wid", MPV_FORMAT_INT64, &wid)
|
||||||
for (name, value) in [
|
for (name, value) in [
|
||||||
("config", "no"),
|
("config", "no"),
|
||||||
("vo", "gpu-next"),
|
("vo", "gpu-next"),
|
||||||
("gpu-api", "vulkan"),
|
("gpu-api", "vulkan"),
|
||||||
("gpu-context", "moltenvk"),
|
("gpu-context", "moltenvk"),
|
||||||
|
// The default; `load` sets it per file from the viewer's setting.
|
||||||
("hwdec", "videotoolbox"),
|
("hwdec", "videotoolbox"),
|
||||||
("video-rotate", "no"),
|
("video-rotate", "no"),
|
||||||
("keep-open", "no"),
|
("keep-open", "no"),
|
||||||
@@ -78,20 +130,21 @@ final class MpvPlayer {
|
|||||||
("demuxer-readahead-secs", "30"),
|
("demuxer-readahead-secs", "30"),
|
||||||
("demuxer-max-bytes", "50331648"),
|
("demuxer-max-bytes", "50331648"),
|
||||||
("subs-fallback", "yes"),
|
("subs-fallback", "yes"),
|
||||||
|
// AVAudioSession is this app's to manage, as audio focus is
|
||||||
|
// Android's.
|
||||||
|
("audio-exclusive", "no"),
|
||||||
] {
|
] {
|
||||||
mpv_set_option_string(mpv, name, value)
|
mpv_set_option_string(mpv, name, value)
|
||||||
}
|
}
|
||||||
guard registerStreamProtocol(mpv), mpv_initialize(mpv) >= 0 else {
|
guard registerStreamProtocol(mpv), mpv_initialize(mpv) >= 0 else {
|
||||||
mpv_terminate_destroy(mpv)
|
mpv_terminate_destroy(mpv)
|
||||||
failure = "libmpv did not initialize"
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
mpv_observe_property(mpv, 1, "time-pos", MPV_FORMAT_DOUBLE)
|
for (id, name, format) in Self.observed {
|
||||||
mpv_observe_property(mpv, 2, "duration", MPV_FORMAT_DOUBLE)
|
mpv_observe_property(mpv, id, name, format)
|
||||||
mpv_observe_property(mpv, 3, "pause", MPV_FORMAT_FLAG)
|
}
|
||||||
mpv_observe_property(mpv, 4, "paused-for-cache", MPV_FORMAT_FLAG)
|
// Errors mpv reports about the file itself, rather than about the
|
||||||
mpv_observe_property(mpv, 5, "track-list/count", MPV_FORMAT_INT64)
|
// request that opened it. Without these a failed demux is silent.
|
||||||
mpv_observe_property(mpv, 6, "chapter-list/count", MPV_FORMAT_INT64)
|
|
||||||
mpv_request_log_messages(mpv, "error")
|
mpv_request_log_messages(mpv, "error")
|
||||||
self.mpv = mpv
|
self.mpv = mpv
|
||||||
|
|
||||||
@@ -100,52 +153,106 @@ final class MpvPlayer {
|
|||||||
thread.start()
|
thread.start()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static let observed: [(UInt64, String, mpv_format)] = [
|
||||||
|
(1, "time-pos", MPV_FORMAT_DOUBLE),
|
||||||
|
(2, "duration", MPV_FORMAT_DOUBLE),
|
||||||
|
(3, "pause", MPV_FORMAT_FLAG),
|
||||||
|
(4, "volume", MPV_FORMAT_DOUBLE),
|
||||||
|
(5, "mute", MPV_FORMAT_FLAG),
|
||||||
|
(6, "video-params/dw", MPV_FORMAT_INT64),
|
||||||
|
(7, "video-params/dh", MPV_FORMAT_INT64),
|
||||||
|
(8, "paused-for-cache", MPV_FORMAT_FLAG),
|
||||||
|
(9, "cache-buffering-state", MPV_FORMAT_INT64),
|
||||||
|
(10, "demuxer-cache-time", MPV_FORMAT_DOUBLE),
|
||||||
|
(11, "track-list/count", MPV_FORMAT_INT64),
|
||||||
|
(12, "chapter-list/count", MPV_FORMAT_INT64),
|
||||||
|
(13, "seeking", MPV_FORMAT_FLAG),
|
||||||
|
]
|
||||||
|
|
||||||
// MARK: Commands
|
// MARK: Commands
|
||||||
|
|
||||||
/// Load `pstr://<handle>`. On `false` nothing was issued, and the caller
|
/// Load `pstr://<handle>` as `media`. On `false` nothing was issued, and
|
||||||
/// still owns the handle's release.
|
/// the caller still owns the handle's release.
|
||||||
func load(handle: UInt64, start: Double, audio: String?, subtitle: String?, subtitles: Bool) -> Bool {
|
func load(
|
||||||
|
media: String,
|
||||||
|
handle: UInt64,
|
||||||
|
start: Double,
|
||||||
|
audio: String?,
|
||||||
|
subtitle: String?,
|
||||||
|
subtitles: Bool,
|
||||||
|
hardwareDecoding: Bool
|
||||||
|
) -> Bool {
|
||||||
guard mpv != nil else { return false }
|
guard mpv != nil else { return false }
|
||||||
|
// Per file, before `loadfile`: a file VideoToolbox decodes wrongly is
|
||||||
|
// fixed from the next episode without restarting the core.
|
||||||
|
setString("hwdec", hardwareDecoding ? "videotoolbox" : "no")
|
||||||
setString("start", start > 0 ? String(format: "%.3f", start) : "none")
|
setString("start", start > 0 ? String(format: "%.3f", start) : "none")
|
||||||
setString("alang", audio ?? "")
|
setString("alang", audio ?? "")
|
||||||
setString("slang", subtitle ?? "")
|
setString("slang", subtitle ?? "")
|
||||||
setString("sid", subtitles ? "auto" : "no")
|
setString("sid", subtitles ? "auto" : "no")
|
||||||
position = 0
|
// Retire the outgoing file's readings *before* the new one loads: mpv
|
||||||
|
// only republishes them once it has demuxed enough of the stream, and
|
||||||
|
// until then every reader would see the last episode's clock under the
|
||||||
|
// new episode's name. Volume, mute and pause are the core's, not the
|
||||||
|
// file's, and carry across.
|
||||||
|
position = start
|
||||||
duration = 0
|
duration = 0
|
||||||
|
endReason = nil
|
||||||
|
buffering = false
|
||||||
|
cachePercent = 0
|
||||||
|
seeking = false
|
||||||
|
cachedUntil = 0
|
||||||
|
videoWidth = 0
|
||||||
|
videoHeight = 0
|
||||||
tracks = []
|
tracks = []
|
||||||
chapters = []
|
chapters = []
|
||||||
failure = nil
|
problem = nil
|
||||||
|
self.media = nil
|
||||||
|
generation += 1
|
||||||
|
pendingMedia = media
|
||||||
loading = true
|
loading = true
|
||||||
if command(["loadfile", "pstr://\(handle)", "replace"]) < 0 {
|
if command(["loadfile", "pstr://\(handle)", "replace"]) < 0 {
|
||||||
|
// Nothing was issued, so no START_FILE will arrive to clear this.
|
||||||
loading = false
|
loading = false
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func setPaused(_ value: Bool) { setFlag("pause", value) }
|
/// Written through rather than left to the observer: a tap that only shows
|
||||||
func seek(to seconds: Double) { setDouble("time-pos", max(0, seconds)) }
|
/// its effect on mpv's next property event reads as a button that missed.
|
||||||
func seek(by seconds: Double) { _ = command(["seek", String(seconds), "relative"]) }
|
func setPaused(_ value: Bool) {
|
||||||
|
setFlag("pause", value)
|
||||||
|
paused = value
|
||||||
|
}
|
||||||
|
|
||||||
|
func seek(to seconds: Double) {
|
||||||
|
setDouble("time-pos", max(0, seconds))
|
||||||
|
seeking = true
|
||||||
|
}
|
||||||
|
|
||||||
func setSpeed(_ value: Double) { setDouble("speed", min(max(value, 0.25), 4)) }
|
func setSpeed(_ value: Double) { setDouble("speed", min(max(value, 0.25), 4)) }
|
||||||
func setVolume(_ value: Double) { setDouble("volume", min(max(value, 0), 100)) }
|
func setVolume(_ value: Double) { setDouble("volume", min(max(value, 0), 100)) }
|
||||||
func setMuted(_ value: Bool) { setFlag("mute", value) }
|
func setMuted(_ value: Bool) { setFlag("mute", value) }
|
||||||
|
|
||||||
func select(audio: Bool, track: MpvTrack?) {
|
/// A track, or none: `nil` turns subtitles off.
|
||||||
let property = audio ? "aid" : "sid"
|
func select(type: String, track: MpvTrack?) {
|
||||||
if let track { setString(property, String(track.id)) } else { setString(property, "no") }
|
setString(type == "audio" ? "aid" : "sid", track.map { String($0.id) } ?? "no")
|
||||||
refreshTracks()
|
refreshTracks()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Showing no picture while backgrounded keeps the audio playing and avoids
|
/// Showing no picture while backgrounded keeps the audio playing: iOS
|
||||||
/// the black frame MoltenVK otherwise leaves on return.
|
/// refuses GPU work from a background app, and would end the process for it.
|
||||||
func setVideoEnabled(_ enabled: Bool) { setString("vid", enabled ? "auto" : "no") }
|
func setVideoEnabled(_ enabled: Bool) { setString("vid", enabled ? "auto" : "no") }
|
||||||
|
|
||||||
/// Stop and tear the core down. `cancel_fn` interrupts a read parked in a
|
/// Stop the open file. The stream behind it is released by mpv closing it,
|
||||||
/// block fetch, and the event thread destroys the core once it is down.
|
/// which this causes; releasing it from here would race that close.
|
||||||
func shutdown() {
|
func stop() {
|
||||||
guard let mpv else { return }
|
_ = command(["stop"])
|
||||||
self.mpv = nil
|
media = nil
|
||||||
mpv_command_string(mpv, "quit")
|
pendingMedia = nil
|
||||||
|
chapters = []
|
||||||
|
tracks = []
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Events
|
// MARK: Events
|
||||||
@@ -153,10 +260,13 @@ final class MpvPlayer {
|
|||||||
private enum Event {
|
private enum Event {
|
||||||
case double(UInt64, Double)
|
case double(UInt64, Double)
|
||||||
case flag(UInt64, Bool)
|
case flag(UInt64, Bool)
|
||||||
case count(UInt64)
|
case int(UInt64, Int64)
|
||||||
|
case unavailable(UInt64)
|
||||||
|
case started
|
||||||
case loaded
|
case loaded
|
||||||
case ended(Int32)
|
case ended(Int64)
|
||||||
case log(String)
|
case restarted
|
||||||
|
case log(prefix: String, text: String)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Runs until the core shuts down, then destroys it. Nothing else calls
|
/// Runs until the core shuts down, then destroys it. Nothing else calls
|
||||||
@@ -170,24 +280,34 @@ final class MpvPlayer {
|
|||||||
mpv_terminate_destroy(mpv)
|
mpv_terminate_destroy(mpv)
|
||||||
return
|
return
|
||||||
case MPV_EVENT_PROPERTY_CHANGE:
|
case MPV_EVENT_PROPERTY_CHANGE:
|
||||||
guard let property = event.data?.assumingMemoryBound(to: mpv_event_property.self).pointee,
|
guard let property = event.data?.assumingMemoryBound(to: mpv_event_property.self).pointee else { break }
|
||||||
let data = property.data else { break }
|
guard let data = property.data else {
|
||||||
|
decoded = .unavailable(event.reply_userdata)
|
||||||
|
break
|
||||||
|
}
|
||||||
switch property.format {
|
switch property.format {
|
||||||
case MPV_FORMAT_DOUBLE: decoded = .double(event.reply_userdata, data.load(as: Double.self))
|
case MPV_FORMAT_DOUBLE: decoded = .double(event.reply_userdata, data.load(as: Double.self))
|
||||||
case MPV_FORMAT_FLAG: decoded = .flag(event.reply_userdata, data.load(as: Int32.self) != 0)
|
case MPV_FORMAT_FLAG: decoded = .flag(event.reply_userdata, data.load(as: Int32.self) != 0)
|
||||||
case MPV_FORMAT_INT64: decoded = .count(event.reply_userdata)
|
case MPV_FORMAT_INT64: decoded = .int(event.reply_userdata, data.load(as: Int64.self))
|
||||||
default: break
|
default: decoded = .unavailable(event.reply_userdata)
|
||||||
}
|
}
|
||||||
|
case MPV_EVENT_START_FILE:
|
||||||
|
decoded = .started
|
||||||
case MPV_EVENT_FILE_LOADED:
|
case MPV_EVENT_FILE_LOADED:
|
||||||
decoded = .loaded
|
decoded = .loaded
|
||||||
|
case MPV_EVENT_PLAYBACK_RESTART:
|
||||||
|
decoded = .restarted
|
||||||
case MPV_EVENT_END_FILE:
|
case MPV_EVENT_END_FILE:
|
||||||
if let end = event.data?.assumingMemoryBound(to: mpv_event_end_file.self).pointee {
|
if let end = event.data?.assumingMemoryBound(to: mpv_event_end_file.self).pointee {
|
||||||
decoded = .ended(end.reason.rawValue)
|
decoded = .ended(Int64(end.reason.rawValue))
|
||||||
}
|
}
|
||||||
case MPV_EVENT_LOG_MESSAGE:
|
case MPV_EVENT_LOG_MESSAGE:
|
||||||
if let message = event.data?.assumingMemoryBound(to: mpv_event_log_message.self).pointee,
|
if let message = event.data?.assumingMemoryBound(to: mpv_event_log_message.self).pointee,
|
||||||
let text = message.text {
|
let text = message.text {
|
||||||
decoded = .log(String(cString: text).trimmingCharacters(in: .whitespacesAndNewlines))
|
decoded = .log(
|
||||||
|
prefix: message.prefix.map { String(cString: $0) } ?? "",
|
||||||
|
text: String(cString: text).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
@@ -202,28 +322,71 @@ final class MpvPlayer {
|
|||||||
switch event {
|
switch event {
|
||||||
case let .double(id, value):
|
case let .double(id, value):
|
||||||
if loading { return }
|
if loading { return }
|
||||||
if id == 1 { position = value } else if id == 2 { duration = value }
|
switch id {
|
||||||
|
case 1:
|
||||||
|
position = value
|
||||||
|
onTick?()
|
||||||
|
case 2:
|
||||||
|
duration = value
|
||||||
|
onStateChanged?()
|
||||||
|
case 4: volume = value
|
||||||
|
case 10: cachedUntil = value
|
||||||
|
default: break
|
||||||
|
}
|
||||||
case let .flag(id, value):
|
case let .flag(id, value):
|
||||||
if id == 3 { paused = value } else if id == 4 { buffering = value }
|
switch id {
|
||||||
case let .count(id):
|
case 3:
|
||||||
|
paused = value
|
||||||
|
onStateChanged?()
|
||||||
|
case 5: muted = value
|
||||||
|
case 8: if !loading { buffering = value }
|
||||||
|
case 13: if !loading { seeking = value }
|
||||||
|
default: break
|
||||||
|
}
|
||||||
|
case let .int(id, value):
|
||||||
if loading { return }
|
if loading { return }
|
||||||
if id == 5 { refreshTracks() } else if id == 6 { refreshChapters() }
|
switch id {
|
||||||
|
case 6: videoWidth = Double(value)
|
||||||
|
case 7: videoHeight = Double(value)
|
||||||
|
case 9: cachePercent = Double(value)
|
||||||
|
case 11: refreshTracks()
|
||||||
|
case 12: refreshChapters()
|
||||||
|
default: break
|
||||||
|
}
|
||||||
|
case let .unavailable(id):
|
||||||
|
if loading { return }
|
||||||
|
if id == 6 || id == 7 { videoWidth = 0; videoHeight = 0 }
|
||||||
|
case .started:
|
||||||
|
// From here on, what mpv reports is the file just loaded.
|
||||||
|
if loading {
|
||||||
|
loading = false
|
||||||
|
media = pendingMedia
|
||||||
|
}
|
||||||
case .loaded:
|
case .loaded:
|
||||||
loading = false
|
|
||||||
refreshTracks()
|
refreshTracks()
|
||||||
refreshChapters()
|
refreshChapters()
|
||||||
|
case .restarted:
|
||||||
|
seeking = false
|
||||||
case let .ended(reason):
|
case let .ended(reason):
|
||||||
// The outgoing file's END_FILE arrives after the next load began.
|
// The outgoing file's END_FILE arrives after the next load began.
|
||||||
if loading { return }
|
if loading { return }
|
||||||
switch reason {
|
let ended: EndReason = switch reason {
|
||||||
case MPV_END_FILE_REASON_EOF.rawValue: onEnded?(.eof)
|
case Int64(MPV_END_FILE_REASON_EOF.rawValue): .eof
|
||||||
case MPV_END_FILE_REASON_ERROR.rawValue:
|
case Int64(MPV_END_FILE_REASON_STOP.rawValue): .stopped
|
||||||
failure = failure ?? "The file could not be played"
|
case Int64(MPV_END_FILE_REASON_QUIT.rawValue): .quit
|
||||||
onEnded?(.failed)
|
case Int64(MPV_END_FILE_REASON_ERROR.rawValue): .failed
|
||||||
default: onEnded?(.stopped)
|
default: .other
|
||||||
}
|
}
|
||||||
case let .log(text):
|
endReason = ended
|
||||||
if !text.isEmpty { failure = text }
|
if ended == .failed { problem = problem ?? "This episode could not be played." }
|
||||||
|
if let media { onEnded?(ended, media) }
|
||||||
|
case let .log(prefix, text):
|
||||||
|
// The outgoing file's complaints are not the new one's.
|
||||||
|
if loading { return }
|
||||||
|
// The first complaint per file, and not ffmpeg's: its decoders log
|
||||||
|
// recoverable glitches at error level all through a healthy file.
|
||||||
|
if text.isEmpty || prefix.hasPrefix("ffmpeg") || problem != nil { return }
|
||||||
|
problem = text
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,567 @@
|
|||||||
|
import AVFoundation
|
||||||
|
import Foundation
|
||||||
|
import MediaPlayer
|
||||||
|
import Observation
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// How long the "up next" card counts down before it loads the next episode.
|
||||||
|
let upNextSeconds = 10
|
||||||
|
|
||||||
|
/// Past this much of an episode it counts as watched.
|
||||||
|
private let watchedFraction = 0.9
|
||||||
|
|
||||||
|
/// How an episode is named in watch state and player readings.
|
||||||
|
func mediaKey(_ episode: EpisodeRecord) -> String { "\(episode.shareId):\(episode.linkId)" }
|
||||||
|
|
||||||
|
/// The open episode's stream, from Rust: opened, handed to mpv, given back.
|
||||||
|
///
|
||||||
|
/// The native handle belongs to mpv once a load succeeds — `close_fn` in the
|
||||||
|
/// stream protocol releases it after mpv's last read. Only a load that never
|
||||||
|
/// happened leaves it here to release.
|
||||||
|
private final class StreamSession {
|
||||||
|
let episode: EpisodeRecord
|
||||||
|
private(set) var handle: UInt64?
|
||||||
|
private(set) var size: UInt64 = 0
|
||||||
|
|
||||||
|
init(episode: EpisodeRecord) {
|
||||||
|
self.episode = episode
|
||||||
|
}
|
||||||
|
|
||||||
|
func open() async throws {
|
||||||
|
let stream = try await NativeRuntime.engine().openStream(shareId: episode.shareId, volumeId: episode.volumeId, linkId: episode.linkId)
|
||||||
|
size = stream.size()
|
||||||
|
handle = stream.nativeHandle()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The player now owns the published handle until stop, end or close.
|
||||||
|
func transferToPlayer() {
|
||||||
|
handle = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Give the episode's stream back to Rust, from a task of its own: the one
|
||||||
|
/// moment this has to run is when the player moves on, and nothing that
|
||||||
|
/// goes away with it may cancel it.
|
||||||
|
func close() {
|
||||||
|
if let handle { pstr_android_stream_release(handle) }
|
||||||
|
handle = nil
|
||||||
|
let episode = episode
|
||||||
|
Task.detached {
|
||||||
|
try? await NativeRuntime.engine().releaseStream(shareId: episode.shareId, volumeId: episode.volumeId, linkId: episode.linkId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the player has open and everything that walks it: the stream per
|
||||||
|
/// episode, the resume position, autoplay, auto-skip, the lock screen and the
|
||||||
|
/// audio session.
|
||||||
|
///
|
||||||
|
/// Android splits this between `PlayerScreen`, `NativeMpvHost` and
|
||||||
|
/// `PlaybackService`. Here it is one object that outlives the player's view,
|
||||||
|
/// because the episode keeps playing while the view is minimised or the app
|
||||||
|
/// is in the background.
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
final class PlayerHost {
|
||||||
|
static let shared = PlayerHost()
|
||||||
|
|
||||||
|
/// The core, once something has been played. Nil if libmpv would not start.
|
||||||
|
private(set) var player: MpvPlayer?
|
||||||
|
private(set) var unavailable = false
|
||||||
|
private(set) var title: TitleRecord?
|
||||||
|
private(set) var episodes: [EpisodeRecord] = []
|
||||||
|
private(set) var index = 0
|
||||||
|
/// Playing, but not on screen: the episode keeps going and the mini
|
||||||
|
/// transport is what says so.
|
||||||
|
var minimized = false
|
||||||
|
/// The open file's chapters, already resolved into openings and endings.
|
||||||
|
private(set) var chapters = ChapterPlan(entries: [], creditsStart: nil)
|
||||||
|
/// What stopped this episode, until the viewer dismisses it.
|
||||||
|
var playbackError: String?
|
||||||
|
/// The stream is being opened: the share session, the revision and its
|
||||||
|
/// block list, all before mpv has a byte. On an old iPad that is seconds
|
||||||
|
/// of a black screen unless something says so.
|
||||||
|
private(set) var opening = false
|
||||||
|
/// Read once per episode: a preference the viewer changes mid-episode
|
||||||
|
/// applies to the next one.
|
||||||
|
private(set) var autoSkip = false
|
||||||
|
private(set) var autoplay = true
|
||||||
|
/// The rate playing, so the speed list opens on it.
|
||||||
|
var speed = 1.0
|
||||||
|
|
||||||
|
/// Persist where an episode was left: position, duration, watched.
|
||||||
|
@ObservationIgnored var onSaveProgress: ((EpisodeRecord, Double, Double, Bool) -> Void)?
|
||||||
|
/// The player closed: where it stopped is what another device wants next.
|
||||||
|
@ObservationIgnored var onClosed: (() -> Void)?
|
||||||
|
|
||||||
|
@ObservationIgnored private var session: StreamSession?
|
||||||
|
@ObservationIgnored private var loadTask: Task<Void, Never>?
|
||||||
|
@ObservationIgnored private var saveTask: Task<Void, Never>?
|
||||||
|
@ObservationIgnored private var plannedFor: (Double, Int)?
|
||||||
|
/// Chapters already auto-skipped in this episode: seeking back into an
|
||||||
|
/// opening on purpose must not be undone by the setting that skipped it.
|
||||||
|
@ObservationIgnored private var skipped = Set<Int64>()
|
||||||
|
@ObservationIgnored private var pausedByInterruption = false
|
||||||
|
@ObservationIgnored private var artworkFor: String?
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
observeAudioSession()
|
||||||
|
configureRemoteCommands()
|
||||||
|
}
|
||||||
|
|
||||||
|
var episode: EpisodeRecord? { episodes.indices.contains(index) ? episodes[index] : nil }
|
||||||
|
var key: String? { episode.map(mediaKey) }
|
||||||
|
var next: EpisodeRecord? { episodes.indices.contains(index + 1) ? episodes[index + 1] : nil }
|
||||||
|
/// Whether mpv's readings — position, chapters — are the open episode's.
|
||||||
|
/// Not while the next one opens: the last one's credits go on playing
|
||||||
|
/// until it is loaded, and read against the new index they offered the
|
||||||
|
/// episode *after* the next as up next.
|
||||||
|
var playingOpenEpisode: Bool { player?.media != nil && player?.media == key }
|
||||||
|
var isOpen: Bool { title != nil }
|
||||||
|
|
||||||
|
// MARK: - Opening
|
||||||
|
|
||||||
|
/// Open a title's episodes at `index`, or move to it if the title is open.
|
||||||
|
func play(_ title: TitleRecord, at index: Int) {
|
||||||
|
let episodes = title.playlist
|
||||||
|
guard !episodes.isEmpty else { return }
|
||||||
|
let target = min(max(index, 0), episodes.count - 1)
|
||||||
|
// The same episode, still open: only bring it back on screen. One that
|
||||||
|
// has ended has nothing loaded, and is played again.
|
||||||
|
if self.title?.key == title.key, mediaKey(episodes[target]) == key, player?.endReason == nil {
|
||||||
|
self.title = title
|
||||||
|
self.episodes = episodes
|
||||||
|
minimized = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
save()
|
||||||
|
self.title = title
|
||||||
|
self.episodes = episodes
|
||||||
|
self.index = target
|
||||||
|
minimized = false
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keep the open title's records current after the library reloads, so the
|
||||||
|
/// episode list shows what was just watched.
|
||||||
|
func update(titles: [TitleRecord]) {
|
||||||
|
guard let key = title?.key, let fresh = titles.first(where: { $0.key == key }) else { return }
|
||||||
|
let current = self.key
|
||||||
|
title = fresh
|
||||||
|
episodes = fresh.playlist
|
||||||
|
if let current, let moved = episodes.firstIndex(where: { mediaKey($0) == current }) { index = moved }
|
||||||
|
}
|
||||||
|
|
||||||
|
func advance() {
|
||||||
|
guard next != nil else { return }
|
||||||
|
move(to: index + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func retreat() {
|
||||||
|
guard index > 0 else { return }
|
||||||
|
move(to: index - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func jump(to target: Int) {
|
||||||
|
guard target != index, episodes.indices.contains(target) else { return }
|
||||||
|
move(to: target)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func move(to target: Int) {
|
||||||
|
save()
|
||||||
|
index = target
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop playback and close the player. The mini transport's ✕ and the
|
||||||
|
/// player's own are the one place it is unambiguous.
|
||||||
|
func close() {
|
||||||
|
save()
|
||||||
|
loadTask?.cancel()
|
||||||
|
saveTask?.cancel()
|
||||||
|
player?.stop()
|
||||||
|
session?.close()
|
||||||
|
session = nil
|
||||||
|
title = nil
|
||||||
|
episodes = []
|
||||||
|
index = 0
|
||||||
|
minimized = false
|
||||||
|
playbackError = nil
|
||||||
|
opening = false
|
||||||
|
chapters = ChapterPlan(entries: [], creditsStart: nil)
|
||||||
|
OrientationLock.set(false)
|
||||||
|
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
|
||||||
|
UIApplication.shared.isIdleTimerDisabled = false
|
||||||
|
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
||||||
|
onClosed?()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func load() {
|
||||||
|
guard let title, let episode else { return }
|
||||||
|
loadTask?.cancel()
|
||||||
|
session?.close()
|
||||||
|
playbackError = nil
|
||||||
|
chapters = ChapterPlan(entries: [], creditsStart: nil)
|
||||||
|
plannedFor = nil
|
||||||
|
skipped = []
|
||||||
|
let session = StreamSession(episode: episode)
|
||||||
|
self.session = session
|
||||||
|
let key = mediaKey(episode)
|
||||||
|
let titleKey = title.key
|
||||||
|
publishNowPlaying()
|
||||||
|
if player == nil, !unavailable {
|
||||||
|
player = MpvPlayer.create()
|
||||||
|
unavailable = player == nil
|
||||||
|
player?.onEnded = { [weak self] reason, media in self?.ended(reason, media) }
|
||||||
|
player?.onTick = { [weak self] in self?.tick() }
|
||||||
|
player?.onStateChanged = { [weak self] in self?.publishNowPlaying() }
|
||||||
|
}
|
||||||
|
guard let player else {
|
||||||
|
playbackError = "Playback is unavailable: libmpv did not start."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try? AVAudioSession.sharedInstance().setActive(true)
|
||||||
|
opening = true
|
||||||
|
loadTask = Task {
|
||||||
|
do {
|
||||||
|
try await session.open()
|
||||||
|
let startup = try await Task.detached {
|
||||||
|
let engine = try NativeRuntime.blockingEngine()
|
||||||
|
// A choice made on this title wins; without one, the choice
|
||||||
|
// the viewer made anywhere else does — the desktop client's
|
||||||
|
// fallback chain.
|
||||||
|
let show = try engine.titleTrackPreferences(titleKey: titleKey)
|
||||||
|
let global = try engine.playbackPrefs()
|
||||||
|
let watch = try engine.watchState(shareId: episode.shareId, linkId: episode.linkId)
|
||||||
|
return (show, global, watch)
|
||||||
|
}.value
|
||||||
|
if Task.isCancelled || self.session !== session {
|
||||||
|
abandon(session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let (show, global, watch) = startup
|
||||||
|
guard let handle = session.handle, session.size > 0 else { throw PlaybackFailure("Cannot play an empty stream") }
|
||||||
|
autoSkip = global.autoSkip
|
||||||
|
autoplay = global.autoplayNext
|
||||||
|
speed = global.speed
|
||||||
|
let loaded = player.load(
|
||||||
|
media: key,
|
||||||
|
handle: handle,
|
||||||
|
// A finished episode restarts rather than resuming three
|
||||||
|
// seconds from its own credits.
|
||||||
|
start: watch.flatMap { $0.watched ? nil : $0.positionSecs } ?? 0,
|
||||||
|
audio: show?.audioLanguage ?? global.audioLanguage,
|
||||||
|
subtitle: show?.subtitleLanguage ?? global.subtitleLanguage,
|
||||||
|
subtitles: show?.subtitles ?? global.subtitles,
|
||||||
|
hardwareDecoding: SettingsStore().hardwareDecoding
|
||||||
|
)
|
||||||
|
guard loaded else { throw PlaybackFailure("libmpv rejected the stream") }
|
||||||
|
// mpv's own buffering takes over the spinner from here.
|
||||||
|
opening = false
|
||||||
|
session.transferToPlayer()
|
||||||
|
// After the load, because mpv keeps none of the three across one.
|
||||||
|
player.setVolume(global.volume)
|
||||||
|
player.setMuted(global.muted)
|
||||||
|
player.setSpeed(global.speed)
|
||||||
|
player.setPaused(false)
|
||||||
|
startSaving()
|
||||||
|
} catch {
|
||||||
|
if Task.isCancelled || self.session !== session {
|
||||||
|
abandon(session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
opening = false
|
||||||
|
playbackError = errorMessage(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A session the player moved past while it was still opening. Its
|
||||||
|
/// `close()` already ran, before there was a handle to release, so the one
|
||||||
|
/// it has published since is released here — and only that, since the
|
||||||
|
/// stream itself was already given back.
|
||||||
|
private func abandon(_ session: StreamSession) {
|
||||||
|
if let handle = session.handle {
|
||||||
|
pstr_android_stream_release(handle)
|
||||||
|
session.transferToPlayer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct PlaybackFailure: LocalizedError {
|
||||||
|
let errorDescription: String?
|
||||||
|
init(_ message: String) { errorDescription = message }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Progress
|
||||||
|
|
||||||
|
/// Resume position, written only from readings that carry this episode's
|
||||||
|
/// name: an unattributed reading is the previous one's clock.
|
||||||
|
func save() {
|
||||||
|
guard let player, let episode, player.media == mediaKey(episode), player.duration > 0 else { return }
|
||||||
|
onSaveProgress?(
|
||||||
|
episode,
|
||||||
|
min(max(player.position, 0), player.duration),
|
||||||
|
player.duration,
|
||||||
|
// Watched means played to the end. An episode that was stopped, or
|
||||||
|
// that mpv could not play at all, also "ended" — recording either
|
||||||
|
// as watched retires an episode nobody saw.
|
||||||
|
player.endReason == .eof || player.position >= player.duration * watchedFraction
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startSaving() {
|
||||||
|
saveTask?.cancel()
|
||||||
|
saveTask = Task {
|
||||||
|
while !Task.isCancelled {
|
||||||
|
try? await Task.sleep(for: .seconds(5))
|
||||||
|
if Task.isCancelled { return }
|
||||||
|
save()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Reacting to the player
|
||||||
|
|
||||||
|
private func ended(_ reason: EndReason, _ media: String) {
|
||||||
|
guard media == key else { return }
|
||||||
|
save()
|
||||||
|
// Autoplay fires on a clean end of file only — never on a failure.
|
||||||
|
if reason == .eof, autoplay, next != nil {
|
||||||
|
advance()
|
||||||
|
} else if reason == .failed {
|
||||||
|
playbackError = player?.problem ?? "This episode could not be played."
|
||||||
|
}
|
||||||
|
publishNowPlaying()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func tick() {
|
||||||
|
guard let player, playingOpenEpisode else { return }
|
||||||
|
replanChapters(player)
|
||||||
|
// Auto-skip, once per chapter.
|
||||||
|
if autoSkip, !player.paused, let offer = skipOffer(plan: chapters, position: player.position),
|
||||||
|
let chapter = chapters.entries.last(where: { player.position + 0.001 >= $0.start }),
|
||||||
|
skipped.insert(chapter.index).inserted {
|
||||||
|
player.seek(to: offer.target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-read the chapters when there is a reason to: a different file, or a
|
||||||
|
/// duration that has only just arrived. Both change the answer — the length
|
||||||
|
/// of a chapter is what settles whether `Intro` is an opening or ten minutes
|
||||||
|
/// of story — and neither changes again after that.
|
||||||
|
private func replanChapters(_ player: MpvPlayer) {
|
||||||
|
let wanted = (player.duration, player.generation)
|
||||||
|
if let plannedFor, plannedFor == wanted, !(chapters.entries.isEmpty && !player.chapters.isEmpty) { return }
|
||||||
|
plannedFor = wanted
|
||||||
|
chapters = chapterPlan(chapters: player.chapters, duration: player.duration > 0 ? player.duration : nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The skip button's offer at the current position, if any.
|
||||||
|
var offer: SkipOffer? {
|
||||||
|
guard let player, playingOpenEpisode else { return nil }
|
||||||
|
return skipOffer(plan: chapters, position: player.position)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Transport
|
||||||
|
|
||||||
|
func setPaused(_ paused: Bool) {
|
||||||
|
player?.setPaused(paused)
|
||||||
|
if !paused { try? AVAudioSession.sharedInstance().setActive(true) }
|
||||||
|
publishNowPlaying()
|
||||||
|
}
|
||||||
|
|
||||||
|
func seek(to seconds: Double) {
|
||||||
|
player?.seek(to: seconds)
|
||||||
|
updateElapsed(seconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persist a volume or a mute, leaving whichever was not given as it was.
|
||||||
|
/// Written when the thumb is released rather than while it moves.
|
||||||
|
func rememberVolume(volume: Double? = nil, muted: Bool? = nil) {
|
||||||
|
Task.detached {
|
||||||
|
guard let engine = try? NativeRuntime.blockingEngine(), var prefs = try? engine.playbackPrefs() else { return }
|
||||||
|
prefs.volume = volume ?? prefs.volume
|
||||||
|
prefs.muted = muted ?? prefs.muted
|
||||||
|
try? engine.setPlaybackPrefs(prefs: prefs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persist a playback rate, so the next episode starts at it.
|
||||||
|
func setSpeed(_ rate: Double) {
|
||||||
|
speed = rate
|
||||||
|
player?.setSpeed(rate)
|
||||||
|
publishNowPlaying()
|
||||||
|
Task.detached {
|
||||||
|
guard let engine = try? NativeRuntime.blockingEngine(), var prefs = try? engine.playbackPrefs() else { return }
|
||||||
|
prefs.speed = rate
|
||||||
|
try? engine.setPlaybackPrefs(prefs: prefs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A track for this title. The choice is the title's from now on.
|
||||||
|
func select(type: String, track: MpvTrack?) {
|
||||||
|
player?.select(type: type, track: track)
|
||||||
|
guard let titleKey = title?.key else { return }
|
||||||
|
Task.detached {
|
||||||
|
guard let engine = try? NativeRuntime.blockingEngine(), let global = try? engine.playbackPrefs() else { return }
|
||||||
|
// No per-title choice yet: start from what plays everywhere else,
|
||||||
|
// so choosing an audio track does not silently drop the subtitle
|
||||||
|
// preference this episode started with.
|
||||||
|
var preferences = (try? engine.titleTrackPreferences(titleKey: titleKey)) ?? TrackPreferencesRecord(
|
||||||
|
audioLanguage: global.audioLanguage,
|
||||||
|
subtitleLanguage: global.subtitleLanguage,
|
||||||
|
subtitles: global.subtitles
|
||||||
|
)
|
||||||
|
let language = track.flatMap { $0.language.trimmingCharacters(in: .whitespaces).isEmpty ? nil : $0.language }
|
||||||
|
if type == "audio" {
|
||||||
|
preferences.audioLanguage = language
|
||||||
|
} else {
|
||||||
|
// Turning subtitles off keeps the language it was off *from*, so
|
||||||
|
// turning them back on does not have to be told it again.
|
||||||
|
preferences.subtitleLanguage = language ?? preferences.subtitleLanguage
|
||||||
|
preferences.subtitles = track != nil
|
||||||
|
}
|
||||||
|
try? engine.setTitleTrackPreferences(titleKey: titleKey, preferences: preferences)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func dismissProblem() {
|
||||||
|
playbackError = nil
|
||||||
|
player?.problem = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Background
|
||||||
|
|
||||||
|
/// Leaving the screen: the picture goes, the sound stays if the viewer
|
||||||
|
/// asked for background audio, and the position is saved either way.
|
||||||
|
func enteredBackground() {
|
||||||
|
save()
|
||||||
|
guard isOpen, let player else { return }
|
||||||
|
if SettingsStore().backgroundAudio {
|
||||||
|
player.setVideoEnabled(false)
|
||||||
|
} else {
|
||||||
|
player.setPaused(true)
|
||||||
|
}
|
||||||
|
publishNowPlaying()
|
||||||
|
}
|
||||||
|
|
||||||
|
func enteredForeground() {
|
||||||
|
player?.setVideoEnabled(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Lock screen
|
||||||
|
|
||||||
|
/// What the lock screen and Control Center show: the episode, its show and
|
||||||
|
/// its art — never just the app.
|
||||||
|
private func publishNowPlaying() {
|
||||||
|
guard let title, let episode else {
|
||||||
|
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let center = MPNowPlayingInfoCenter.default()
|
||||||
|
var info = center.nowPlayingInfo ?? [:]
|
||||||
|
info[MPMediaItemPropertyTitle] = episode.label
|
||||||
|
info[MPMediaItemPropertyArtist] = title.displayName
|
||||||
|
info[MPMediaItemPropertyAlbumTitle] = title.displayName
|
||||||
|
info[MPNowPlayingInfoPropertyMediaType] = MPNowPlayingInfoMediaType.video.rawValue
|
||||||
|
info[MPMediaItemPropertyPlaybackDuration] = player?.duration ?? 0
|
||||||
|
info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = player?.position ?? 0
|
||||||
|
info[MPNowPlayingInfoPropertyPlaybackRate] = (player?.paused ?? true) ? 0.0 : speed
|
||||||
|
center.nowPlayingInfo = info
|
||||||
|
let commands = MPRemoteCommandCenter.shared()
|
||||||
|
commands.nextTrackCommand.isEnabled = next != nil
|
||||||
|
commands.previousTrackCommand.isEnabled = index > 0
|
||||||
|
UIApplication.shared.isIdleTimerDisabled = !(player?.paused ?? true) && !minimized
|
||||||
|
|
||||||
|
let artKey = mediaKey(episode)
|
||||||
|
if artworkFor != artKey {
|
||||||
|
artworkFor = artKey
|
||||||
|
info[MPMediaItemPropertyArtwork] = nil
|
||||||
|
center.nowPlayingInfo = info
|
||||||
|
let url = title.posterUrl ?? title.backdropUrl
|
||||||
|
let source = episode.thumbnailSource
|
||||||
|
Task {
|
||||||
|
guard let image = await ArtworkLoader.shared.artwork(url: url, fallback: source), artworkFor == artKey else { return }
|
||||||
|
var current = center.nowPlayingInfo ?? [:]
|
||||||
|
current[MPMediaItemPropertyArtwork] = MPMediaItemArtwork(boundsSize: image.size) { _ in image }
|
||||||
|
center.nowPlayingInfo = current
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateElapsed(_ seconds: Double) {
|
||||||
|
let center = MPNowPlayingInfoCenter.default()
|
||||||
|
guard var info = center.nowPlayingInfo else { return }
|
||||||
|
info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = seconds
|
||||||
|
center.nowPlayingInfo = info
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureRemoteCommands() {
|
||||||
|
let commands = MPRemoteCommandCenter.shared()
|
||||||
|
commands.playCommand.addTarget { [weak self] _ in
|
||||||
|
MainActor.assumeIsolated { self?.setPaused(false) }
|
||||||
|
return .success
|
||||||
|
}
|
||||||
|
commands.pauseCommand.addTarget { [weak self] _ in
|
||||||
|
MainActor.assumeIsolated { self?.setPaused(true) }
|
||||||
|
return .success
|
||||||
|
}
|
||||||
|
commands.togglePlayPauseCommand.addTarget { [weak self] _ in
|
||||||
|
MainActor.assumeIsolated { self?.setPaused(!(self?.player?.paused ?? true)) }
|
||||||
|
return .success
|
||||||
|
}
|
||||||
|
commands.changePlaybackPositionCommand.addTarget { [weak self] event in
|
||||||
|
guard let event = event as? MPChangePlaybackPositionCommandEvent else { return .commandFailed }
|
||||||
|
MainActor.assumeIsolated { self?.seek(to: event.positionTime) }
|
||||||
|
return .success
|
||||||
|
}
|
||||||
|
commands.nextTrackCommand.addTarget { [weak self] _ in
|
||||||
|
MainActor.assumeIsolated { self?.advance() }
|
||||||
|
return .success
|
||||||
|
}
|
||||||
|
commands.previousTrackCommand.addTarget { [weak self] _ in
|
||||||
|
MainActor.assumeIsolated { self?.retreat() }
|
||||||
|
return .success
|
||||||
|
}
|
||||||
|
commands.stopCommand.addTarget { [weak self] _ in
|
||||||
|
MainActor.assumeIsolated { self?.close() }
|
||||||
|
return .success
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A call or an alarm pauses, and its end resumes only what this paused —
|
||||||
|
/// Android's transient focus loss. Headphones coming out pause, as
|
||||||
|
/// `BECOMING_NOISY` does there.
|
||||||
|
private func observeAudioSession() {
|
||||||
|
let center = NotificationCenter.default
|
||||||
|
center.addObserver(forName: AVAudioSession.interruptionNotification, object: nil, queue: .main) { [weak self] note in
|
||||||
|
guard let raw = note.userInfo?[AVAudioSessionInterruptionTypeKey] as? UInt,
|
||||||
|
let type = AVAudioSession.InterruptionType(rawValue: raw)
|
||||||
|
else { return }
|
||||||
|
let options = (note.userInfo?[AVAudioSessionInterruptionOptionKey] as? UInt).map(AVAudioSession.InterruptionOptions.init)
|
||||||
|
MainActor.assumeIsolated {
|
||||||
|
guard let self, let player = self.player, self.isOpen else { return }
|
||||||
|
switch type {
|
||||||
|
case .began:
|
||||||
|
if !player.paused {
|
||||||
|
self.pausedByInterruption = true
|
||||||
|
self.setPaused(true)
|
||||||
|
}
|
||||||
|
case .ended:
|
||||||
|
if self.pausedByInterruption, options?.contains(.shouldResume) == true { self.setPaused(false) }
|
||||||
|
self.pausedByInterruption = false
|
||||||
|
@unknown default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
center.addObserver(forName: AVAudioSession.routeChangeNotification, object: nil, queue: .main) { [weak self] note in
|
||||||
|
guard let raw = note.userInfo?[AVAudioSessionRouteChangeReasonKey] as? UInt,
|
||||||
|
AVAudioSession.RouteChangeReason(rawValue: raw) == .oldDeviceUnavailable
|
||||||
|
else { return }
|
||||||
|
MainActor.assumeIsolated {
|
||||||
|
guard let self, self.isOpen else { return }
|
||||||
|
self.setPaused(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,633 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// How long the controls stay up after a tap.
|
||||||
|
private let controlsSeconds = 4.0
|
||||||
|
|
||||||
|
/// The narrowest layout the in-app volume slider is worth the room it costs.
|
||||||
|
/// Below it — a phone held upright — the hardware volume buttons are the
|
||||||
|
/// better answer.
|
||||||
|
private let volumeSliderMinWidth: CGFloat = 600
|
||||||
|
|
||||||
|
/// The rates worth offering. A short list rather than a slider: the useful
|
||||||
|
/// rates are a handful of ratios, and a slider lands on 1.07 as often as 1.0.
|
||||||
|
private let speeds = [0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0]
|
||||||
|
|
||||||
|
/// The player, over the whole window, notch included.
|
||||||
|
///
|
||||||
|
/// The episode itself lives in `PlayerHost`, so this view can go — minimised to
|
||||||
|
/// the mini transport, or with the app in the background — while it plays on.
|
||||||
|
struct PlayerView: View {
|
||||||
|
let host: PlayerHost
|
||||||
|
let player: MpvPlayer
|
||||||
|
@Environment(\.scheme) private var scheme
|
||||||
|
@State private var showControls = true
|
||||||
|
@State private var hideTask: Task<Void, Never>?
|
||||||
|
@State private var landscapeLocked = OrientationLock.landscape
|
||||||
|
@State private var sheet: PlayerSheet?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
GeometryReader { geometry in
|
||||||
|
ZStack {
|
||||||
|
Color.black.ignoresSafeArea()
|
||||||
|
MpvView(player: player).ignoresSafeArea()
|
||||||
|
Color.clear
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.ignoresSafeArea()
|
||||||
|
.onTapGesture { setControls(!showControls) }
|
||||||
|
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
if showControls { topBar.transition(.opacity) }
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
if showControls {
|
||||||
|
PlayerControls(
|
||||||
|
host: host,
|
||||||
|
player: player,
|
||||||
|
roomForVolume: geometry.size.width >= volumeSliderMinWidth,
|
||||||
|
landscapeLocked: landscapeLocked,
|
||||||
|
onRotate: { landscapeLocked = OrientationLock.toggle() },
|
||||||
|
onSheet: { sheet = $0 },
|
||||||
|
onInteract: { setControls(true) }
|
||||||
|
)
|
||||||
|
.transition(.opacity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drawn whether or not the rest of the controls are up: an
|
||||||
|
// opening lasts ninety seconds, and a button you have to wake
|
||||||
|
// with a tap is one nobody reaches in time.
|
||||||
|
VStack(alignment: .trailing, spacing: 8) {
|
||||||
|
Spacer()
|
||||||
|
if let offer = host.offer, !host.autoSkip {
|
||||||
|
Button(offer.label) { host.seek(to: offer.target) }.buttonStyle(.tonal)
|
||||||
|
}
|
||||||
|
UpNextCard(
|
||||||
|
next: host.next,
|
||||||
|
visible: host.autoplay && !player.paused && host.playingOpenEpisode
|
||||||
|
&& host.chapters.creditsStart.map { player.position >= $0 } == true,
|
||||||
|
onPlayNow: host.advance
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||||
|
.padding(.trailing, 16)
|
||||||
|
.padding(.bottom, showControls ? 132 : 24)
|
||||||
|
|
||||||
|
// Only while nothing else explains the still picture: an error
|
||||||
|
// is the more useful thing to say.
|
||||||
|
if (host.opening || player.stalled) && host.playbackError == nil {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
ProgressView().controlSize(.large).tint(scheme.primary)
|
||||||
|
if host.opening {
|
||||||
|
Text("Opening…").textStyle(.labelMedium).foregroundStyle(scheme.onSurface)
|
||||||
|
} else if player.buffering {
|
||||||
|
Text(player.cachePercent > 0 ? "Buffering… \(Int(player.cachePercent.rounded()))%" : "Buffering…")
|
||||||
|
.textStyle(.labelMedium).foregroundStyle(scheme.onSurface)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(20)
|
||||||
|
.background(scheme.surfaceContainerHighest, in: RoundedRectangle(cornerRadius: Corner.standard))
|
||||||
|
}
|
||||||
|
|
||||||
|
if let message = host.playbackError ?? player.problem {
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
Text(message).foregroundStyle(scheme.onErrorContainer).multilineTextAlignment(.center)
|
||||||
|
// Dismissible, because the message is not always fatal
|
||||||
|
// — a subtitle track that failed to load leaves an
|
||||||
|
// episode that plays perfectly well behind it.
|
||||||
|
Button("Dismiss", action: host.dismissProblem)
|
||||||
|
.buttonStyle(.quiet)
|
||||||
|
.tint(scheme.onErrorContainer)
|
||||||
|
}
|
||||||
|
.textStyle(.bodyLarge)
|
||||||
|
.padding(16)
|
||||||
|
.background(scheme.errorContainer, in: RoundedRectangle(cornerRadius: Corner.standard))
|
||||||
|
.padding(32)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.animation(.easeInOut(duration: 0.2), value: showControls)
|
||||||
|
.statusBarHidden(!showControls)
|
||||||
|
.persistentSystemOverlays(.hidden)
|
||||||
|
.onAppear { setControls(true) }
|
||||||
|
.onChange(of: player.paused) { setControls(showControls) }
|
||||||
|
.sheet(item: $sheet) { which in
|
||||||
|
switch which {
|
||||||
|
case .speed: SpeedSheet(current: host.speed, onSelect: host.setSpeed)
|
||||||
|
case .episodes: EpisodeSheet(episodes: host.episodes, playing: host.index, onSelect: host.jump)
|
||||||
|
case .chapters: ChapterSheet(chapters: host.chapters.entries, position: player.position) { host.seek(to: $0.start) }
|
||||||
|
case let .tracks(type):
|
||||||
|
TrackSheet(type: type, tracks: player.tracks.filter { $0.type == type }) { host.select(type: type, track: $0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var topBar: some View {
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
// Two ways out, because they mean different things: this leaves the
|
||||||
|
// video and keeps the episode playing, ✕ ends it.
|
||||||
|
IconButton("chevron.down", "Leave the video", tint: .white) {
|
||||||
|
// The rest of the app is not a landscape app.
|
||||||
|
landscapeLocked = false
|
||||||
|
OrientationLock.set(false)
|
||||||
|
host.minimized = true
|
||||||
|
}
|
||||||
|
// The episode leads and the show follows it: which episode is the
|
||||||
|
// thing a viewer who has just woken the controls is checking.
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
Text(host.episode?.label ?? "").textStyle(.titleMedium).foregroundStyle(.white).lineLimit(1)
|
||||||
|
Text([host.title?.displayName, host.episode?.detail].compactMap { $0 }.filter { !$0.isEmpty }.joined(separator: " • "))
|
||||||
|
.textStyle(.bodySmall).foregroundStyle(.white.opacity(0.7)).lineLimit(1)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
IconButton("xmark", "Stop playback", tint: .white, action: host.close)
|
||||||
|
if player.duration > 0, player.duration - player.position >= 1 {
|
||||||
|
Text("\(clock(player.duration - player.position)) left")
|
||||||
|
.textStyle(.labelMedium).foregroundStyle(.white.opacity(0.7))
|
||||||
|
.padding(.leading, 12).padding(.trailing, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(scheme.scrim.opacity(0.45).ignoresSafeArea(edges: [.top, .horizontal]))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Show or hide the controls; shown and playing, they hide themselves.
|
||||||
|
private func setControls(_ shown: Bool) {
|
||||||
|
showControls = shown
|
||||||
|
hideTask?.cancel()
|
||||||
|
guard shown, !player.paused else { return }
|
||||||
|
hideTask = Task {
|
||||||
|
try? await Task.sleep(for: .seconds(controlsSeconds))
|
||||||
|
if !Task.isCancelled { showControls = false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum PlayerSheet: Identifiable {
|
||||||
|
case speed, episodes, chapters
|
||||||
|
case tracks(String)
|
||||||
|
|
||||||
|
var id: String {
|
||||||
|
switch self {
|
||||||
|
case .speed: "speed"
|
||||||
|
case .episodes: "episodes"
|
||||||
|
case .chapters: "chapters"
|
||||||
|
case let .tracks(type): "tracks-\(type)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everything the transport offers, in two rows: the seek bar on its own,
|
||||||
|
/// then the transport cluster on the left, where a thumb already is, and the
|
||||||
|
/// pickers on the right.
|
||||||
|
private struct PlayerControls: View {
|
||||||
|
let host: PlayerHost
|
||||||
|
let player: MpvPlayer
|
||||||
|
let roomForVolume: Bool
|
||||||
|
let landscapeLocked: Bool
|
||||||
|
let onRotate: () -> Void
|
||||||
|
let onSheet: (PlayerSheet) -> Void
|
||||||
|
let onInteract: () -> Void
|
||||||
|
@Environment(\.scheme) private var scheme
|
||||||
|
// Where the thumb is while a finger is on it: a seek per drag step is a
|
||||||
|
// block fetch per drag step, and the thumb would snap back under the
|
||||||
|
// finger between readings.
|
||||||
|
@State private var scrubbing: Double?
|
||||||
|
@State private var dragVolume: Double?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let duration = max(player.duration, 1)
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Text(clock(scrubbing ?? player.position)).textStyle(.labelMedium).foregroundStyle(.white).monospacedDigit()
|
||||||
|
AccentTrack(
|
||||||
|
value: Binding(
|
||||||
|
get: { scrubbing ?? min(max(player.position, 0), duration) },
|
||||||
|
set: { scrubbing = $0 }
|
||||||
|
),
|
||||||
|
range: 0 ... duration,
|
||||||
|
buffered: player.duration > 0 ? player.cachedUntil / player.duration : 0,
|
||||||
|
ticks: player.duration > 0 && host.chapters.entries.count > 1
|
||||||
|
? host.chapters.entries.dropFirst().map { $0.start / player.duration } : [],
|
||||||
|
onEditing: { editing in
|
||||||
|
onInteract()
|
||||||
|
if !editing, let target = scrubbing {
|
||||||
|
host.seek(to: target)
|
||||||
|
scrubbing = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Text(clock(player.duration)).textStyle(.labelMedium).foregroundStyle(.white).monospacedDigit()
|
||||||
|
}
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
CompactIcon("backward.end.fill", "Previous episode", enabled: host.index > 0, action: host.retreat)
|
||||||
|
CompactIcon("gobackward.10", "Back ten seconds") { host.seek(to: player.position - 10) }
|
||||||
|
Button { host.setPaused(!player.paused); onInteract() } label: {
|
||||||
|
Image(systemName: player.paused ? "play.fill" : "pause.fill")
|
||||||
|
.font(.system(size: 30))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.frame(width: 48, height: 48)
|
||||||
|
}
|
||||||
|
.accessibilityLabel(player.paused ? "Play" : "Pause")
|
||||||
|
CompactIcon("goforward.30", "Forward thirty seconds") { host.seek(to: player.position + 30) }
|
||||||
|
CompactIcon("forward.end.fill", "Next episode", enabled: host.next != nil, action: host.advance)
|
||||||
|
Spacer(minLength: 4)
|
||||||
|
if host.episodes.count > 1 {
|
||||||
|
CompactIcon("rectangle.stack", "Episodes") { onSheet(.episodes) }
|
||||||
|
}
|
||||||
|
if host.chapters.entries.count > 1 {
|
||||||
|
CompactIcon("list.bullet", "Chapters") { onSheet(.chapters) }
|
||||||
|
}
|
||||||
|
CompactIcon("speedometer", "Playback speed") { onSheet(.speed) }
|
||||||
|
CompactIcon("waveform", "Audio track") { onSheet(.tracks("audio")) }
|
||||||
|
CompactIcon("captions.bubble", "Subtitles") { onSheet(.tracks("sub")) }
|
||||||
|
CompactIcon(player.muted ? "speaker.slash.fill" : "speaker.wave.2.fill", "Mute") {
|
||||||
|
player.setMuted(!player.muted)
|
||||||
|
host.rememberVolume(muted: !player.muted)
|
||||||
|
}
|
||||||
|
// The app's own volume, which is not the device's: a file
|
||||||
|
// mastered quiet is turned up here and stays up for the next.
|
||||||
|
if roomForVolume {
|
||||||
|
Slider(
|
||||||
|
value: Binding(
|
||||||
|
get: { dragVolume ?? min(max(player.volume, 0), 100) },
|
||||||
|
set: { dragVolume = $0; player.setVolume($0) }
|
||||||
|
),
|
||||||
|
in: 0 ... 100
|
||||||
|
) { editing in
|
||||||
|
onInteract()
|
||||||
|
if !editing, let chosen = dragVolume {
|
||||||
|
dragVolume = nil
|
||||||
|
host.rememberVolume(volume: chosen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.tint(scheme.primary)
|
||||||
|
.frame(width: 96)
|
||||||
|
}
|
||||||
|
CompactIcon(
|
||||||
|
landscapeLocked ? "arrow.down.right.and.arrow.up.left" : "arrow.up.left.and.arrow.down.right",
|
||||||
|
landscapeLocked ? "Unlock rotation" : "Lock to landscape",
|
||||||
|
action: onRotate
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(scheme.scrim.opacity(0.68).ignoresSafeArea(edges: [.bottom, .horizontal]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct CompactIcon: View {
|
||||||
|
let icon: String
|
||||||
|
let label: String
|
||||||
|
var enabled = true
|
||||||
|
let action: () -> Void
|
||||||
|
|
||||||
|
init(_ icon: String, _ label: String, enabled: Bool = true, action: @escaping () -> Void) {
|
||||||
|
self.icon = icon
|
||||||
|
self.label = label
|
||||||
|
self.enabled = enabled
|
||||||
|
self.action = action
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: action) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: 19))
|
||||||
|
.foregroundStyle(enabled ? Color.white : Color.white.opacity(0.35))
|
||||||
|
.frame(width: 40, height: 40)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(PressableStyle(circle: true, wash: .white))
|
||||||
|
.disabled(!enabled)
|
||||||
|
.accessibilityLabel(label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The countdown into the next episode, shown once the credits run starts.
|
||||||
|
///
|
||||||
|
/// "Watch till the end" holds for the rest of the file: someone who wants the
|
||||||
|
/// post-credits scene should have to say so once, not once every ten seconds.
|
||||||
|
private struct UpNextCard: View {
|
||||||
|
let next: EpisodeRecord?
|
||||||
|
let visible: Bool
|
||||||
|
let onPlayNow: () -> Void
|
||||||
|
@Environment(\.scheme) private var scheme
|
||||||
|
@State private var held = false
|
||||||
|
@State private var remaining = upNextSeconds
|
||||||
|
@State private var heldFor: String?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let showing = visible && next != nil && !(held && heldFor == next?.linkId)
|
||||||
|
Group {
|
||||||
|
if showing, let upNext = next {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
// The next episode's still, so the card says which
|
||||||
|
// episode without being read; the ring over it is both
|
||||||
|
// the countdown and the button that skips it.
|
||||||
|
Button(action: onPlayNow) {
|
||||||
|
RemoteArtwork(upNext.stillUrl, upNext.label, fallback: upNext.thumbnailSource, labelled: false)
|
||||||
|
.frame(width: 112)
|
||||||
|
.aspectRatio(16 / 9, contentMode: .fit)
|
||||||
|
.overlay {
|
||||||
|
ZStack {
|
||||||
|
Circle().fill(.black.opacity(0.5))
|
||||||
|
Circle().stroke(.white.opacity(0.25), lineWidth: 3)
|
||||||
|
Circle()
|
||||||
|
.trim(from: 0, to: Double(remaining) / Double(upNextSeconds))
|
||||||
|
.stroke(.white, style: StrokeStyle(lineWidth: 3, lineCap: .round))
|
||||||
|
.rotationEffect(.degrees(-90))
|
||||||
|
.animation(.linear(duration: 1), value: remaining)
|
||||||
|
Image(systemName: "play.fill").foregroundStyle(.white)
|
||||||
|
}
|
||||||
|
.frame(width: 40, height: 40)
|
||||||
|
}
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: Corner.standard))
|
||||||
|
}
|
||||||
|
.buttonStyle(PressableStyle(wash: .white))
|
||||||
|
.accessibilityLabel("Play now")
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Up next in \(remaining)s").textStyle(.labelMedium).foregroundStyle(scheme.onSurfaceVariant)
|
||||||
|
Text(upNext.providerName ?? upNext.label).textStyle(.titleSmall).foregroundStyle(scheme.onSurface).lineLimit(2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button("Watch till the end") {
|
||||||
|
held = true
|
||||||
|
heldFor = upNext.linkId
|
||||||
|
}
|
||||||
|
.buttonStyle(.quiet)
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.frame(width: 280)
|
||||||
|
.background(scheme.surfaceContainerHighest, in: RoundedRectangle(cornerRadius: Corner.standard))
|
||||||
|
.task(id: upNext.linkId) {
|
||||||
|
remaining = upNextSeconds
|
||||||
|
while remaining > 0 {
|
||||||
|
try? await Task.sleep(for: .seconds(1))
|
||||||
|
if Task.isCancelled { return }
|
||||||
|
remaining -= 1
|
||||||
|
}
|
||||||
|
onPlayNow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct SpeedSheet: View {
|
||||||
|
let current: Double
|
||||||
|
let onSelect: (Double) -> Void
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
DialogSheet("Playback speed") {
|
||||||
|
ForEach(speeds, id: \.self) { rate in
|
||||||
|
Button {
|
||||||
|
onSelect(rate)
|
||||||
|
dismiss()
|
||||||
|
} label: {
|
||||||
|
Text((abs(rate - current) < 0.001 ? "✓ " : "") + (rate == 1 ? "Normal" : "\(rate.formatted())×"))
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.edgedWide)
|
||||||
|
}
|
||||||
|
} buttons: {
|
||||||
|
Button("Close") { dismiss() }.buttonStyle(.tonal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ChapterSheet: View {
|
||||||
|
let chapters: [ChapterEntry]
|
||||||
|
let position: Double
|
||||||
|
let onSelect: (ChapterEntry) -> Void
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let current = chapters.lastIndex { position + 0.001 >= $0.start }
|
||||||
|
DialogSheet("Chapters") {
|
||||||
|
ForEach(Array(chapters.enumerated()), id: \.element.index) { position, chapter in
|
||||||
|
Button {
|
||||||
|
onSelect(chapter)
|
||||||
|
dismiss()
|
||||||
|
} label: {
|
||||||
|
Text((position == current ? "✓ " : "") + "\(clock(chapter.start)) \(chapter.label)")
|
||||||
|
.lineLimit(1)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
.buttonStyle(.edgedWide)
|
||||||
|
}
|
||||||
|
} buttons: {
|
||||||
|
Button("Close") { dismiss() }.buttonStyle(.tonal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct TrackSheet: View {
|
||||||
|
let type: String
|
||||||
|
let tracks: [MpvTrack]
|
||||||
|
let onSelect: (MpvTrack?) -> Void
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
DialogSheet(type == "audio" ? "Audio track" : "Subtitle track") {
|
||||||
|
if type == "sub" {
|
||||||
|
Button {
|
||||||
|
onSelect(nil)
|
||||||
|
dismiss()
|
||||||
|
} label: {
|
||||||
|
Text(tracks.contains(where: \.selected) ? "Off" : "✓ Off").frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
.buttonStyle(.edgedWide)
|
||||||
|
}
|
||||||
|
ForEach(tracks) { track in
|
||||||
|
Button {
|
||||||
|
onSelect(track)
|
||||||
|
dismiss()
|
||||||
|
} label: {
|
||||||
|
Text(track.selected ? "✓ \(track.label)" : track.label).frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
.buttonStyle(.edgedWide)
|
||||||
|
}
|
||||||
|
} buttons: {
|
||||||
|
Button("Close") { dismiss() }.buttonStyle(.tonal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The title's episodes, from the player, opened scrolled to the one playing
|
||||||
|
/// so the next few are in view. Each row has its still, its name, and whether
|
||||||
|
/// it is playing, watched or part way.
|
||||||
|
private struct EpisodeSheet: View {
|
||||||
|
let episodes: [EpisodeRecord]
|
||||||
|
let playing: Int
|
||||||
|
let onSelect: (Int) -> Void
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@Environment(\.scheme) private var scheme
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
Text("Episodes").textStyle(.titleMedium).foregroundStyle(scheme.onSurface)
|
||||||
|
.padding(.horizontal, 24).padding(.vertical, 8)
|
||||||
|
ScrollViewReader { reader in
|
||||||
|
ScrollView {
|
||||||
|
LazyVStack(spacing: 0) {
|
||||||
|
ForEach(Array(episodes.enumerated()), id: \.element.linkId) { position, episode in
|
||||||
|
row(position, episode).id(position)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.bottom, 24)
|
||||||
|
}
|
||||||
|
.onAppear { reader.scrollTo(max(playing - 1, 0), anchor: .top) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.top, 20)
|
||||||
|
.presentationDetents([.medium, .large])
|
||||||
|
.presentationDragIndicator(.visible)
|
||||||
|
.presentationBackground(scheme.surfaceContainerLow)
|
||||||
|
.presentationCornerRadius(Corner.sheet)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func row(_ position: Int, _ episode: EpisodeRecord) -> some View {
|
||||||
|
let current = position == playing
|
||||||
|
return Button {
|
||||||
|
dismiss()
|
||||||
|
onSelect(position)
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
RemoteArtwork(episode.stillUrl, episode.label, fallback: episode.thumbnailSource, labelled: false)
|
||||||
|
.frame(width: 112)
|
||||||
|
.aspectRatio(16 / 9, contentMode: .fit)
|
||||||
|
.overlay(alignment: .bottom) {
|
||||||
|
if let progress = episode.progress, !episode.watched, !current { AccentProgress(progress: progress) }
|
||||||
|
}
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: Corner.standard))
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(episode.providerName ?? episode.label).textStyle(.titleSmall).foregroundStyle(scheme.onSurface).lineLimit(2)
|
||||||
|
Text(current ? "Playing"
|
||||||
|
: episode.watched ? "\(episode.label) · Watched"
|
||||||
|
: episode.resumeAt != nil ? "\(episode.label) · Part way" : episode.label)
|
||||||
|
.textStyle(.bodySmall)
|
||||||
|
.foregroundStyle(current ? scheme.primary : scheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(current ? scheme.secondaryContainer : .clear)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.pressable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The bar that says playback is still going, and gets back to it.
|
||||||
|
///
|
||||||
|
/// Leaving the player does not stop it — the episode keeps playing — so there
|
||||||
|
/// has to be something on screen that says so and takes one tap to return to.
|
||||||
|
struct MiniTransport: View {
|
||||||
|
let host: PlayerHost
|
||||||
|
@Environment(\.scheme) private var scheme
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if let episode = host.episode, let title = host.title {
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
RemoteArtwork(title.posterUrl ?? title.backdropUrl, title.displayName, fallback: episode.thumbnailSource)
|
||||||
|
.frame(width: 64)
|
||||||
|
.aspectRatio(16 / 9, contentMode: .fit)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: Corner.standard))
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
Text(episode.label).textStyle(.bodyLarge, semibold: true).foregroundStyle(scheme.onSurface).lineLimit(1)
|
||||||
|
Text(title.displayName).textStyle(.bodySmall).foregroundStyle(scheme.onSurfaceVariant).lineLimit(1)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
let paused = host.player?.paused ?? true
|
||||||
|
IconButton(paused ? "play.fill" : "pause.fill", paused ? "Play" : "Pause", tint: scheme.onSurface) {
|
||||||
|
host.setPaused(!paused)
|
||||||
|
}
|
||||||
|
IconButton("xmark", "Stop playback", tint: scheme.onSurface, action: host.close)
|
||||||
|
}
|
||||||
|
.padding(8)
|
||||||
|
.card()
|
||||||
|
.shadow(color: .black.opacity(0.25), radius: 8, y: 2)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture { host.minimized = false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The view libmpv draws into: its Metal layer, sized to this view.
|
||||||
|
struct MpvView: UIViewRepresentable {
|
||||||
|
let player: MpvPlayer
|
||||||
|
|
||||||
|
func makeUIView(context _: Context) -> LayerView {
|
||||||
|
LayerView(layer: player.layer)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIView(_: LayerView, context _: Context) {}
|
||||||
|
|
||||||
|
final class LayerView: UIView {
|
||||||
|
private let video: CAMetalLayer
|
||||||
|
|
||||||
|
init(layer video: CAMetalLayer) {
|
||||||
|
self.video = video
|
||||||
|
super.init(frame: .zero)
|
||||||
|
backgroundColor = .black
|
||||||
|
video.removeFromSuperlayer()
|
||||||
|
layer.addSublayer(video)
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder _: NSCoder) { nil }
|
||||||
|
|
||||||
|
/// The drawable is sized here, not left to the layer. MoltenVK sets
|
||||||
|
/// `drawableSize` once when it builds the swapchain and Core Animation
|
||||||
|
/// stops tracking the bounds from then on, while mpv decides its
|
||||||
|
/// output size from `drawableSize` alone. Left alone, a rotation, a
|
||||||
|
/// Split View resize or the player reopening at another size kept the
|
||||||
|
/// old drawable, which Core Animation stretched over the new bounds —
|
||||||
|
/// a squashed or cropped picture until something else resized it.
|
||||||
|
override func layoutSubviews() {
|
||||||
|
super.layoutSubviews()
|
||||||
|
let scale = window?.screen.nativeScale ?? UIScreen.main.nativeScale
|
||||||
|
CATransaction.begin()
|
||||||
|
CATransaction.setDisableActions(true)
|
||||||
|
video.frame = bounds
|
||||||
|
video.contentsScale = scale
|
||||||
|
video.drawableSize = CGSize(width: bounds.width * scale, height: bounds.height * scale)
|
||||||
|
CATransaction.commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func didMoveToWindow() {
|
||||||
|
super.didMoveToWindow()
|
||||||
|
// Reattached after the player was minimised: the layer is the one
|
||||||
|
// from before, still sized for wherever it last was.
|
||||||
|
setNeedsLayout()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lock to landscape, or hand rotation back to the device.
|
||||||
|
@MainActor
|
||||||
|
enum OrientationLock {
|
||||||
|
private(set) static var landscape = false
|
||||||
|
|
||||||
|
/// What the app delegate answers for every window.
|
||||||
|
static var mask: UIInterfaceOrientationMask { landscape ? .landscape : .all }
|
||||||
|
|
||||||
|
/// Reports which way it went.
|
||||||
|
static func toggle() -> Bool {
|
||||||
|
set(!landscape)
|
||||||
|
return landscape
|
||||||
|
}
|
||||||
|
|
||||||
|
static func set(_ locked: Bool) {
|
||||||
|
landscape = locked
|
||||||
|
guard let scene = UIApplication.shared.connectedScenes.first(where: { $0 is UIWindowScene }) as? UIWindowScene else { return }
|
||||||
|
scene.keyWindow?.rootViewController?.setNeedsUpdateOfSupportedInterfaceOrientations()
|
||||||
|
scene.requestGeometryUpdate(.iOS(interfaceOrientations: mask))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!--
|
||||||
|
The iOS app icon: Android's adaptive launcher icon, both layers on the full
|
||||||
|
108-unit canvas, since iOS cuts its own shape out of a square just as a
|
||||||
|
launcher does. Layer sources: android/app/src/main/res/drawable/
|
||||||
|
ic_launcher_background.xml and ic_launcher_foreground.xml. The glyph is
|
||||||
|
scaled up by 108/72: a launcher shows only the middle 72 units of the layer,
|
||||||
|
and iOS's mask takes far less off the edges.
|
||||||
|
Render with scripts/render-ios-icon.sh.
|
||||||
|
-->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 108 108">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="face" gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="108" y2="108">
|
||||||
|
<stop offset="0" stop-color="#A48BFF"/>
|
||||||
|
<stop offset="0.55" stop-color="#7552FF"/>
|
||||||
|
<stop offset="1" stop-color="#5B36F2"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="glyph" gradientUnits="userSpaceOnUse" x1="0" y1="36" x2="0" y2="88">
|
||||||
|
<stop offset="0" stop-color="#FFFFFF"/>
|
||||||
|
<stop offset="1" stop-color="#ECE6FF"/>
|
||||||
|
</linearGradient>
|
||||||
|
<clipPath id="blocks">
|
||||||
|
<path d="M0,0h63v128h-63zM68.5,0h10v128h-10zM84,0h44v128h-44z"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
<rect width="108" height="108" fill="url(#face)"/>
|
||||||
|
<g transform="translate(54 54) scale(1.5) translate(-54 -54)">
|
||||||
|
<g transform="translate(-16 -8) translate(70 62) scale(0.6923) translate(-70 -62)">
|
||||||
|
<g transform="translate(0 3)" clip-path="url(#blocks)">
|
||||||
|
<path fill="#2E1596" fill-opacity="0.302" d="M50,41.5v41a6,6 0,0 0,9.1 5.1l34.2,-20.5a6,6 0,0 0,0 -10.3L59.1,36.4A6,6 0,0 0,50 41.5z"/>
|
||||||
|
</g>
|
||||||
|
<g clip-path="url(#blocks)">
|
||||||
|
<path fill="url(#glyph)" d="M50,41.5v41a6,6 0,0 0,9.1 5.1l34.2,-20.5a6,6 0,0 0,0 -10.3L59.1,36.4A6,6 0,0 0,50 41.5z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 52 KiB |
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "AppIcon-1024.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"platform" : "ios",
|
||||||
|
"size" : "1024x1024"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "0x12",
|
||||||
|
"green" : "0x0E",
|
||||||
|
"red" : "0x0E"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Third-Party Notices
|
||||||
|
|
||||||
|
The proton-stream iOS distribution combines open-source components. The
|
||||||
|
complete GNU GPL version 3 text is packaged beside this file as `GPL-3.0.txt`.
|
||||||
|
|
||||||
|
| Component | License |
|
||||||
|
|---|---|
|
||||||
|
| mpv/libmpv (MPVKit `MPVKit-GPL` 1.0.0) | GPL-2.0-or-later; verify the pinned build configuration |
|
||||||
|
| FFmpeg (MPVKit GPL build) | GPL-2.0-or-later |
|
||||||
|
| MPVKit | LGPL-3.0 |
|
||||||
|
| MoltenVK | Apache-2.0 |
|
||||||
|
| libplacebo | LGPL-2.1-or-later |
|
||||||
|
| libass, FreeType, FriBidi, HarfBuzz | ISC, FTL, LGPL-2.1-or-later, MIT |
|
||||||
|
| UniFFI | MPL-2.0 |
|
||||||
|
| Inter typeface (bundled, `Inter-OFL.txt`) | OFL-1.1 |
|
||||||
|
| proton-stream reusable Rust crates | MIT |
|
||||||
|
|
||||||
|
Before release, replace this dependency summary with an inventory generated
|
||||||
|
from the resolved Cargo and Swift Package dependency graphs and package every
|
||||||
|
required license text. A bundled libmpv release must identify the exact source
|
||||||
|
revision, patches, and build configuration, and make the corresponding source
|
||||||
|
available under the GPL.
|
||||||
@@ -0,0 +1,389 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import WebKit
|
||||||
|
|
||||||
|
/// The Proton account, at the top of the shares page: why one would sign in,
|
||||||
|
/// or who is signed in and how watch-history sync is doing.
|
||||||
|
struct AccountCard: View {
|
||||||
|
let onSignIn: () -> Void
|
||||||
|
let onBrowse: () -> Void
|
||||||
|
@Environment(AppModel.self) private var model
|
||||||
|
@Environment(\.scheme) private var scheme
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if let state = model.account.state {
|
||||||
|
Group {
|
||||||
|
if case let .signedIn(username) = state {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
Image(systemName: "person.crop.circle.fill").font(.system(size: 22)).foregroundStyle(scheme.primary)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(username).textStyle(.bodyLarge, semibold: true).foregroundStyle(scheme.onSurface)
|
||||||
|
TimelineView(.periodic(from: .now, by: 30)) { context in
|
||||||
|
Text(syncLine(model.account, now: context.date))
|
||||||
|
.textStyle(.bodyMedium).foregroundStyle(scheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
Menu {
|
||||||
|
Button(action: model.syncNow) { Label("Sync now", systemImage: "arrow.triangle.2.circlepath") }
|
||||||
|
Button(action: model.signOut) { Label("Sign out", systemImage: "rectangle.portrait.and.arrow.right") }
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "ellipsis").font(.system(size: 20)).frame(width: 48, height: 48)
|
||||||
|
.foregroundStyle(scheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
.accessibilityLabel("Account actions")
|
||||||
|
}
|
||||||
|
.padding(.leading, 16)
|
||||||
|
.padding(.trailing, 4)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
Button(action: onBrowse) { Label("Add from your Drive", systemImage: "folder") }
|
||||||
|
.buttonStyle(.tonal)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.bottom, 12)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Proton account").textStyle(.titleMedium).foregroundStyle(scheme.onSurface)
|
||||||
|
Text("Optional. Signed in, your shares, settings and watch history are kept in your own Drive, so every device shows the same library and resumes where another left off, and folders of your Drive can join the library.")
|
||||||
|
.textStyle(.bodyMedium).foregroundStyle(scheme.onSurfaceVariant)
|
||||||
|
Button("Sign in", action: onSignIn).buttonStyle(.tonal)
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.card()
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "Synced 3 min ago · 2 from other devices", or why it did not.
|
||||||
|
func syncLine(_ account: AccountUiState, now: Date) -> String {
|
||||||
|
if let error = account.syncError { return "Did not sync: \(error)" }
|
||||||
|
guard let at = account.syncedAt else { return "Library, settings and watch history sync through your Drive" }
|
||||||
|
let minutes = Int(max(now.timeIntervalSince(at), 0) / 60)
|
||||||
|
let ago = minutes < 1 ? "just now" : minutes < 60 ? "\(minutes) min ago" : "\(minutes / 60) h ago"
|
||||||
|
let applied = account.applied > 0 ? " · \(account.applied) from other devices" : ""
|
||||||
|
return "Synced \(ago)\(applied)"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Security keys need WebAuthn, which the sign-in here does not speak. An
|
||||||
|
/// account with nothing else is refused before this form (`AccountStore::sign_in`).
|
||||||
|
private let securityKeyNote = "Security keys are not supported here — use the code from your authenticator app."
|
||||||
|
|
||||||
|
/// Signing in, one step at a time: address and password, then a second-factor
|
||||||
|
/// code or the mailbox password if the account has them, with Proton's CAPTCHA
|
||||||
|
/// over the top when it asks for one.
|
||||||
|
struct SignInSheet: View {
|
||||||
|
let onDismiss: () -> Void
|
||||||
|
@Environment(AppModel.self) private var model
|
||||||
|
@Environment(\.scheme) private var scheme
|
||||||
|
@State private var username = ""
|
||||||
|
@State private var password = ""
|
||||||
|
@State private var code = ""
|
||||||
|
@State private var mailbox = ""
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let account = model.account
|
||||||
|
DialogSheet("Sign in to Proton") {
|
||||||
|
switch account.state {
|
||||||
|
case .secondFactor:
|
||||||
|
Field(label: "Code from your authenticator app", text: $code, keyboard: .numberPad, content: .oneTimeCode)
|
||||||
|
Text(securityKeyNote).textStyle(.bodySmall)
|
||||||
|
case .mailboxPassword:
|
||||||
|
Text("This account has a separate mailbox password. It unlocks your files.").textStyle(.bodySmall)
|
||||||
|
Field(label: "Mailbox password", text: $mailbox, secret: true, content: .password)
|
||||||
|
default:
|
||||||
|
Field(label: "Address", text: $username, keyboard: .emailAddress, content: .username)
|
||||||
|
Field(label: "Password", text: $password, secret: true, content: .password)
|
||||||
|
}
|
||||||
|
if let error = account.error {
|
||||||
|
Text(error).foregroundStyle(scheme.error)
|
||||||
|
}
|
||||||
|
if account.busy {
|
||||||
|
ProgressView().tint(scheme.primary).padding(.top, 4)
|
||||||
|
}
|
||||||
|
} buttons: {
|
||||||
|
Button("Cancel", action: dismiss).buttonStyle(.tonal)
|
||||||
|
switch account.state {
|
||||||
|
case .secondFactor:
|
||||||
|
Button("Continue") { model.submitSecondFactor(code.trimmingCharacters(in: .whitespaces)) }
|
||||||
|
.buttonStyle(.accent)
|
||||||
|
.disabled(code.trimmingCharacters(in: .whitespaces).isEmpty || account.busy)
|
||||||
|
case .mailboxPassword:
|
||||||
|
Button("Unlock") { model.submitMailboxPassword(mailbox) }
|
||||||
|
.buttonStyle(.accent)
|
||||||
|
.disabled(mailbox.isEmpty || account.busy)
|
||||||
|
default:
|
||||||
|
Button("Sign in") { model.signIn(username.trimmingCharacters(in: .whitespaces), password) }
|
||||||
|
.buttonStyle(.accent)
|
||||||
|
.disabled(username.trimmingCharacters(in: .whitespaces).isEmpty || password.isEmpty || account.busy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.secureContent()
|
||||||
|
.interactiveDismissDisabled(account.busy)
|
||||||
|
.onChange(of: account.state) { _, state in
|
||||||
|
if case .signedIn = state { onDismiss() }
|
||||||
|
}
|
||||||
|
.fullScreenCover(isPresented: Binding(
|
||||||
|
get: { account.verificationUrl != nil },
|
||||||
|
set: { if !$0 { model.dismissVerification() } }
|
||||||
|
)) {
|
||||||
|
if let url = account.verificationUrl {
|
||||||
|
VerificationView(url: url, onToken: { token in
|
||||||
|
model.signIn(username.trimmingCharacters(in: .whitespaces), password, verificationToken: token)
|
||||||
|
}, onDismiss: model.dismissVerification)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func dismiss() {
|
||||||
|
if model.account.state == .secondFactor || model.account.state == .mailboxPassword { model.cancelSignIn() }
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Proton's hosted CAPTCHA, which it puts in front of a sign-in it does not
|
||||||
|
/// recognise — a new network, a VPN exit.
|
||||||
|
///
|
||||||
|
/// In a web view rather than Safari because the page reports success by
|
||||||
|
/// posting a message to its host, not by redirecting. The page targets
|
||||||
|
/// `window.parent`, which for a top-level page is the page itself, so a
|
||||||
|
/// listener on its own window hears it.
|
||||||
|
private struct VerificationView: View {
|
||||||
|
let url: String
|
||||||
|
let onToken: (String) -> Void
|
||||||
|
let onDismiss: () -> Void
|
||||||
|
@Environment(\.scheme) private var scheme
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
HStack {
|
||||||
|
Text("Confirm you are human").textStyle(.titleMedium).foregroundStyle(scheme.onSurface).padding(.leading, 16)
|
||||||
|
Spacer()
|
||||||
|
IconButton("xmark", "Cancel", action: onDismiss)
|
||||||
|
}
|
||||||
|
.frame(height: 56)
|
||||||
|
VerificationWebView(url: url, onToken: onToken)
|
||||||
|
}
|
||||||
|
.background(scheme.surfaceContainerHigh)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Installed once per page, at start and again at finish in case the first ran
|
||||||
|
/// too early.
|
||||||
|
private let verificationBridge = """
|
||||||
|
if (!window.__pstrVerification) {
|
||||||
|
window.__pstrVerification = true;
|
||||||
|
window.addEventListener('message', function (event) {
|
||||||
|
try {
|
||||||
|
window.webkit.messageHandlers.pstrVerification.postMessage(
|
||||||
|
typeof event.data === 'string' ? event.data : JSON.stringify(event.data));
|
||||||
|
} catch (e) {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
/// Whether a page is Proton's own, over HTTPS.
|
||||||
|
func isProtonPage(scheme: String?, host: String?) -> Bool {
|
||||||
|
guard scheme == "https", let host else { return false }
|
||||||
|
return host == "proton.me" || host.hasSuffix(".proton.me")
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct VerificationWebView: UIViewRepresentable {
|
||||||
|
let url: String
|
||||||
|
let onToken: (String) -> Void
|
||||||
|
|
||||||
|
func makeCoordinator() -> Coordinator { Coordinator(onToken: onToken) }
|
||||||
|
|
||||||
|
func makeUIView(context: Context) -> WKWebView {
|
||||||
|
let configuration = WKWebViewConfiguration()
|
||||||
|
configuration.websiteDataStore = .nonPersistent()
|
||||||
|
let content = configuration.userContentController
|
||||||
|
for time in [WKUserScriptInjectionTime.atDocumentStart, .atDocumentEnd] {
|
||||||
|
content.addUserScript(WKUserScript(source: verificationBridge, injectionTime: time, forMainFrameOnly: true))
|
||||||
|
}
|
||||||
|
content.add(WeakHandler(context.coordinator), name: "pstrVerification")
|
||||||
|
let view = WKWebView(frame: .zero, configuration: configuration)
|
||||||
|
view.navigationDelegate = context.coordinator
|
||||||
|
if let address = URL(string: url) { view.load(URLRequest(url: address)) }
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIView(_: WKWebView, context _: Context) {}
|
||||||
|
|
||||||
|
static func dismantleUIView(_ view: WKWebView, coordinator _: Coordinator) {
|
||||||
|
view.configuration.userContentController.removeScriptMessageHandler(forName: "pstrVerification")
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class Coordinator: NSObject, WKScriptMessageHandler, WKNavigationDelegate {
|
||||||
|
let onToken: (String) -> Void
|
||||||
|
private var answered = false
|
||||||
|
|
||||||
|
init(onToken: @escaping (String) -> Void) {
|
||||||
|
self.onToken = onToken
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every message the page posts lands here, most of them its own chatter.
|
||||||
|
func userContentController(_: WKUserContentController, didReceive message: WKScriptMessage) {
|
||||||
|
guard !answered, let text = message.body as? String, let token = verificationToken(message: text) else { return }
|
||||||
|
answered = true
|
||||||
|
onToken(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The bridge is reachable by whatever the frame shows, so the frame
|
||||||
|
/// stays on Proton.
|
||||||
|
func webView(_: WKWebView, decidePolicyFor action: WKNavigationAction) async -> WKNavigationActionPolicy {
|
||||||
|
guard action.targetFrame?.isMainFrame ?? true else { return .allow }
|
||||||
|
return isProtonPage(scheme: action.request.url?.scheme, host: action.request.url?.host) ? .allow : .cancel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `WKUserContentController` holds its handlers strongly.
|
||||||
|
@MainActor
|
||||||
|
private final class WeakHandler: NSObject, WKScriptMessageHandler {
|
||||||
|
weak var target: WKScriptMessageHandler?
|
||||||
|
|
||||||
|
init(_ target: WKScriptMessageHandler) {
|
||||||
|
self.target = target
|
||||||
|
}
|
||||||
|
|
||||||
|
func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) {
|
||||||
|
target?.userContentController(controller, didReceive: message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Walk the account's Drive and add a folder of it to the library.
|
||||||
|
///
|
||||||
|
/// Full screen, because a Drive is deep. Listings are asked for here rather
|
||||||
|
/// than through the model: they are this view's alone, and gone when it closes.
|
||||||
|
struct DriveBrowser: View {
|
||||||
|
@Environment(AppModel.self) private var model
|
||||||
|
@Environment(\.scheme) private var scheme
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@State private var trail: [(name: String, place: DrivePlaceRecord)] = []
|
||||||
|
@State private var places: [DrivePlaceRecord]?
|
||||||
|
@State private var entries: [DriveEntryRecord]?
|
||||||
|
@State private var error: String?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let current = trail.last?.place
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
IconButton(trail.isEmpty ? "xmark" : "chevron.backward", trail.isEmpty ? "Close" : "Up a folder", tint: scheme.onSurface) {
|
||||||
|
if trail.isEmpty { dismiss() } else { trail.removeLast() }
|
||||||
|
}
|
||||||
|
Text(trail.last?.name ?? "Your Drive").textStyle(.titleLarge).foregroundStyle(scheme.onSurface).lineLimit(1)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.frame(height: 56)
|
||||||
|
Group {
|
||||||
|
let loading = error == nil && (current == nil ? places == nil : entries == nil)
|
||||||
|
if let error {
|
||||||
|
EmptyState("Could not list this", error)
|
||||||
|
} else if loading {
|
||||||
|
ProgressView().tint(scheme.primary).frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
} else if current == nil {
|
||||||
|
ScrollView {
|
||||||
|
LazyVStack(spacing: 0) {
|
||||||
|
ForEach(places ?? [], id: \.linkId) { place in
|
||||||
|
DriveRow(icon: place.kind.icon, name: place.name, detail: nil) {
|
||||||
|
trail.append((place.name, place))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
} else if (entries ?? []).isEmpty {
|
||||||
|
EmptyState("Empty folder", "Nothing to add from here.")
|
||||||
|
} else {
|
||||||
|
ScrollView {
|
||||||
|
LazyVStack(spacing: 0) {
|
||||||
|
ForEach(entries ?? [], id: \.linkId) { entry in
|
||||||
|
DriveRow(
|
||||||
|
icon: entry.isFolder ? "folder.fill" : entry.isVideo ? "film" : "doc",
|
||||||
|
name: entry.name,
|
||||||
|
detail: entry.size.map(formatBytes),
|
||||||
|
open: entry.isFolder ? {
|
||||||
|
trail.append((entry.name, DrivePlaceRecord(kind: .myFiles, name: entry.name, volumeId: entry.volumeId, linkId: entry.linkId)))
|
||||||
|
} : nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxHeight: .infinity)
|
||||||
|
// A folder already in the library is refused by the engine, with a
|
||||||
|
// message saying so.
|
||||||
|
if let folder = current, let name = trail.last?.name {
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
Button("Add this folder") {
|
||||||
|
model.addAccountFolder(name: name, volumeId: folder.volumeId, linkId: folder.linkId)
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
.buttonStyle(.accent)
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(scheme.background)
|
||||||
|
.environment(\.colorScheme, scheme.colorScheme)
|
||||||
|
.task(id: current?.linkId) {
|
||||||
|
error = nil
|
||||||
|
do {
|
||||||
|
if let current {
|
||||||
|
entries = nil
|
||||||
|
entries = try await NativeRuntime.engine().driveFolder(volumeId: current.volumeId, linkId: current.linkId)
|
||||||
|
} else if places == nil {
|
||||||
|
places = try await NativeRuntime.engine().drivePlaces()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
self.error = errorMessage(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension PlaceType {
|
||||||
|
var icon: String {
|
||||||
|
switch self {
|
||||||
|
case .myFiles: "externaldrive"
|
||||||
|
case .device: "desktopcomputer"
|
||||||
|
case .sharedWithMe: "folder.badge.person.crop"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct DriveRow: View {
|
||||||
|
let icon: String
|
||||||
|
let name: String
|
||||||
|
let detail: String?
|
||||||
|
var open: (() -> Void)?
|
||||||
|
@Environment(\.scheme) private var scheme
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button { open?() } label: {
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
Image(systemName: icon).font(.system(size: 20))
|
||||||
|
.foregroundStyle(open != nil ? scheme.primary : scheme.onSurfaceVariant).frame(width: 24)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(name).textStyle(.bodyLarge).foregroundStyle(scheme.onSurface).lineLimit(2)
|
||||||
|
if let detail { Text(detail).textStyle(.bodyMedium).foregroundStyle(scheme.onSurfaceVariant) }
|
||||||
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.pressable)
|
||||||
|
.disabled(open == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// A tab's page: its own name in a bar, over whatever the page holds.
|
||||||
|
///
|
||||||
|
/// The bar draws under the status bar itself; the mini transport's room at the
|
||||||
|
/// foot of the page is the shell's `safeAreaInset`, so scrolling content clears
|
||||||
|
/// it without being told.
|
||||||
|
struct TabPage<Content: View, Leading: View, Actions: View>: View {
|
||||||
|
let title: String
|
||||||
|
@ViewBuilder var leading: Leading
|
||||||
|
@ViewBuilder var actions: Actions
|
||||||
|
@ViewBuilder var content: Content
|
||||||
|
@Environment(\.scheme) private var scheme
|
||||||
|
|
||||||
|
init(
|
||||||
|
_ title: String,
|
||||||
|
@ViewBuilder leading: () -> Leading = { EmptyView() },
|
||||||
|
@ViewBuilder actions: () -> Actions = { EmptyView() },
|
||||||
|
@ViewBuilder content: () -> Content
|
||||||
|
) {
|
||||||
|
self.title = title
|
||||||
|
self.leading = leading()
|
||||||
|
self.actions = actions()
|
||||||
|
self.content = content()
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
leading
|
||||||
|
Text(title)
|
||||||
|
.textStyle(.titleLarge)
|
||||||
|
.foregroundStyle(scheme.onSurface)
|
||||||
|
.lineLimit(1)
|
||||||
|
.padding(.leading, Leading.self == EmptyView.self ? 16 : 4)
|
||||||
|
Spacer(minLength: 8)
|
||||||
|
actions
|
||||||
|
}
|
||||||
|
.frame(height: 56)
|
||||||
|
.padding(.trailing, 4)
|
||||||
|
content.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
.background(scheme.background)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A 48pt icon target: what Material's `IconButton` is.
|
||||||
|
struct IconButton: View {
|
||||||
|
let systemName: String
|
||||||
|
let label: String
|
||||||
|
var tint: Color?
|
||||||
|
var enabled = true
|
||||||
|
let action: () -> Void
|
||||||
|
@Environment(\.scheme) private var scheme
|
||||||
|
|
||||||
|
init(_ systemName: String, _ label: String, tint: Color? = nil, enabled: Bool = true, action: @escaping () -> Void) {
|
||||||
|
self.systemName = systemName
|
||||||
|
self.label = label
|
||||||
|
self.tint = tint
|
||||||
|
self.enabled = enabled
|
||||||
|
self.action = action
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: action) {
|
||||||
|
Image(systemName: systemName)
|
||||||
|
.font(.system(size: 20))
|
||||||
|
.frame(width: 48, height: 48)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.pressableIcon)
|
||||||
|
.foregroundStyle(enabled ? tint ?? scheme.onSurfaceVariant : scheme.onSurface.opacity(0.38))
|
||||||
|
.disabled(!enabled)
|
||||||
|
.accessibilityLabel(label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct EmptyState: View {
|
||||||
|
let title: String
|
||||||
|
let body_: String
|
||||||
|
@Environment(\.scheme) private var scheme
|
||||||
|
|
||||||
|
init(_ title: String, _ body: String) {
|
||||||
|
self.title = title
|
||||||
|
body_ = body
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 2) {
|
||||||
|
Text(title).textStyle(.titleMedium).foregroundStyle(scheme.onSurface)
|
||||||
|
Text(body_).textStyle(.bodyMedium).foregroundStyle(scheme.onSurface)
|
||||||
|
}
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(24)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A Material card: the container fill at the shared radius.
|
||||||
|
struct CardBackground: ViewModifier {
|
||||||
|
@Environment(\.scheme) private var scheme
|
||||||
|
var fill: Color?
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content.background(fill ?? scheme.surfaceContainerHighest, in: RoundedRectangle(cornerRadius: Corner.standard))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
func card(_ fill: Color? = nil) -> some View { modifier(CardBackground(fill: fill)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One row of a list: a leading icon, a headline, a supporting line and a
|
||||||
|
/// trailing control — Material's `ListItem`.
|
||||||
|
struct ListRow<Trailing: View>: View {
|
||||||
|
var icon: String?
|
||||||
|
var iconTint: Color?
|
||||||
|
let headline: String
|
||||||
|
var supporting: String?
|
||||||
|
var enabled = true
|
||||||
|
@ViewBuilder var trailing: Trailing
|
||||||
|
@Environment(\.scheme) private var scheme
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
if let icon {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: 20))
|
||||||
|
.foregroundStyle(iconTint ?? scheme.onSurfaceVariant)
|
||||||
|
.frame(width: 24)
|
||||||
|
}
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(headline).textStyle(.bodyLarge).foregroundStyle(scheme.onSurface)
|
||||||
|
if let supporting {
|
||||||
|
Text(supporting).textStyle(.bodyMedium).foregroundStyle(scheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
trailing
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.frame(minHeight: 56)
|
||||||
|
.opacity(enabled ? 1 : 0.38)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ListRow where Trailing == EmptyView {
|
||||||
|
init(icon: String? = nil, iconTint: Color? = nil, headline: String, supporting: String? = nil, enabled: Bool = true) {
|
||||||
|
self.icon = icon
|
||||||
|
self.iconTint = iconTint
|
||||||
|
self.headline = headline
|
||||||
|
self.supporting = supporting
|
||||||
|
self.enabled = enabled
|
||||||
|
trailing = EmptyView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A switch row: Material's `ListItem` with a trailing `Switch`.
|
||||||
|
struct SwitchRow: View {
|
||||||
|
let headline: String
|
||||||
|
var supporting: String?
|
||||||
|
@Binding var isOn: Bool
|
||||||
|
@Environment(\.scheme) private var scheme
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ListRow(headline: headline, supporting: supporting) {
|
||||||
|
Toggle("", isOn: $isOn).labelsHidden().tint(scheme.primary)
|
||||||
|
}
|
||||||
|
.onTapGesture { isOn.toggle() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A form sheet in the place of Material's `AlertDialog`: a heading, what it
|
||||||
|
/// asks, and its buttons at the foot, in the dialog container colour.
|
||||||
|
struct DialogSheet<Content: View, Buttons: View>: View {
|
||||||
|
let title: String
|
||||||
|
@ViewBuilder var content: Content
|
||||||
|
@ViewBuilder var buttons: Buttons
|
||||||
|
@Environment(\.scheme) private var scheme
|
||||||
|
|
||||||
|
init(_ title: String, @ViewBuilder content: () -> Content, @ViewBuilder buttons: () -> Buttons) {
|
||||||
|
self.title = title
|
||||||
|
self.content = content()
|
||||||
|
self.buttons = buttons()
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
Text(title).textStyle(.headlineSmall).foregroundStyle(scheme.onSurface)
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 12) { content }
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
.scrollBounceBehavior(.basedOnSize)
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Spacer()
|
||||||
|
buttons
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(24)
|
||||||
|
.foregroundStyle(scheme.onSurfaceVariant)
|
||||||
|
.textStyle(.bodyMedium)
|
||||||
|
.presentationDetents([.medium, .large])
|
||||||
|
.presentationBackground(scheme.surfaceContainerHigh)
|
||||||
|
.presentationCornerRadius(Corner.sheet)
|
||||||
|
.environment(\.colorScheme, scheme.colorScheme)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A text field in the outlined Material style.
|
||||||
|
struct Field: View {
|
||||||
|
let label: String
|
||||||
|
@Binding var text: String
|
||||||
|
var secret = false
|
||||||
|
var keyboard: UIKeyboardType = .default
|
||||||
|
var content: UITextContentType?
|
||||||
|
var submit: SubmitLabel = .done
|
||||||
|
@Environment(\.scheme) private var scheme
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(label).textStyle(.bodySmall).foregroundStyle(scheme.onSurfaceVariant)
|
||||||
|
Group {
|
||||||
|
if secret {
|
||||||
|
SecureField("", text: $text)
|
||||||
|
} else {
|
||||||
|
TextField("", text: $text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.textStyle(.bodyLarge)
|
||||||
|
.foregroundStyle(scheme.onSurface)
|
||||||
|
.keyboardType(keyboard)
|
||||||
|
.textContentType(content)
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
.submitLabel(submit)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.frame(height: 48)
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: Corner.tight).strokeBorder(scheme.outline))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Covers a sheet holding a secret while the app is not frontmost, so the app
|
||||||
|
/// switcher's snapshot does not keep it — what `FLAG_SECURE` does on Android.
|
||||||
|
struct SecureContent: ViewModifier {
|
||||||
|
@Environment(\.scenePhase) private var phase
|
||||||
|
@Environment(\.scheme) private var scheme
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content.overlay {
|
||||||
|
if phase != .active {
|
||||||
|
scheme.surfaceContainerHigh.ignoresSafeArea()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
func secureContent() -> some View { modifier(SecureContent()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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`.
|
||||||
|
func nextUpIndex(_ playlist: [EpisodeRecord]) -> Int {
|
||||||
|
if let resumed = playlist.indices.filter({ playlist[$0].resumeAt != nil }).max(by: { playlist[$0].lastPlayed < playlist[$1].lastPlayed }) {
|
||||||
|
return resumed
|
||||||
|
}
|
||||||
|
return playlist.firstIndex { !$0.watched } ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatBytes(_ bytes: UInt64) -> String {
|
||||||
|
let value = Double(bytes)
|
||||||
|
let gib = 1024.0 * 1024 * 1024
|
||||||
|
let mib = 1024.0 * 1024
|
||||||
|
if value >= gib { return String(format: "%.1f GiB", value / gib) }
|
||||||
|
if value >= mib { return String(format: "%.1f MiB", value / mib) }
|
||||||
|
if value >= 1024 { return String(format: "%.1f KiB", value / 1024) }
|
||||||
|
return "\(bytes) bytes"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One line under a download that has not finished: "Running · 120.0 MiB of
|
||||||
|
/// 1.2 GiB · 2.4 MiB/s", or "Queued" while it waits for a slot.
|
||||||
|
func downloadStatusLine(_ download: RetainedDownload) -> String {
|
||||||
|
var parts = [download.status.prefix(1).uppercased() + download.status.dropFirst()]
|
||||||
|
if download.total > 0, download.downloaded > 0 {
|
||||||
|
parts.append("\(formatBytes(UInt64(download.downloaded))) of \(formatBytes(UInt64(download.total)))")
|
||||||
|
}
|
||||||
|
if download.bytesPerSecond > 0, download.status == RetainedDownload.statusRunning {
|
||||||
|
parts.append("\(formatBytes(UInt64(download.bytesPerSecond)))/s")
|
||||||
|
}
|
||||||
|
return parts.joined(separator: " · ")
|
||||||
|
}
|
||||||
|
|
||||||
|
extension EpisodeRecord {
|
||||||
|
/// 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.
|
||||||
|
func heading(_ position: Int) -> String {
|
||||||
|
let named = providerName ?? (detail != name ? detail : nil)
|
||||||
|
switch (named, number) {
|
||||||
|
case let (named?, number?): return "\(number). \(named)"
|
||||||
|
case let (named?, nil): return named
|
||||||
|
case let (nil, number?): return "Episode \(number)"
|
||||||
|
case (nil, nil):
|
||||||
|
let stem = name.range(of: ".", options: .backwards).map { String(name[..<$0.lowerBound]) } ?? name
|
||||||
|
return stem.trimmingCharacters(in: .whitespaces).isEmpty ? "Episode \(position + 1)" : stem
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension TitleRecord {
|
||||||
|
/// Every episode, in display order — what the player is handed.
|
||||||
|
var playlist: [EpisodeRecord] { seasons.flatMap(\.episodes) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Colours mixed in sRGB, for the few places a role is blended with another.
|
||||||
|
func mix(_ from: Color, _ to: Color, _ fraction: Double) -> Color {
|
||||||
|
let a = UIColor(from).rgba
|
||||||
|
let b = UIColor(to).rgba
|
||||||
|
let t = min(max(fraction, 0), 1)
|
||||||
|
return Color(
|
||||||
|
.sRGB,
|
||||||
|
red: a.0 + (b.0 - a.0) * t,
|
||||||
|
green: a.1 + (b.1 - a.1) * t,
|
||||||
|
blue: a.2 + (b.2 - a.2) * t,
|
||||||
|
opacity: a.3 + (b.3 - a.3) * t
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension UIColor {
|
||||||
|
var rgba: (Double, Double, Double, Double) {
|
||||||
|
var red: CGFloat = 0, green: CGFloat = 0, blue: CGFloat = 0, alpha: CGFloat = 0
|
||||||
|
getRed(&red, green: &green, blue: &blue, alpha: &alpha)
|
||||||
|
return (red, green, blue, alpha)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// One show's saved episodes, and the show itself where the library has it.
|
||||||
|
private struct OfflineGroup: Identifiable {
|
||||||
|
let name: String
|
||||||
|
let title: TitleRecord?
|
||||||
|
let files: [OfflineRecord]
|
||||||
|
var id: String { name }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DownloadsView: View {
|
||||||
|
@Environment(AppModel.self) private var model
|
||||||
|
@Environment(\.scheme) private var scheme
|
||||||
|
// Deleting a saved episode asks first: it is the viewer's own choice to
|
||||||
|
// keep it, and getting it back is a download, not a tap.
|
||||||
|
@State private var deleting: OfflineRecord?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let retained = DownloadCoordinator.shared.records
|
||||||
|
let offline = model.offline
|
||||||
|
TabPage("Downloads") {
|
||||||
|
if retained.isEmpty && offline.isEmpty {
|
||||||
|
EmptyState("No downloads", "Episodes, seasons and shows saved offline appear here.")
|
||||||
|
} else {
|
||||||
|
ScrollView {
|
||||||
|
LazyVStack(alignment: .leading, spacing: 0) {
|
||||||
|
if !offline.isEmpty {
|
||||||
|
Text("\(offline.count) \(offline.count == 1 ? "episode" : "episodes") · \(formatBytes(offline.reduce(0) { $0 + $1.size })) on this device")
|
||||||
|
.textStyle(.bodyMedium)
|
||||||
|
.foregroundStyle(scheme.onSurfaceVariant)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
// What is still arriving comes first: it is the part of
|
||||||
|
// the page that changes, and the part with something to do.
|
||||||
|
if !retained.isEmpty {
|
||||||
|
SectionHeading(title: "In progress")
|
||||||
|
ForEach(retained) { download in
|
||||||
|
PartialRow(download: download)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Under the show they belong to, as on desktop: a saved
|
||||||
|
// season is fourteen rows, and fourteen rows of
|
||||||
|
// "Episode 3" name nothing.
|
||||||
|
ForEach(groups(offline)) { group in
|
||||||
|
SectionHeading(
|
||||||
|
title: group.name,
|
||||||
|
caption: "\(group.files.count) \(group.files.count == 1 ? "episode" : "episodes") · \(formatBytes(group.files.reduce(0) { $0 + $1.size }))"
|
||||||
|
)
|
||||||
|
ForEach(group.files, id: \.linkId) { file in
|
||||||
|
SavedRow(file: file, title: group.title) { deleting = file }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.bottom, 24)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.alert(
|
||||||
|
"Delete this download?",
|
||||||
|
isPresented: Binding(get: { deleting != nil }, set: { if !$0 { deleting = nil } }),
|
||||||
|
presenting: deleting
|
||||||
|
) { file in
|
||||||
|
Button("Delete", role: .destructive) { model.removeOffline(file) }
|
||||||
|
Button("Cancel", role: .cancel) {}
|
||||||
|
} message: { file in
|
||||||
|
Text("\(file.episode?.label ?? "The episode") (\(formatBytes(file.size))) leaves this device. It still streams, and can be downloaded again.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
private func groups(_ offline: [OfflineRecord]) -> [OfflineGroup] {
|
||||||
|
var shows: [String: TitleRecord] = [:]
|
||||||
|
for title in model.titles {
|
||||||
|
for episode in title.playlist { shows["\(episode.shareId)/\(episode.linkId)"] = title }
|
||||||
|
}
|
||||||
|
let grouped = Dictionary(grouping: offline) { shows["\($0.shareId)/\($0.linkId)"]?.key }
|
||||||
|
return grouped.map { key, files in
|
||||||
|
let title = key.flatMap { key in model.titles.first { $0.key == key } }
|
||||||
|
return OfflineGroup(name: title?.displayName ?? "Not in the library", title: title, files: files)
|
||||||
|
}
|
||||||
|
.sorted { $0.name < $1.name }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct SectionHeading: View {
|
||||||
|
let title: String
|
||||||
|
var caption: String?
|
||||||
|
@Environment(\.scheme) private var scheme
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
Text(title).textStyle(.titleMedium).foregroundStyle(scheme.onSurface).lineLimit(1)
|
||||||
|
if let caption { Text(caption).textStyle(.bodySmall).foregroundStyle(scheme.onSurfaceVariant) }
|
||||||
|
}
|
||||||
|
.padding(EdgeInsets(top: 20, leading: 16, bottom: 4, trailing: 16))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A saved episode: its still, what it is, its size, and a way to remove it.
|
||||||
|
private struct SavedRow: View {
|
||||||
|
let file: OfflineRecord
|
||||||
|
let title: TitleRecord?
|
||||||
|
let onDelete: () -> Void
|
||||||
|
@Environment(\.scheme) private var scheme
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let episode = file.episode
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
RemoteArtwork(episode?.stillUrl ?? title?.backdropUrl, title?.name ?? file.linkId, fallback: episode?.thumbnailSource, labelled: episode == nil)
|
||||||
|
.frame(width: 96)
|
||||||
|
.aspectRatio(16 / 9, contentMode: .fit)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: Corner.standard))
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(episode?.heading(0) ?? file.linkId).textStyle(.bodyLarge).foregroundStyle(scheme.onSurface).lineLimit(2)
|
||||||
|
Text([episode?.label, formatBytes(file.size)].compactMap { $0 }.joined(separator: " · "))
|
||||||
|
.textStyle(.bodyMedium).foregroundStyle(scheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
IconButton("trash", "Delete download", action: onDelete)
|
||||||
|
}
|
||||||
|
.padding(.leading, 16)
|
||||||
|
.padding(.trailing, 4)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A download not yet finished: how far it has got, why it stopped if it did,
|
||||||
|
/// and pause or resume beside cancel.
|
||||||
|
private struct PartialRow: View {
|
||||||
|
let download: RetainedDownload
|
||||||
|
@Environment(AppModel.self) private var model
|
||||||
|
@Environment(\.scheme) private var scheme
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let active = download.status == RetainedDownload.statusRunning || download.status == RetainedDownload.statusQueued
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(download.label).textStyle(.bodyLarge).foregroundStyle(scheme.onSurface).lineLimit(2)
|
||||||
|
Text(downloadStatusLine(download)).textStyle(.bodyMedium).foregroundStyle(scheme.onSurfaceVariant)
|
||||||
|
if download.total > 0 {
|
||||||
|
AccentProgress(progress: Double(download.downloaded) / Double(download.total)).padding(.top, 8)
|
||||||
|
}
|
||||||
|
if let error = download.error {
|
||||||
|
Text(error).textStyle(.bodyMedium).foregroundStyle(scheme.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
if active {
|
||||||
|
IconButton("pause.fill", "Pause download") { model.pauseDownload(download) }
|
||||||
|
} else {
|
||||||
|
IconButton("play.fill", "Resume download") { model.resumeDownload(download) }
|
||||||
|
}
|
||||||
|
// What has arrived so far is discarded; nothing finished is.
|
||||||
|
IconButton("xmark", "Cancel and delete what has arrived") { model.deletePartial(download) }
|
||||||
|
}
|
||||||
|
.padding(.leading, 16)
|
||||||
|
.padding(.trailing, 4)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct Played: Identifiable {
|
||||||
|
let title: TitleRecord
|
||||||
|
let episode: EpisodeRecord
|
||||||
|
let index: Int
|
||||||
|
|
||||||
|
var id: String { "\(episode.shareId)/\(episode.linkId)" }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every episode started or finished, newest first — the desktop's
|
||||||
|
/// `Library::history`, over the records the library already holds.
|
||||||
|
///
|
||||||
|
/// A row marked unwatched with its position at zero is "never played" for the
|
||||||
|
/// purpose here, and left out. Progress stands in for the position: it is
|
||||||
|
/// non-zero exactly when the position is.
|
||||||
|
func history(_ titles: [TitleRecord]) -> [Played] {
|
||||||
|
titles.flatMap { title in
|
||||||
|
title.playlist.enumerated()
|
||||||
|
.filter { $0.element.watched || ($0.element.progress ?? 0) > 0 }
|
||||||
|
.map { Played(title: title, episode: $0.element, index: $0.offset) }
|
||||||
|
}
|
||||||
|
.sorted { $0.episode.lastPlayed > $1.episode.lastPlayed }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "Today", "Yesterday", a weekday within the last week, then a date — how the
|
||||||
|
/// history page heads each day.
|
||||||
|
func dayHeading(_ day: Date, today: Date, calendar: Calendar = .current, locale: Locale = .current) -> String {
|
||||||
|
let day = calendar.startOfDay(for: day)
|
||||||
|
let today = calendar.startOfDay(for: today)
|
||||||
|
let days = calendar.dateComponents([.day], from: day, to: today).day ?? 0
|
||||||
|
if days == 0 { return "Today" }
|
||||||
|
if days == 1 { return "Yesterday" }
|
||||||
|
let format = DateFormatter()
|
||||||
|
format.locale = locale
|
||||||
|
format.calendar = calendar
|
||||||
|
format.timeZone = calendar.timeZone
|
||||||
|
if days > 0, days < 7 {
|
||||||
|
format.dateFormat = "EEEE"
|
||||||
|
} else if calendar.component(.year, from: day) == calendar.component(.year, from: today) {
|
||||||
|
format.dateFormat = "d MMMM"
|
||||||
|
} else {
|
||||||
|
format.dateFormat = "d MMMM yyyy"
|
||||||
|
}
|
||||||
|
return format.string(from: day)
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct PlayedDay {
|
||||||
|
let day: Date
|
||||||
|
var rows: [Played]
|
||||||
|
}
|
||||||
|
|
||||||
|
struct HistoryView: View {
|
||||||
|
let onPlay: (TitleRecord, Int) -> Void
|
||||||
|
let onTitle: (TitleRecord) -> Void
|
||||||
|
@Environment(AppModel.self) private var model
|
||||||
|
@Environment(\.scheme) private var scheme
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let played = history(model.titles)
|
||||||
|
// Grouped in order: the list is newest first, so each day's rows are
|
||||||
|
// already together.
|
||||||
|
let days = played.reduce(into: [PlayedDay]()) { days, entry in
|
||||||
|
let day = calendar.startOfDay(for: Date(timeIntervalSince1970: TimeInterval(entry.episode.lastPlayed)))
|
||||||
|
if days.last?.day == day { days[days.count - 1].rows.append(entry) } else { days.append(PlayedDay(day: day, rows: [entry])) }
|
||||||
|
}
|
||||||
|
TabPage("History") {
|
||||||
|
if days.isEmpty {
|
||||||
|
EmptyState("Nothing watched yet", "Episodes you play show up here, newest first.")
|
||||||
|
} else {
|
||||||
|
ScrollView {
|
||||||
|
LazyVStack(alignment: .leading, spacing: 0) {
|
||||||
|
ForEach(days, id: \.day) { day in
|
||||||
|
Text(dayHeading(day.day, today: Date()))
|
||||||
|
.textStyle(.titleSmall)
|
||||||
|
.foregroundStyle(scheme.primary)
|
||||||
|
.padding(EdgeInsets(top: 20, leading: 16, bottom: 4, trailing: 16))
|
||||||
|
ForEach(day.rows) { entry in
|
||||||
|
HistoryRow(
|
||||||
|
entry: entry,
|
||||||
|
onPlay: { onPlay(entry.title, entry.index) },
|
||||||
|
onTitle: { onTitle(entry.title) },
|
||||||
|
onRemove: { model.removeFromHistory(entry.title, entry.episode) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.bottom, 24)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
private struct HistoryRow: View {
|
||||||
|
let entry: Played
|
||||||
|
let onPlay: () -> Void
|
||||||
|
let onTitle: () -> Void
|
||||||
|
let onRemove: () -> Void
|
||||||
|
@Environment(\.scheme) private var scheme
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let episode = entry.episode
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
RemoteArtwork(episode.stillUrl ?? entry.title.backdropUrl, episode.label, fallback: episode.thumbnailSource, labelled: false)
|
||||||
|
.frame(width: 128)
|
||||||
|
.aspectRatio(16 / 9, contentMode: .fit)
|
||||||
|
.overlay(alignment: .bottom) {
|
||||||
|
if let progress = episode.progress, !episode.watched { AccentProgress(progress: progress) }
|
||||||
|
}
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: Corner.standard))
|
||||||
|
.onTapGesture(perform: onPlay)
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
Text(entry.title.displayName).textStyle(.titleSmall).foregroundStyle(scheme.onSurface).lineLimit(1)
|
||||||
|
Text(episode.providerName.map { "\(episode.label) · \($0)" } ?? episode.label)
|
||||||
|
.textStyle(.bodySmall).foregroundStyle(scheme.onSurface).lineLimit(1)
|
||||||
|
Text(episode.watched ? "Finished" : episode.stoppedAt)
|
||||||
|
.textStyle(.bodySmall).foregroundStyle(scheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture(perform: onTitle)
|
||||||
|
IconButton("xmark", "Remove from history", action: onRemove)
|
||||||
|
}
|
||||||
|
.padding(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 4))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension EpisodeRecord {
|
||||||
|
/// "Stopped at 12:04 of 23:40", or as much of it as is known.
|
||||||
|
var stoppedAt: String {
|
||||||
|
guard let at = resumeAt else { return "Started" }
|
||||||
|
let duration = progress.flatMap { $0 > 0 ? at / $0 : nil }
|
||||||
|
return "Stopped at \(clock(at))" + (duration.map { " of \(clock($0))" } ?? "")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// The languages offered by name, as the three-letter tag a Matroska file
|
||||||
|
/// carries.
|
||||||
|
///
|
||||||
|
/// The same short list the desktop names tracks from (`pstr-player`'s
|
||||||
|
/// `language_name`). mpv treats the two- and three-letter forms of a language
|
||||||
|
/// as one, so the tag chosen here also matches a file that says "ja". Anything
|
||||||
|
/// else is still one typed tag away.
|
||||||
|
let languages: [(code: String, name: String)] = [
|
||||||
|
("jpn", "Japanese"), ("eng", "English"), ("ger", "German"), ("fre", "French"),
|
||||||
|
("spa", "Spanish"), ("ita", "Italian"), ("por", "Portuguese"), ("dut", "Dutch"),
|
||||||
|
("rus", "Russian"), ("pol", "Polish"), ("swe", "Swedish"), ("dan", "Danish"),
|
||||||
|
("nor", "Norwegian"), ("fin", "Finnish"), ("cze", "Czech"), ("hun", "Hungarian"),
|
||||||
|
("tur", "Turkish"), ("ara", "Arabic"), ("heb", "Hebrew"), ("hin", "Hindi"),
|
||||||
|
("kor", "Korean"), ("chi", "Chinese"), ("tha", "Thai"), ("vie", "Vietnamese"),
|
||||||
|
("ukr", "Ukrainian"), ("rum", "Romanian"), ("gre", "Greek"), ("ind", "Indonesian"),
|
||||||
|
("may", "Malay"),
|
||||||
|
]
|
||||||
|
|
||||||
|
/// ISO 639-1, the form a file or the desktop may also have stored.
|
||||||
|
let twoLetter: [String: String] = [
|
||||||
|
"jpn": "ja", "eng": "en", "ger": "de", "fre": "fr", "spa": "es", "ita": "it",
|
||||||
|
"por": "pt", "dut": "nl", "rus": "ru", "pol": "pl", "swe": "sv", "dan": "da",
|
||||||
|
"nor": "no", "fin": "fi", "cze": "cs", "hun": "hu", "tur": "tr", "ara": "ar",
|
||||||
|
"heb": "he", "hin": "hi", "kor": "ko", "chi": "zh", "tha": "th", "vie": "vi",
|
||||||
|
"ukr": "uk", "rum": "ro", "gre": "el", "ind": "id", "may": "ms",
|
||||||
|
]
|
||||||
|
|
||||||
|
/// ISO 639-2/T, for the handful whose bibliographic code differs.
|
||||||
|
private let terminology: [String: String] = [
|
||||||
|
"ger": "deu", "fre": "fra", "dut": "nld", "cze": "ces", "chi": "zho", "rum": "ron", "gre": "ell", "may": "msa",
|
||||||
|
]
|
||||||
|
|
||||||
|
/// "Japanese", for a tag in any of the forms a file or the desktop may have
|
||||||
|
/// stored — "jpn", "ja", "JA-jp" — and the tag itself for one not on the list.
|
||||||
|
/// Nil is no preference.
|
||||||
|
func languageLabel(_ tag: String?) -> String {
|
||||||
|
guard let value = tag?.trimmingCharacters(in: .whitespaces), !value.isEmpty else { return "No preference" }
|
||||||
|
return languageOf(value)?.name ?? value
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The listed language a tag names, matching either ISO 639 form.
|
||||||
|
private func languageOf(_ tag: String) -> (code: String, name: String)? {
|
||||||
|
let base = tag.split(whereSeparator: { $0 == "-" || $0 == "_" }).first.map { $0.lowercased() } ?? ""
|
||||||
|
return languages.first { $0.code == base || twoLetter[$0.code] == base || terminology[$0.code] == base }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A language preference as a row, opening a list to choose from.
|
||||||
|
struct LanguageSetting: View {
|
||||||
|
let headline: String
|
||||||
|
let value: String?
|
||||||
|
let onChange: (String?) -> Void
|
||||||
|
@State private var choosing = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button { choosing = true } label: {
|
||||||
|
ListRow(headline: headline, supporting: languageLabel(value))
|
||||||
|
}
|
||||||
|
.buttonStyle(.pressable)
|
||||||
|
.sheet(isPresented: $choosing) {
|
||||||
|
LanguageSheet(headline: headline, value: value) { tag in
|
||||||
|
choosing = false
|
||||||
|
onChange(tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct LanguageSheet: View {
|
||||||
|
let headline: String
|
||||||
|
let onChoose: (String?) -> Void
|
||||||
|
private let current: String?
|
||||||
|
@State private var typed: String
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@Environment(\.scheme) private var scheme
|
||||||
|
|
||||||
|
init(headline: String, value: String?, onChoose: @escaping (String?) -> Void) {
|
||||||
|
self.headline = headline
|
||||||
|
self.onChoose = onChoose
|
||||||
|
let current = value.flatMap { languageOf($0)?.code ?? $0 }
|
||||||
|
self.current = current
|
||||||
|
// A tag that is not on the list starts in the field, so it is shown and
|
||||||
|
// can be edited rather than silently replaced.
|
||||||
|
_typed = State(initialValue: current.flatMap { tag in languages.contains { $0.code == tag } ? nil : tag } ?? "")
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
DialogSheet(headline) {
|
||||||
|
option("No preference", selected: current == nil) { onChoose(nil) }
|
||||||
|
ForEach(languages, id: \.code) { language in
|
||||||
|
option(language.name, selected: current == language.code) { onChoose(language.code) }
|
||||||
|
}
|
||||||
|
Divider().overlay(scheme.outlineVariant).padding(.vertical, 8)
|
||||||
|
Field(label: "Another tag", text: $typed)
|
||||||
|
Text("As it appears in the file, such as \"tgl\"").textStyle(.bodySmall)
|
||||||
|
} buttons: {
|
||||||
|
Button("Cancel") { dismiss() }.buttonStyle(.tonal)
|
||||||
|
Button("Use tag") { onChoose(typed.trimmingCharacters(in: .whitespaces).lowercased()) }
|
||||||
|
.buttonStyle(.accent)
|
||||||
|
.disabled(typed.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func option(_ name: String, selected: Bool, action: @escaping () -> Void) -> some View {
|
||||||
|
Button(action: action) {
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
Image(systemName: selected ? "largecircle.fill.circle" : "circle")
|
||||||
|
.font(.system(size: 20))
|
||||||
|
.foregroundStyle(selected ? scheme.primary : scheme.onSurfaceVariant)
|
||||||
|
Text(name).textStyle(.bodyLarge).foregroundStyle(scheme.onSurface)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.pressable)
|
||||||
|
.accessibilityAddTraits(selected ? .isSelected : [])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,536 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// How many shows the Continue watching shelf holds: a shelf is a shortcut,
|
||||||
|
/// not a second library.
|
||||||
|
private let continueWatchingMax = 12
|
||||||
|
|
||||||
|
/// One row of the Continue watching shelf: the episode, and where it sits.
|
||||||
|
struct Resumable: Identifiable {
|
||||||
|
let title: TitleRecord
|
||||||
|
let episode: EpisodeRecord
|
||||||
|
let index: Int
|
||||||
|
|
||||||
|
var id: String { "\(episode.shareId)/\(episode.linkId)" }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One tile of the grid: the title drawn, and how many more of its franchise
|
||||||
|
/// it stands for.
|
||||||
|
private struct Tile: Identifiable {
|
||||||
|
let title: TitleRecord
|
||||||
|
let folded: Int
|
||||||
|
var id: String { title.key }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A row above the grid: titles that share a director, a studio or a genre.
|
||||||
|
private struct Shelf: Identifiable {
|
||||||
|
let label: String
|
||||||
|
let titles: [TitleRecord]
|
||||||
|
var id: String { label }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
func resumable(_ titles: [TitleRecord]) -> [Resumable] {
|
||||||
|
titles.compactMap { title -> Resumable? in
|
||||||
|
let playlist = title.playlist
|
||||||
|
guard let index = playlist.indices
|
||||||
|
.filter({ playlist[$0].resumeAt != nil && !playlist[$0].watched })
|
||||||
|
.max(by: { playlist[$0].lastPlayed < playlist[$1].lastPlayed })
|
||||||
|
else { return nil }
|
||||||
|
return Resumable(title: title, episode: playlist[index], index: index)
|
||||||
|
}
|
||||||
|
.sorted { $0.episode.lastPlayed > $1.episode.lastPlayed }
|
||||||
|
.prefix(continueWatchingMax)
|
||||||
|
.map { $0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LibraryView: View {
|
||||||
|
@Environment(AppModel.self) private var model
|
||||||
|
@Environment(\.scheme) private var scheme
|
||||||
|
let onResume: (TitleRecord, Int) -> Void
|
||||||
|
let onTitle: (TitleRecord) -> Void
|
||||||
|
|
||||||
|
// What a long press opened a menu for: a title, or a Continue watching card.
|
||||||
|
@State private var menuFor: TitleRecord?
|
||||||
|
@State private var menuResume: Resumable?
|
||||||
|
@State private var matching: TitleRecord?
|
||||||
|
/// "Change match" picked in the tile menu, waiting for that menu to finish
|
||||||
|
/// leaving: a sheet presented while another is still dismissing is dropped.
|
||||||
|
@State private var pendingMatch: TitleRecord?
|
||||||
|
// Search is an icon until it is wanted: a permanent field above the
|
||||||
|
// library spent a band of every visit on something used in few of them.
|
||||||
|
@SceneStorage("library.searching") private var searching = false
|
||||||
|
@State private var collapsed = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let resumable = resumable(model.titles)
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
if searching {
|
||||||
|
SearchBar(query: Binding(get: { model.query }, set: model.search)) {
|
||||||
|
searching = false
|
||||||
|
model.search("")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
// The large title folds into the bar as the grid scrolls.
|
||||||
|
Text("Library")
|
||||||
|
.textStyle(.titleLarge)
|
||||||
|
.foregroundStyle(scheme.onSurface)
|
||||||
|
.padding(.leading, 16)
|
||||||
|
.opacity(collapsed ? 1 : 0)
|
||||||
|
Spacer()
|
||||||
|
IconButton("magnifyingglass", "Search library") { searching = true }
|
||||||
|
ArrangeMenu(sort: model.sort, grouped: model.grouped, onArrange: model.arrange)
|
||||||
|
// Pull to refresh is the gesture; the icon stays for a
|
||||||
|
// viewer who does not know it.
|
||||||
|
IconButton("arrow.clockwise", "Refresh", enabled: !model.refreshing) { model.refresh() }
|
||||||
|
}
|
||||||
|
.frame(height: 56)
|
||||||
|
.padding(.trailing, 4)
|
||||||
|
.background(collapsed ? scheme.surfaceContainer : scheme.background)
|
||||||
|
}
|
||||||
|
content(resumable)
|
||||||
|
}
|
||||||
|
.background(scheme.background)
|
||||||
|
.sheet(item: $menuFor, onDismiss: {
|
||||||
|
// After the dismissal rather than a guessed delay: on a slow iPad
|
||||||
|
// the menu was still leaving after 350 ms, the search sheet was
|
||||||
|
// dropped, and it turned up later on some unrelated redraw.
|
||||||
|
if let title = pendingMatch {
|
||||||
|
pendingMatch = nil
|
||||||
|
matching = title
|
||||||
|
}
|
||||||
|
}) { title in
|
||||||
|
let playlist = title.playlist
|
||||||
|
let allWatched = title.episodeCount > 0 && title.watchedCount == title.episodeCount
|
||||||
|
TileMenu(heading: title.displayName, caption: title.caption) {
|
||||||
|
MenuRow("play.fill", playlist.contains { $0.resumeAt != nil } ? "Resume" : "Play") {
|
||||||
|
onResume(title, nextUpIndex(playlist))
|
||||||
|
}
|
||||||
|
MenuRow("info.circle", "Open") { onTitle(title) }
|
||||||
|
MenuRow("arrow.down.circle", "Download all") { DownloadCoordinator.shared.enqueue(playlist) }
|
||||||
|
MenuRow(allWatched ? "checkmark.circle" : "checkmark.circle.fill", allWatched ? "Mark unwatched" : "Mark watched") {
|
||||||
|
model.setTitleWatched(title, !allWatched)
|
||||||
|
}
|
||||||
|
// The same search the title page offers, for the tile that is
|
||||||
|
// plainly wrong — the wrong poster is what the grid shows.
|
||||||
|
MenuRow("pencil", "Change match") { pendingMatch = title }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(item: $menuResume) { entry in
|
||||||
|
TileMenu(heading: entry.title.displayName, caption: entry.episode.label) {
|
||||||
|
MenuRow("play.fill", "Resume") { onResume(entry.title, entry.index) }
|
||||||
|
MenuRow("info.circle", "Open") { onTitle(entry.title) }
|
||||||
|
MenuRow("minus.circle", "Remove from Continue watching") {
|
||||||
|
model.forgetPosition(entry.title, entry.episode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(item: $matching) { title in
|
||||||
|
ChangeMatchSheet(title: title) { matching = nil }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder private func content(_ resumable: [Resumable]) -> some View {
|
||||||
|
if model.loading {
|
||||||
|
LibrarySkeleton()
|
||||||
|
} else if model.titles.isEmpty && !model.query.trimmingCharacters(in: .whitespaces).isEmpty {
|
||||||
|
EmptyState("Nothing matches", "No title in the library is called “\(model.query.trimmingCharacters(in: .whitespaces))”.")
|
||||||
|
} else if model.titles.isEmpty {
|
||||||
|
ScrollView {
|
||||||
|
EmptyState("Your library is empty", "Add a Proton Drive public link under Shares, then refresh.")
|
||||||
|
.containerRelativeFrame(.vertical)
|
||||||
|
}
|
||||||
|
.refreshable { await model.crawlNow(nil) }
|
||||||
|
} else {
|
||||||
|
grid(resumable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func grid(_ resumable: [Resumable]) -> some View {
|
||||||
|
// The arrangement names titles by key; before it has arrived, the
|
||||||
|
// titles as they came are the grid.
|
||||||
|
let byKey = Dictionary(model.titles.map { ($0.key, $0) }, uniquingKeysWith: { first, _ in first })
|
||||||
|
let tiles = model.arrangement?.tiles.compactMap { tile in byKey[tile.key].map { Tile(title: $0, folded: Int(tile.folded)) } }
|
||||||
|
?? model.titles.map { Tile(title: $0, folded: 0) }
|
||||||
|
let shelves = (model.arrangement?.shelves ?? [])
|
||||||
|
.map { Shelf(label: $0.label, titles: $0.keys.compactMap { byKey[$0] }) }
|
||||||
|
.filter { !$0.titles.isEmpty }
|
||||||
|
let browsing = model.query.trimmingCharacters(in: .whitespaces).isEmpty
|
||||||
|
let featured = browsing ? featuredTitle(model.titles, resumable) : nil
|
||||||
|
|
||||||
|
return ScrollView {
|
||||||
|
LazyVStack(alignment: .leading, spacing: 16) {
|
||||||
|
if !searching {
|
||||||
|
Text("Library")
|
||||||
|
.textStyle(.headlineMedium)
|
||||||
|
.foregroundStyle(scheme.onSurface)
|
||||||
|
.background {
|
||||||
|
GeometryReader { geometry in
|
||||||
|
Color.clear.onChange(of: geometry.frame(in: .named("library")).maxY < 0) { _, folded in
|
||||||
|
collapsed = folded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let featured {
|
||||||
|
FeaturedBanner(
|
||||||
|
title: featured,
|
||||||
|
resume: resumable.first { $0.title.key == featured.key },
|
||||||
|
onPlay: { onResume(featured, $0) },
|
||||||
|
onOpen: { onTitle(featured) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if browsing && !resumable.isEmpty {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
Text("Continue watching").textStyle(.titleMedium).foregroundStyle(scheme.onSurface)
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
LazyHStack(alignment: .top, spacing: 12) {
|
||||||
|
ForEach(resumable) { entry in
|
||||||
|
ContinueCard(entry: entry)
|
||||||
|
.tapAndHold { onResume(entry.title, entry.index) } onHold: {
|
||||||
|
longPressed { menuResume = entry }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if browsing {
|
||||||
|
ForEach(shelves) { shelf in
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
Text(shelf.label).textStyle(.titleMedium).foregroundStyle(scheme.onSurface)
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
LazyHStack(alignment: .top, spacing: 12) {
|
||||||
|
ForEach(shelf.titles, id: \.key) { title in
|
||||||
|
PosterTile(title: title)
|
||||||
|
.frame(width: 112)
|
||||||
|
.tapAndHold { onTitle(title) } onHold: { longPressed { menuFor = title } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !resumable.isEmpty || !shelves.isEmpty {
|
||||||
|
Text("All titles").textStyle(.titleMedium).foregroundStyle(scheme.onSurface).padding(.top, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Posters, three across on a phone.
|
||||||
|
LazyVGrid(columns: [GridItem(.adaptive(minimum: 108), spacing: 12, alignment: .top)], spacing: 16) {
|
||||||
|
ForEach(tiles) { tile in
|
||||||
|
PosterTile(title: tile.title, folded: tile.folded)
|
||||||
|
.tapAndHold { onTitle(tile.title) } onHold: { longPressed { menuFor = tile.title } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(EdgeInsets(top: 8, leading: 16, bottom: 24, trailing: 16))
|
||||||
|
}
|
||||||
|
.coordinateSpace(name: "library")
|
||||||
|
.refreshable { await model.crawlNow(nil) }
|
||||||
|
.scrollDismissesKeyboard(.immediately)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func longPressed(_ open: () -> Void) {
|
||||||
|
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
|
||||||
|
open()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The grid's order, and whether a franchise is one tile or one per title.
|
||||||
|
private struct ArrangeMenu: View {
|
||||||
|
let sort: LibrarySort
|
||||||
|
let grouped: Bool
|
||||||
|
let onArrange: (LibrarySort, Bool) -> Void
|
||||||
|
@Environment(\.scheme) private var scheme
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Menu {
|
||||||
|
ForEach([LibrarySort.name, .recent, .added, .release, .rating, .popularity], id: \.self) { option in
|
||||||
|
Button {
|
||||||
|
onArrange(option, grouped)
|
||||||
|
} label: {
|
||||||
|
if option == sort { Label(option.label, systemImage: "checkmark") } else { Text(option.label) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Divider()
|
||||||
|
Button {
|
||||||
|
onArrange(sort, !grouped)
|
||||||
|
} label: {
|
||||||
|
if grouped { Label("One tile per franchise", systemImage: "checkmark") } else { Text("One tile per franchise") }
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "arrow.up.arrow.down")
|
||||||
|
.font(.system(size: 20))
|
||||||
|
.frame(width: 48, height: 48)
|
||||||
|
.foregroundStyle(scheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
.accessibilityLabel("Sort library")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension LibrarySort {
|
||||||
|
/// What each order is called. The desktop says the same.
|
||||||
|
var label: String {
|
||||||
|
switch self {
|
||||||
|
case .name: "A – Z"
|
||||||
|
case .recent: "Recently watched"
|
||||||
|
case .added: "Recently added"
|
||||||
|
case .release: "Newest"
|
||||||
|
case .rating: "Highest rated"
|
||||||
|
case .popularity: "Most popular"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The bar while searching: back closes the search and clears it, and the
|
||||||
|
/// field takes the keyboard as soon as it opens.
|
||||||
|
private struct SearchBar: View {
|
||||||
|
@Binding var query: String
|
||||||
|
let onClose: () -> Void
|
||||||
|
@FocusState private var focused: Bool
|
||||||
|
@Environment(\.scheme) private var scheme
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
IconButton("chevron.backward", "Close search", tint: scheme.onSurface, action: onClose)
|
||||||
|
HStack {
|
||||||
|
TextField("", text: $query, prompt: Text("Search library").foregroundStyle(scheme.onSurfaceVariant))
|
||||||
|
.textStyle(.bodyLarge)
|
||||||
|
.foregroundStyle(scheme.onSurface)
|
||||||
|
.focused($focused)
|
||||||
|
.submitLabel(.search)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
if !query.isEmpty {
|
||||||
|
Button { query = "" } label: {
|
||||||
|
Image(systemName: "xmark").foregroundStyle(scheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
.accessibilityLabel("Clear search")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.frame(height: 44)
|
||||||
|
.background(scheme.surfaceContainerHighest, in: Capsule())
|
||||||
|
.padding(.trailing, 12)
|
||||||
|
}
|
||||||
|
.frame(height: 56)
|
||||||
|
.background(scheme.background)
|
||||||
|
.onAppear { focused = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A long press's menu, from the bottom of the screen where the thumb already
|
||||||
|
/// is. Each row closes it before doing its thing.
|
||||||
|
struct TileMenu<Rows: View>: View {
|
||||||
|
let heading: String
|
||||||
|
let caption: String
|
||||||
|
@ViewBuilder var rows: Rows
|
||||||
|
@Environment(\.scheme) private var scheme
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
Text(heading).textStyle(.titleMedium).foregroundStyle(scheme.onSurface).lineLimit(1).padding(.horizontal, 24)
|
||||||
|
Text(caption).textStyle(.bodySmall).foregroundStyle(scheme.onSurfaceVariant)
|
||||||
|
.padding(.horizontal, 24).padding(.bottom, 8)
|
||||||
|
rows
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.padding(.top, 28)
|
||||||
|
.presentationDetents([.height(CGFloat(110 + 56 * 5)), .large])
|
||||||
|
.presentationDragIndicator(.visible)
|
||||||
|
.presentationBackground(scheme.surfaceContainerLow)
|
||||||
|
.presentationCornerRadius(Corner.sheet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MenuRow: View {
|
||||||
|
let icon: String
|
||||||
|
let label: String
|
||||||
|
let action: () -> Void
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
init(_ icon: String, _ label: String, action: @escaping () -> Void) {
|
||||||
|
self.icon = icon
|
||||||
|
self.label = label
|
||||||
|
self.action = action
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button {
|
||||||
|
dismiss()
|
||||||
|
action()
|
||||||
|
} label: {
|
||||||
|
ListRow(icon: icon, headline: label)
|
||||||
|
}
|
||||||
|
.buttonStyle(.pressable)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The grid's shape before the catalog has answered: posters and their two
|
||||||
|
/// lines of text as blocks, so the page does not jump when the titles land.
|
||||||
|
private struct LibrarySkeleton: View {
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
LazyVGrid(columns: [GridItem(.adaptive(minimum: 108), spacing: 12)], spacing: 16) {
|
||||||
|
ForEach(0 ..< 18, id: \.self) { _ in
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
Skeleton().aspectRatio(2 / 3, contentMode: .fit).clipShape(RoundedRectangle(cornerRadius: Corner.standard))
|
||||||
|
Skeleton().frame(height: 12).clipShape(RoundedRectangle(cornerRadius: Corner.standard))
|
||||||
|
.padding(.trailing, 20).padding(.top, 8)
|
||||||
|
Skeleton().frame(height: 10).clipShape(RoundedRectangle(cornerRadius: Corner.standard))
|
||||||
|
.padding(.trailing, 50).padding(.top, 6)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(EdgeInsets(top: 8, leading: 16, bottom: 24, trailing: 16))
|
||||||
|
}
|
||||||
|
.scrollDisabled(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 func featuredTitle(_ titles: [TitleRecord], _ resumable: [Resumable]) -> TitleRecord? {
|
||||||
|
if let first = resumable.first { return first.title }
|
||||||
|
let pictured = titles.filter { $0.backdropUrl != nil }
|
||||||
|
if pictured.isEmpty { return nil }
|
||||||
|
let day = Int(Date().timeIntervalSince1970 + Double(TimeZone.current.secondsFromGMT())) / 86400
|
||||||
|
return pictured[day % pictured.count]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Large art behind the name, rating, genres and three lines of synopsis.
|
||||||
|
private struct FeaturedBanner: View {
|
||||||
|
let title: TitleRecord
|
||||||
|
let resume: Resumable?
|
||||||
|
let onPlay: (Int) -> Void
|
||||||
|
let onOpen: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
RemoteArtwork(title.backdropUrl ?? title.posterUrl, title.displayName, fallback: title.thumbnailSource, labelled: false)
|
||||||
|
.aspectRatio(4 / 3, contentMode: .fit)
|
||||||
|
.overlay {
|
||||||
|
LinearGradient(
|
||||||
|
stops: [.init(color: .clear, location: 0.35), .init(color: .black.opacity(0.85), location: 1)],
|
||||||
|
startPoint: .top,
|
||||||
|
endPoint: .bottom
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.overlay(alignment: .bottomLeading) {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
Text(title.displayName).textStyle(.headlineMedium).foregroundStyle(.white).lineLimit(2)
|
||||||
|
Text(factLine).textStyle(.bodySmall).foregroundStyle(.white.opacity(0.8))
|
||||||
|
if let overview = title.overview {
|
||||||
|
Text(overview.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||||
|
.textStyle(.bodySmall).foregroundStyle(.white.opacity(0.8)).lineLimit(3).padding(.top, 6)
|
||||||
|
}
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Button { onPlay(resume?.index ?? nextUpIndex(title.playlist)) } label: {
|
||||||
|
Label(resume != nil ? "Resume" : "Play", systemImage: "play.fill")
|
||||||
|
}
|
||||||
|
.buttonStyle(.accent)
|
||||||
|
Button("More info", action: onOpen).buttonStyle(.tonal)
|
||||||
|
}
|
||||||
|
.padding(.top, 12)
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
}
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: Corner.standard))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var factLine: String {
|
||||||
|
let parts = [
|
||||||
|
title.rating.map { String(format: "★ %.1f", $0) },
|
||||||
|
title.genres.isEmpty ? nil : title.genres.prefix(3).joined(separator: ", "),
|
||||||
|
].compactMap { $0 }
|
||||||
|
return parts.isEmpty ? title.caption : parts.joined(separator: " · ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A part-watched episode: its still, where it was left, and what is left of it.
|
||||||
|
private struct ContinueCard: View {
|
||||||
|
let entry: Resumable
|
||||||
|
@Environment(\.scheme) private var scheme
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
RemoteArtwork(entry.episode.stillUrl ?? entry.title.backdropUrl, entry.title.displayName, fallback: entry.episode.thumbnailSource)
|
||||||
|
.aspectRatio(16 / 9, contentMode: .fit)
|
||||||
|
.overlay(alignment: .bottom) {
|
||||||
|
if let progress = entry.episode.progress { AccentProgress(progress: progress) }
|
||||||
|
}
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: Corner.standard))
|
||||||
|
Text(entry.title.displayName).textStyle(.bodyLarge, semibold: true).foregroundStyle(scheme.onSurface)
|
||||||
|
.lineLimit(1).padding(.top, 8)
|
||||||
|
Text([entry.episode.label, entry.episode.timeLeft].compactMap { $0 }.joined(separator: " · "))
|
||||||
|
.textStyle(.bodySmall).foregroundStyle(scheme.onSurfaceVariant).lineLimit(1)
|
||||||
|
}
|
||||||
|
.frame(width: 220)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One title: its poster, and its name and size under it rather than in a card.
|
||||||
|
///
|
||||||
|
/// `folded` is how many more titles of its franchise the tile stands for, said
|
||||||
|
/// in the corner.
|
||||||
|
struct PosterTile: View {
|
||||||
|
let title: TitleRecord
|
||||||
|
var folded = 0
|
||||||
|
@Environment(\.scheme) private var scheme
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
RemoteArtwork(title.posterUrl ?? title.backdropUrl, title.displayName, fallback: title.thumbnailSource)
|
||||||
|
.aspectRatio(2 / 3, contentMode: .fit)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: Corner.standard))
|
||||||
|
.overlay(alignment: .topTrailing) {
|
||||||
|
if folded > 0 {
|
||||||
|
Text("+\(folded)")
|
||||||
|
.textStyle(.labelSmall)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.padding(.horizontal, 6).padding(.vertical, 2)
|
||||||
|
.background(.black.opacity(0.7), in: RoundedRectangle(cornerRadius: Corner.standard))
|
||||||
|
.padding(6)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(title.displayName).textStyle(.bodyMedium, semibold: true).foregroundStyle(scheme.onSurface)
|
||||||
|
.lineLimit(2).padding(.top, 6)
|
||||||
|
// Two lines, not one: at a large text size "2023 · 7 episodes"
|
||||||
|
// does not fit a poster's width.
|
||||||
|
Text(title.caption).textStyle(.bodySmall).foregroundStyle(scheme.onSurfaceVariant).lineLimit(2)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension TitleRecord {
|
||||||
|
/// "2023 · 11 episodes", or "1988 · Film" — never "1 episodes".
|
||||||
|
var caption: String {
|
||||||
|
let count: String
|
||||||
|
if (metadataKind ?? kind) == .film && episodeCount == 1 {
|
||||||
|
count = "Film"
|
||||||
|
} else if episodeCount == 1 {
|
||||||
|
count = "1 episode"
|
||||||
|
} else {
|
||||||
|
count = "\(episodeCount) episodes"
|
||||||
|
}
|
||||||
|
return [(metadataYear ?? year).map(String.init), count].compactMap { $0 }.joined(separator: " · ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension EpisodeRecord {
|
||||||
|
/// "12 min left", from where the episode was left and how far through that
|
||||||
|
/// is. Nil when either is unknown — a guess would be worse than no figure.
|
||||||
|
var timeLeft: String? {
|
||||||
|
guard let at = resumeAt, let fraction = progress, fraction > 0, fraction < 1 else { return nil }
|
||||||
|
let minutes = Int((at / fraction - at) / 60)
|
||||||
|
return minutes < 1 ? "under a minute left" : "\(minutes) min left"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension TitleRecord: Identifiable {
|
||||||
|
public var id: String { key }
|
||||||
|
}
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
import CryptoKit
|
||||||
|
import ImageIO
|
||||||
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// One file to pull Proton's own thumbnail from when no provider poster exists.
|
||||||
|
///
|
||||||
|
/// Proton renders a thumbnail per file, so any episode of a show is a frame of
|
||||||
|
/// the right show — the desktop client picks the first for the same reason.
|
||||||
|
struct ThumbnailSource: Hashable, Sendable {
|
||||||
|
let shareId: String
|
||||||
|
let volumeId: String
|
||||||
|
let linkId: String
|
||||||
|
|
||||||
|
var key: String { "\(shareId)/\(linkId)" }
|
||||||
|
}
|
||||||
|
|
||||||
|
extension TitleRecord {
|
||||||
|
/// What the first episode of a title offers as a poster. Metadata lookups
|
||||||
|
/// are off by default, so for a freshly crawled library this is the only
|
||||||
|
/// artwork there is.
|
||||||
|
var thumbnailSource: ThumbnailSource? {
|
||||||
|
seasons.lazy.compactMap(\.episodes.first).first.map(\.thumbnailSource)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension EpisodeRecord {
|
||||||
|
var thumbnailSource: ThumbnailSource { ThumbnailSource(shareId: shareId, volumeId: volumeId, linkId: linkId) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// HTTPS-only artwork, cropped to whatever frame its parent gives it.
|
||||||
|
///
|
||||||
|
/// `fallback` is used only when there is no provider artwork or it fails to
|
||||||
|
/// load, which — with metadata off — is the common case rather than the rare
|
||||||
|
/// one. Until either answers, the box pulses as a skeleton; when neither has
|
||||||
|
/// anything, it shows `ArtworkPlaceholder` rather than an empty card — without
|
||||||
|
/// initials when `labelled` is false, for art with the name already over it.
|
||||||
|
struct RemoteArtwork: View {
|
||||||
|
let url: String?
|
||||||
|
let description: String
|
||||||
|
var fallback: ThumbnailSource?
|
||||||
|
var labelled = true
|
||||||
|
@State private var load: ArtworkLoad
|
||||||
|
|
||||||
|
init(_ url: String?, _ description: String, fallback: ThumbnailSource? = nil, labelled: Bool = true) {
|
||||||
|
self.url = url
|
||||||
|
self.description = description
|
||||||
|
self.fallback = fallback
|
||||||
|
self.labelled = labelled
|
||||||
|
// A poster already decoded draws in the first frame, so scrolling back
|
||||||
|
// over the grid does not flash a skeleton per tile.
|
||||||
|
_load = State(initialValue: ArtworkLoader.shared.cached(url: url, fallback: fallback).map(ArtworkLoad.loaded) ?? .pending)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Color.clear
|
||||||
|
.overlay {
|
||||||
|
switch load {
|
||||||
|
case .pending:
|
||||||
|
Skeleton()
|
||||||
|
case .missing:
|
||||||
|
ArtworkPlaceholder(name: description, labelled: labelled)
|
||||||
|
case let .loaded(image):
|
||||||
|
Image(uiImage: image).resizable().scaledToFill()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.clipped()
|
||||||
|
.accessibilityElement()
|
||||||
|
.accessibilityLabel(description)
|
||||||
|
.task(id: Request(url: url, fallback: fallback)) {
|
||||||
|
if case .loaded = load, ArtworkLoader.shared.cached(url: url, fallback: fallback) != nil { return }
|
||||||
|
let loaded = await ArtworkLoader.shared.artwork(url: url, fallback: fallback)
|
||||||
|
if Task.isCancelled { return }
|
||||||
|
load = loaded.map(ArtworkLoad.loaded) ?? .missing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct Request: Hashable {
|
||||||
|
let url: String?
|
||||||
|
let fallback: ThumbnailSource?
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum ArtworkLoad {
|
||||||
|
case pending
|
||||||
|
case missing
|
||||||
|
case loaded(UIImage)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Something being loaded: the surface tint, breathing.
|
||||||
|
///
|
||||||
|
/// A spinner per tile is a grid of spinners; a shape where the poster will be
|
||||||
|
/// says what is coming and where, and settles the layout before it arrives.
|
||||||
|
struct Skeleton: View {
|
||||||
|
@Environment(\.scheme) private var scheme
|
||||||
|
@State private var dim = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
scheme.surfaceVariant
|
||||||
|
.opacity(dim ? 0.55 : 1)
|
||||||
|
.onAppear {
|
||||||
|
withAnimation(.linear(duration: 0.8).repeatForever(autoreverses: true)) { dim = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Art for a title that has none: the accent as a gradient, and its initials.
|
||||||
|
///
|
||||||
|
/// Each name sets where on the accent ramp its gradient starts, so neighbours
|
||||||
|
/// differ while every one stays in the theme's colours — and since the hash is
|
||||||
|
/// Java's, a title gets the same colour here as on Android.
|
||||||
|
struct ArtworkPlaceholder: View {
|
||||||
|
let name: String
|
||||||
|
var labelled = true
|
||||||
|
@Environment(\.scheme) private var scheme
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let start = mix(scheme.primary, scheme.secondary, Double(javaHash(name).modulo(5)) / 4)
|
||||||
|
GeometryReader { geometry in
|
||||||
|
LinearGradient(colors: [start, mix(start, .black, 0.55)], startPoint: .topLeading, endPoint: .bottomTrailing)
|
||||||
|
.overlay {
|
||||||
|
if labelled {
|
||||||
|
Text(initials(name))
|
||||||
|
.font(.inter(size: min(geometry.size.width, geometry.size.height) * 0.3, semibold: true))
|
||||||
|
.foregroundStyle(Color.white.opacity(0.9))
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "Oshi no Ko" is "OK", "Akira" is "A": the first letters of up to two
|
||||||
|
/// capitalised words, so particles and articles in a romanised name do not
|
||||||
|
/// take a slot. A name with no capitals uses its first two words.
|
||||||
|
func initials(_ name: String) -> String {
|
||||||
|
let words = name
|
||||||
|
.split(whereSeparator: { " -_.:".contains($0) })
|
||||||
|
.filter { $0.first.map { $0.isLetter || $0.isNumber } ?? false }
|
||||||
|
let capitalised = words.filter { $0.first!.isUppercase || $0.first!.isNumber }
|
||||||
|
return (capitalised.isEmpty ? words : capitalised).prefix(2).map { $0.first!.uppercased() }.joined()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `String.hashCode()` from the JVM, over UTF-16 code units.
|
||||||
|
func javaHash(_ text: String) -> Int32 {
|
||||||
|
text.utf16.reduce(Int32(0)) { hash, unit in hash &* 31 &+ Int32(unit) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension Int32 {
|
||||||
|
/// Kotlin's `mod`: never negative.
|
||||||
|
func modulo(_ divisor: Int32) -> Int32 {
|
||||||
|
let remainder = self % divisor
|
||||||
|
return remainder < 0 ? remainder + divisor : remainder
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The HTTPS-only fetch behind every poster, its disk cache under
|
||||||
|
/// `Caches/metadata-art`, and Proton's own thumbnails as the fallback.
|
||||||
|
final class ArtworkLoader: @unchecked Sendable {
|
||||||
|
static let shared = ArtworkLoader()
|
||||||
|
|
||||||
|
/// Decoded posters, so scrolling the grid re-reads neither disk nor
|
||||||
|
/// decoder. Keyed by artwork URL or by `share/link` for a Proton
|
||||||
|
/// thumbnail; the two cannot collide, since one is always an `https:` URL.
|
||||||
|
private let decoded: NSCache<NSString, UIImage> = {
|
||||||
|
let cache = NSCache<NSString, UIImage>()
|
||||||
|
cache.totalCostLimit = 32 * 1024 * 1024
|
||||||
|
return cache
|
||||||
|
}()
|
||||||
|
|
||||||
|
/// Files known to have no Proton thumbnail, so the answer is paid for once.
|
||||||
|
/// Deliberately not persisted: a re-upload can add one.
|
||||||
|
private var missing = Set<String>()
|
||||||
|
private let lock = NSLock()
|
||||||
|
|
||||||
|
private let session: URLSession = {
|
||||||
|
let configuration = URLSessionConfiguration.ephemeral
|
||||||
|
configuration.timeoutIntervalForRequest = 10
|
||||||
|
configuration.urlCache = nil
|
||||||
|
return URLSession(configuration: configuration)
|
||||||
|
}()
|
||||||
|
|
||||||
|
private static let maxBytes = 12 * 1024 * 1024
|
||||||
|
private static let maxCacheBytes = 48 * 1024 * 1024
|
||||||
|
/// Longest edge worth decoding: a poster on a phone, or lock-screen art.
|
||||||
|
private static let maxEdge = 1024
|
||||||
|
|
||||||
|
func cached(url: String?, fallback: ThumbnailSource?) -> UIImage? {
|
||||||
|
if let url, let image = decoded.object(forKey: url as NSString) { return image }
|
||||||
|
if let key = fallback?.key { return decoded.object(forKey: key as NSString) }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func artwork(url: String?, fallback: ThumbnailSource?) async -> UIImage? {
|
||||||
|
if let image = await remote(url) { return image }
|
||||||
|
return await thumbnail(fallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Provider artwork, from the disk cache or the network.
|
||||||
|
func remote(_ requested: String?) async -> UIImage? {
|
||||||
|
guard let requested else { return nil }
|
||||||
|
if let image = decoded.object(forKey: requested as NSString) { return image }
|
||||||
|
guard let url = URL(string: requested), url.scheme?.lowercased() == "https" else { return nil }
|
||||||
|
let directory = Self.directory
|
||||||
|
let digest = SHA256.hash(data: Data(requested.utf8)).map { String(format: "%02x", $0) }.joined()
|
||||||
|
let cachedFile = directory.appendingPathComponent("\(digest).img")
|
||||||
|
let files = FileManager.default
|
||||||
|
let fresh = !files.fileExists(atPath: cachedFile.path)
|
||||||
|
let bytes: Data
|
||||||
|
if fresh {
|
||||||
|
guard let downloaded = await download(url) else { return nil }
|
||||||
|
bytes = downloaded
|
||||||
|
} else {
|
||||||
|
guard let read = try? Data(contentsOf: cachedFile) else { return nil }
|
||||||
|
bytes = read
|
||||||
|
}
|
||||||
|
guard let image = Self.decodeSampled(bytes) else { return nil }
|
||||||
|
if fresh {
|
||||||
|
// Written only once it has decoded: caching the bytes first is what
|
||||||
|
// turns one hostile poster into a failure on every launch.
|
||||||
|
let temporary = directory.appendingPathComponent("\(digest).part")
|
||||||
|
if (try? bytes.write(to: temporary)) != nil {
|
||||||
|
try? files.moveItem(at: temporary, to: cachedFile)
|
||||||
|
Self.prune(directory)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try? files.setAttributes([.modificationDate: Date()], ofItemAtPath: cachedFile.path)
|
||||||
|
}
|
||||||
|
decoded.setObject(image, forKey: requested as NSString, cost: image.cost)
|
||||||
|
return image
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Proton's own thumbnail for one file, decoded. A failed call is not
|
||||||
|
/// remembered — an unreachable share is transient — but a file with no
|
||||||
|
/// thumbnail is, for the session.
|
||||||
|
func thumbnail(_ source: ThumbnailSource?) async -> UIImage? {
|
||||||
|
guard let source else { return nil }
|
||||||
|
let key = source.key
|
||||||
|
if let image = decoded.object(forKey: key as NSString) { return image }
|
||||||
|
if lock.withLock({ missing.contains(key) }) { return nil }
|
||||||
|
let fetched: Data?
|
||||||
|
do {
|
||||||
|
fetched = try await NativeRuntime.engine().thumbnail(shareId: source.shareId, volumeId: source.volumeId, linkId: source.linkId)
|
||||||
|
} catch {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
guard let bytes = fetched, let image = Self.decodeSampled(bytes) else {
|
||||||
|
lock.withLock { _ = missing.insert(key) }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
decoded.setObject(image, forKey: key as NSString, cost: image.cost)
|
||||||
|
return image
|
||||||
|
}
|
||||||
|
|
||||||
|
private func download(_ url: URL) async -> Data? {
|
||||||
|
guard let (stream, response) = try? await session.bytes(from: url),
|
||||||
|
let http = response as? HTTPURLResponse,
|
||||||
|
(200 ... 299).contains(http.statusCode),
|
||||||
|
// A redirect must not leave HTTPS.
|
||||||
|
http.url?.scheme?.lowercased() == "https"
|
||||||
|
else { return nil }
|
||||||
|
// Refused before reading when the server says up front it is too big.
|
||||||
|
if response.expectedContentLength > Int64(Self.maxBytes) { return nil }
|
||||||
|
// An array sized once, not a `Data` grown a byte at a time: on an old
|
||||||
|
// iPad, a screen of posters read that way was seconds of CPU.
|
||||||
|
var bytes = [UInt8]()
|
||||||
|
bytes.reserveCapacity(Int(min(max(response.expectedContentLength, 0), Int64(Self.maxBytes))))
|
||||||
|
do {
|
||||||
|
for try await byte in stream {
|
||||||
|
bytes.append(byte)
|
||||||
|
if bytes.count > Self.maxBytes { return nil }
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return Data(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode within a bound the display can actually use: the compressed size
|
||||||
|
/// says nothing about what an image costs decoded.
|
||||||
|
private static func decodeSampled(_ bytes: Data) -> UIImage? {
|
||||||
|
guard let source = CGImageSourceCreateWithData(bytes as CFData, nil) else { return nil }
|
||||||
|
let options: [CFString: Any] = [
|
||||||
|
kCGImageSourceCreateThumbnailFromImageAlways: true,
|
||||||
|
kCGImageSourceCreateThumbnailWithTransform: true,
|
||||||
|
kCGImageSourceShouldCacheImmediately: true,
|
||||||
|
kCGImageSourceThumbnailMaxPixelSize: maxEdge,
|
||||||
|
]
|
||||||
|
guard let image = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else { return nil }
|
||||||
|
return UIImage(cgImage: image)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static var directory: URL {
|
||||||
|
let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
|
||||||
|
let directory = caches.appendingPathComponent("metadata-art", isDirectory: true)
|
||||||
|
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||||
|
return directory
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hold the cache directory under `maxCacheBytes`, oldest first.
|
||||||
|
private static func prune(_ directory: URL) {
|
||||||
|
let keys: [URLResourceKey] = [.contentModificationDateKey, .fileSizeKey]
|
||||||
|
guard let files = try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: keys) else { return }
|
||||||
|
let entries = files.compactMap { file -> (URL, Date, Int)? in
|
||||||
|
guard let values = try? file.resourceValues(forKeys: Set(keys)) else { return nil }
|
||||||
|
return (file, values.contentModificationDate ?? .distantPast, values.fileSize ?? 0)
|
||||||
|
}.sorted { $0.1 < $1.1 }
|
||||||
|
var total = entries.reduce(0) { $0 + $1.2 }
|
||||||
|
for (file, _, size) in entries {
|
||||||
|
if total <= maxCacheBytes { return }
|
||||||
|
total -= size
|
||||||
|
try? FileManager.default.removeItem(at: file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension UIImage {
|
||||||
|
var cost: Int {
|
||||||
|
guard let image = cgImage else { return 0 }
|
||||||
|
return image.bytesPerRow * image.height
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,527 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// The areas the first page lists, each a page of its own.
|
||||||
|
enum SettingsPage: String, CaseIterable, Identifiable {
|
||||||
|
case playback, storage, appearance, metadata, about
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .playback: "Playback"
|
||||||
|
case .storage: "Downloads and storage"
|
||||||
|
case .appearance: "Appearance"
|
||||||
|
case .metadata: "Metadata"
|
||||||
|
case .about: "About"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var icon: String {
|
||||||
|
switch self {
|
||||||
|
case .playback: "play.circle"
|
||||||
|
case .storage: "externaldrive"
|
||||||
|
case .appearance: "paintpalette"
|
||||||
|
case .metadata: "text.magnifyingglass"
|
||||||
|
case .about: "info.circle"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The installed version, as the Home Screen and the sideloading store show it.
|
||||||
|
let appVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown"
|
||||||
|
|
||||||
|
struct SettingsView: View {
|
||||||
|
@Environment(AppModel.self) private var model
|
||||||
|
@Environment(\.scheme) private var scheme
|
||||||
|
// One row per area on the first page, each opening its own page.
|
||||||
|
@SceneStorage("settings.page") private var page: SettingsPage?
|
||||||
|
@State private var settings = SettingsValues()
|
||||||
|
// 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.
|
||||||
|
@State private var prefs: PlaybackPrefsRecord?
|
||||||
|
@State private var confirmDelete = false
|
||||||
|
@State private var showMetadata = false
|
||||||
|
/// How far an edge swipe back has gone, 0 to 1.
|
||||||
|
@State private var backProgress: CGFloat = 0
|
||||||
|
@State private var legalDocument: LegalDocument?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if let page {
|
||||||
|
subPage(page)
|
||||||
|
} else {
|
||||||
|
TabPage("Settings") {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
ForEach(SettingsPage.allCases) { entry in
|
||||||
|
Button { page = entry } label: {
|
||||||
|
ListRow(icon: entry.icon, headline: entry.title, supporting: subtitle(entry))
|
||||||
|
}
|
||||||
|
.buttonStyle(.pressable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Read again whenever the list is shown, not once: sync can rewrite
|
||||||
|
// them while the page is open, and a page holding the old copy would
|
||||||
|
// write it back over the new one on the next switch.
|
||||||
|
.task(id: page) {
|
||||||
|
do {
|
||||||
|
prefs = try await model.run { try $0.playbackPrefs() }
|
||||||
|
} catch {
|
||||||
|
model.reportError(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.alert("Delete all offline episodes?", isPresented: $confirmDelete) {
|
||||||
|
Button("Delete", role: .destructive, action: model.removeAllOffline)
|
||||||
|
Button("Cancel", role: .cancel) {}
|
||||||
|
} message: {
|
||||||
|
Text("\(model.storage.offlineCount) episodes (\(formatBytes(model.storage.offlineBytes))) will be removed from this device. Watch history is kept, and anything deleted can be downloaded again.")
|
||||||
|
}
|
||||||
|
.sheet(item: $legalDocument) { LegalDocumentSheet(document: $0) }
|
||||||
|
.sheet(isPresented: $showMetadata) {
|
||||||
|
MetadataSettingsSheet(current: model.metadataSettings) { enabled, provider, language, key in
|
||||||
|
await model.saveMetadataSettings(enabled: enabled, provider: provider, language: language, apiKey: key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func subtitle(_ entry: SettingsPage) -> String {
|
||||||
|
switch entry {
|
||||||
|
case .playback: "Autoplay, languages and decoding"
|
||||||
|
case .storage: "\(formatBytes(model.storage.cacheBytes)) cached · \(model.storage.offlineCount) offline"
|
||||||
|
case .appearance: "Palette, accent and gradient"
|
||||||
|
case .metadata: model.metadataSettings.enabled ? "On · \(model.metadataSettings.provider.displayName)" : "Off"
|
||||||
|
case .about: "Version \(appVersion) · licences"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One area's page: its name, a way back, and its rows.
|
||||||
|
private func subPage(_ page: SettingsPage) -> some View {
|
||||||
|
TabPage(page.title, leading: {
|
||||||
|
IconButton("chevron.backward", "Back to settings", tint: scheme.onSurface) { self.page = nil }
|
||||||
|
}) {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
switch page {
|
||||||
|
case .playback: playback
|
||||||
|
case .storage: storage
|
||||||
|
case .appearance: AppearancePicker { model.reloadAfterMetadataChange() }.padding(.horizontal, 16)
|
||||||
|
case .metadata: metadata
|
||||||
|
case .about: about
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.bottom, 24)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.scaleEffect(1 - 0.1 * backProgress)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 32 * backProgress))
|
||||||
|
// Back by an edge swipe, as from a title page — from a strip of its
|
||||||
|
// own, as there. On the whole page the swipe began on whichever row
|
||||||
|
// was under the edge, and lifting the finger pressed it: the way back
|
||||||
|
// from Metadata opened the enrichment sheet.
|
||||||
|
.overlay(alignment: .leading) {
|
||||||
|
Color.clear
|
||||||
|
.frame(width: 20)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.gesture(
|
||||||
|
DragGesture(minimumDistance: 10)
|
||||||
|
.onChanged { drag in backProgress = min(max(drag.translation.width / 300, 0), 1) }
|
||||||
|
.onEnded { drag in
|
||||||
|
if drag.translation.width > 100 || drag.predictedEndTranslation.width > 250 {
|
||||||
|
self.page = nil
|
||||||
|
}
|
||||||
|
withAnimation(.spring) { backProgress = 0 }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder private var playback: some View {
|
||||||
|
if let current = prefs {
|
||||||
|
SwitchRow(headline: "Autoplay next episode", supporting: "Starts the next episode when the credits begin",
|
||||||
|
isOn: preference(current.autoplayNext) { $0.autoplayNext = $1 })
|
||||||
|
// Off is the safer default: a mis-named chapter then costs a tap on
|
||||||
|
// Skip rather than a scene.
|
||||||
|
SwitchRow(headline: "Skip openings and endings", supporting: "Uses the release's chapters; off shows a Skip button instead",
|
||||||
|
isOn: preference(current.autoSkip) { $0.autoSkip = $1 })
|
||||||
|
SwitchRow(headline: "Subtitles", supporting: "Shown by default where a file has them",
|
||||||
|
isOn: preference(current.subtitles) { $0.subtitles = $1 })
|
||||||
|
// Preferences, not filters: a file with nothing in the language
|
||||||
|
// plays its own default track. A show that has been given its own
|
||||||
|
// choice keeps it.
|
||||||
|
LanguageSetting(headline: "Audio language", value: current.audioLanguage) { tag in
|
||||||
|
update { $0.audioLanguage = tag }
|
||||||
|
}
|
||||||
|
LanguageSetting(headline: "Subtitle language", value: current.subtitleLanguage) { tag in
|
||||||
|
update { $0.subtitleLanguage = tag }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SwitchRow(headline: "Hardware decoding", supporting: "Turn off if video shows green or torn frames", isOn: $settings.hardwareDecoding)
|
||||||
|
SwitchRow(headline: "Background audio", supporting: "Keeps playing with the app closed, from the lock screen", isOn: $settings.backgroundAudio)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder private var storage: some View {
|
||||||
|
let budget = settings.cacheBudgetGib
|
||||||
|
SwitchRow(headline: "Download on Wi-Fi only", supporting: "Mobile data is never used for downloads", isOn: Binding(
|
||||||
|
get: { settings.wifiOnly },
|
||||||
|
set: {
|
||||||
|
settings.wifiOnly = $0
|
||||||
|
// A queue that already exists keeps the policy it was queued
|
||||||
|
// under until it is re-issued.
|
||||||
|
DownloadCoordinator.shared.applyNetworkPolicy()
|
||||||
|
}
|
||||||
|
))
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
Text("Streaming cache").textStyle(.bodyLarge).foregroundStyle(scheme.onSurface)
|
||||||
|
Text("\(formatBytes(model.storage.cacheBytes)) of \(budget) GiB used").textStyle(.bodySmall).foregroundStyle(scheme.onSurfaceVariant)
|
||||||
|
AccentProgress(progress: Double(model.storage.cacheBytes) / Double(SettingsStore.gibToBytes(budget)))
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
// 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.
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ForEach(SettingsStore.cacheBudgetChoices, id: \.self) { gib in
|
||||||
|
FilterChip(label: "\(gib) GiB", selected: gib == budget) {
|
||||||
|
settings.cacheBudgetGib = gib
|
||||||
|
Task.detached { try? NativeRuntime.blockingEngine().setStreamCacheBudget(bytes: SettingsStore.gibToBytes(gib)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
// The cache is rebuildable, so it goes without asking. Offline episodes
|
||||||
|
// are a choice the viewer made, so that one asks.
|
||||||
|
SettingAction(headline: "Clear streaming cache", supporting: "Frees \(formatBytes(model.storage.cacheBytes))", action: model.clearBlockCache)
|
||||||
|
let storage = model.storage
|
||||||
|
SettingAction(
|
||||||
|
headline: "Delete all offline episodes",
|
||||||
|
supporting: "\(storage.offlineCount) \(storage.offlineCount == 1 ? "episode" : "episodes") · \(formatBytes(storage.offlineBytes))"
|
||||||
|
+ (storage.partialBytes > 0 ? " · \(formatBytes(storage.partialBytes)) unfinished" : ""),
|
||||||
|
enabled: storage.offlineCount > 0 || storage.partialBytes > 0
|
||||||
|
) { confirmDelete = true }
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder private var metadata: some View {
|
||||||
|
SettingAction(
|
||||||
|
headline: "Metadata enrichment",
|
||||||
|
supporting: model.metadataSettings.enabled
|
||||||
|
? "On · \(model.metadataSettings.provider.displayName) · sends title names to it"
|
||||||
|
: "Off, the privacy default"
|
||||||
|
) { showMetadata = true }
|
||||||
|
// Every title looked up again, matched ones included: the way out of a
|
||||||
|
// library the provider answered wrong.
|
||||||
|
SettingAction(
|
||||||
|
headline: "Match everything again",
|
||||||
|
supporting: "Looks every title up again, hand-picked matches excepted",
|
||||||
|
enabled: model.metadataSettings.enabled && !model.refreshing
|
||||||
|
) { model.matchTitles(force: true) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder private var about: some View {
|
||||||
|
ListRow(headline: "Proton Stream", supporting: "Version \(appVersion)")
|
||||||
|
SettingAction(headline: "Licence", supporting: "GPL-3.0-or-later. Comes with absolutely no warranty.") {
|
||||||
|
legalDocument = LegalDocument(title: "GNU GPL v3", resource: "GPL-3.0", type: "txt")
|
||||||
|
}
|
||||||
|
SettingAction(headline: "Third-party notices", supporting: "libmpv, Inter and the other components bundled") {
|
||||||
|
legalDocument = LegalDocument(title: "Third-party notices", resource: "THIRD_PARTY_NOTICES", type: "md")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func preference(_ value: Bool, _ change: @escaping (inout PlaybackPrefsRecord, Bool) -> Void) -> Binding<Bool> {
|
||||||
|
Binding(get: { value }, set: { on in update { change(&$0, on) } })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Change a preference on screen at once, and store it. A store that
|
||||||
|
/// fails says so and puts the switch back, rather than leaving one that
|
||||||
|
/// looks saved and is not.
|
||||||
|
private func update(_ change: (inout PlaybackPrefsRecord) -> Void) {
|
||||||
|
guard var next = prefs else { return }
|
||||||
|
let before = next
|
||||||
|
change(&next)
|
||||||
|
prefs = next
|
||||||
|
let stored = next
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
try await model.run { try $0.setPlaybackPrefs(prefs: stored) }
|
||||||
|
} catch {
|
||||||
|
if prefs == stored { prefs = before }
|
||||||
|
model.reportError(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `SettingsStore` as observable state, so a switch redraws when it is flipped.
|
||||||
|
@Observable
|
||||||
|
private final class SettingsValues {
|
||||||
|
private let store = SettingsStore()
|
||||||
|
var wifiOnly: Bool { didSet { store.wifiOnly = wifiOnly } }
|
||||||
|
var backgroundAudio: Bool { didSet { store.backgroundAudio = backgroundAudio } }
|
||||||
|
var hardwareDecoding: Bool { didSet { store.hardwareDecoding = hardwareDecoding } }
|
||||||
|
var cacheBudgetGib: Int { didSet { store.cacheBudgetGib = cacheBudgetGib } }
|
||||||
|
|
||||||
|
init() {
|
||||||
|
wifiOnly = store.wifiOnly
|
||||||
|
backgroundAudio = store.backgroundAudio
|
||||||
|
hardwareDecoding = store.hardwareDecoding
|
||||||
|
cacheBudgetGib = store.cacheBudgetGib
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A row that does something when tapped: opens a dialog, clears a cache.
|
||||||
|
private struct SettingAction: View {
|
||||||
|
let headline: String
|
||||||
|
let supporting: String
|
||||||
|
var enabled = true
|
||||||
|
let action: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: action) { ListRow(headline: headline, supporting: supporting, enabled: enabled) }
|
||||||
|
.buttonStyle(.pressable)
|
||||||
|
.disabled(!enabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct MetadataSettingsSheet: View {
|
||||||
|
let current: MetadataSettingsRecord
|
||||||
|
/// Resolves to why the settings were not stored, or nil once they are.
|
||||||
|
let onSave: (Bool, MetadataProvider, String, String) async -> String?
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@Environment(\.scheme) private var scheme
|
||||||
|
@State private var enabled: Bool
|
||||||
|
@State private var provider: MetadataProvider
|
||||||
|
@State private var language: String
|
||||||
|
@State private var apiKey = ""
|
||||||
|
@State private var saving = false
|
||||||
|
@State private var refusal: String?
|
||||||
|
|
||||||
|
init(current: MetadataSettingsRecord, onSave: @escaping (Bool, MetadataProvider, String, String) async -> String?) {
|
||||||
|
self.current = current
|
||||||
|
self.onSave = onSave
|
||||||
|
_enabled = State(initialValue: current.enabled)
|
||||||
|
_provider = State(initialValue: current.provider)
|
||||||
|
_language = State(initialValue: current.language)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
DialogSheet("Metadata enrichment") {
|
||||||
|
Text("Off by default: enabling sends the titles in your library to a third party, associated with your IP address and subject to their privacy policy. With AniList, the matched ids also go to ani.zip for episode names and wide artwork.")
|
||||||
|
Toggle("Enable enrichment", isOn: $enabled).tint(scheme.primary).foregroundStyle(scheme.onSurface).padding(.vertical, 6)
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ForEach([MetadataProvider.aniList, .tmdb], id: \.self) { option in
|
||||||
|
Button(option.displayName) { provider = option }
|
||||||
|
.buttonStyle(option == provider ? ProtonButtonStyle(kind: .accent) : ProtonButtonStyle(kind: .edged))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(provider == .aniList ? "Anime; no account or API key required." : "Film and television; requires a free TMDB API key.")
|
||||||
|
.textStyle(.bodySmall)
|
||||||
|
if provider == .tmdb {
|
||||||
|
Field(label: "Language", text: $language)
|
||||||
|
Field(label: current.ready ? "TMDB API key (leave blank to keep)" : "TMDB API key", text: $apiKey, secret: true)
|
||||||
|
}
|
||||||
|
if enabled, provider == .tmdb, !current.ready, apiKey.trimmingCharacters(in: .whitespaces).isEmpty {
|
||||||
|
Text("Enter a TMDB API key to save.").foregroundStyle(scheme.error)
|
||||||
|
}
|
||||||
|
if let refusal {
|
||||||
|
Text(refusal).foregroundStyle(scheme.error)
|
||||||
|
}
|
||||||
|
} buttons: {
|
||||||
|
Button("Cancel") { dismiss() }.buttonStyle(.tonal)
|
||||||
|
Button(saving ? "Saving…" : "Save") {
|
||||||
|
saving = true
|
||||||
|
refusal = nil
|
||||||
|
Task {
|
||||||
|
refusal = await onSave(enabled, provider, language.trimmingCharacters(in: .whitespaces).isEmpty ? "en" : language, apiKey)
|
||||||
|
saving = false
|
||||||
|
if refusal == nil { dismiss() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.accent)
|
||||||
|
.disabled(saving || !(!enabled || provider != .tmdb || current.ready || !apiKey.trimmingCharacters(in: .whitespaces).isEmpty))
|
||||||
|
}
|
||||||
|
.interactiveDismissDisabled(saving)
|
||||||
|
.secureContent()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct LegalDocument: Identifiable {
|
||||||
|
let title: String
|
||||||
|
let resource: String
|
||||||
|
let type: String
|
||||||
|
var id: String { resource }
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct LegalDocumentSheet: View {
|
||||||
|
let document: LegalDocument
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@State private var contents = "Loading…"
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
DialogSheet(document.title) {
|
||||||
|
Text(contents).textStyle(.bodySmall).textSelection(.enabled)
|
||||||
|
} buttons: {
|
||||||
|
Button("Close") { dismiss() }.buttonStyle(.accent)
|
||||||
|
}
|
||||||
|
.presentationDetents([.large])
|
||||||
|
.task {
|
||||||
|
let url = Bundle.main.url(forResource: document.resource, withExtension: document.type)
|
||||||
|
do {
|
||||||
|
guard let url else { throw CocoaError(.fileNoSuchFile) }
|
||||||
|
contents = try String(contentsOf: url, encoding: .utf8)
|
||||||
|
} catch {
|
||||||
|
contents = "Unable to load this document: \(error.localizedDescription)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
private struct AppearancePicker: View {
|
||||||
|
let onNamesChanged: () -> Void
|
||||||
|
@Environment(AppModel.self) private var model
|
||||||
|
@Environment(\.scheme) private var scheme
|
||||||
|
@State private var choice: AppearanceRecord?
|
||||||
|
@State private var swatches: [AccentChoice: Color] = [:]
|
||||||
|
|
||||||
|
private static let accents: [AccentChoice] = [.mauve, .pink, .sky, .pinkSky, .lavender, .blue, .teal, .peach, .red]
|
||||||
|
private static let flavors: [FlavorChoice] = [.proton, .latte, .frappe, .macchiato, .mocha, .persona5]
|
||||||
|
private static let names: [TitleNamesChoice] = [.library, .english, .romaji]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
if let current = choice {
|
||||||
|
Text("Palette").textStyle(.bodyMedium).foregroundStyle(scheme.onSurface).padding(.top, 8)
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ForEach(Self.flavors, id: \.self) { flavor in
|
||||||
|
Button(flavor.label) { change { $0.flavor = flavor } }
|
||||||
|
.buttonStyle(flavor == current.flavor ? ProtonButtonStyle(kind: .accent) : ProtonButtonStyle(kind: .edged))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
Text("Accent").textStyle(.bodyMedium).foregroundStyle(scheme.onSurface)
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
ForEach(Self.accents, id: \.self) { accent in
|
||||||
|
let selected = accent == current.accent
|
||||||
|
Circle()
|
||||||
|
.fill(swatches[accent] ?? scheme.surfaceVariant)
|
||||||
|
.frame(width: 36, height: 36)
|
||||||
|
.overlay(Circle().strokeBorder(selected ? scheme.onBackground : scheme.outline, lineWidth: selected ? 3 : 1))
|
||||||
|
.onTapGesture { change { $0.accent = accent } }
|
||||||
|
.accessibilityLabel(String(describing: accent))
|
||||||
|
.accessibilityAddTraits(selected ? [.isButton, .isSelected] : .isButton)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
Text("Title names").textStyle(.bodyMedium).foregroundStyle(scheme.onSurface)
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ForEach(Self.names, id: \.self) { names in
|
||||||
|
Button(names.label) { change(namesChanged: true) { $0.names = names } }
|
||||||
|
.buttonStyle(names == current.names ? ProtonButtonStyle(kind: .accent) : ProtonButtonStyle(kind: .edged))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
Text("What a matched title is called. Its other names still find it in search.")
|
||||||
|
.textStyle(.bodySmall).foregroundStyle(scheme.onSurfaceVariant).padding(.bottom, 8)
|
||||||
|
Toggle("Paint the accent as a gradient", isOn: Binding(
|
||||||
|
get: { current.gradients },
|
||||||
|
set: { on in change { $0.gradients = on } }
|
||||||
|
))
|
||||||
|
.tint(scheme.primary)
|
||||||
|
.foregroundStyle(scheme.onSurface)
|
||||||
|
.padding(.vertical, 14)
|
||||||
|
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.")
|
||||||
|
.textStyle(.bodySmall).foregroundStyle(scheme.onSurfaceVariant).padding(.bottom, 14)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
if let stored = try? await model.run({ try $0.appearance() }) {
|
||||||
|
await repaint(stored, store: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Selected at once, so a second tap builds on the first rather than on
|
||||||
|
/// what was there before it, and painted when Rust has resolved it.
|
||||||
|
private func change(namesChanged: Bool = false, _ edit: (inout AppearanceRecord) -> Void) {
|
||||||
|
guard var next = choice else { return }
|
||||||
|
edit(&next)
|
||||||
|
choice = next
|
||||||
|
let chosen = next
|
||||||
|
Task {
|
||||||
|
await repaint(chosen, store: true)
|
||||||
|
if namesChanged { onNamesChanged() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func repaint(_ next: AppearanceRecord, store: Bool) async {
|
||||||
|
let accents = Self.accents
|
||||||
|
let resolved: (PaletteRecord, [AccentChoice: UInt32])
|
||||||
|
do {
|
||||||
|
resolved = try await model.run { engine -> (PaletteRecord, [AccentChoice: UInt32]) in
|
||||||
|
if store { try engine.setAppearance(appearance: next) }
|
||||||
|
let palette = engine.previewPalette(appearance: next)
|
||||||
|
// Every accent as it would look in *this* flavour: a swatch row that
|
||||||
|
// keeps Mocha's pastels while Latte is selected lies about what the
|
||||||
|
// next tap does.
|
||||||
|
var row: [AccentChoice: UInt32] = [:]
|
||||||
|
for accent in accents {
|
||||||
|
var variant = next
|
||||||
|
variant.accent = accent
|
||||||
|
row[accent] = engine.previewPalette(appearance: variant).accent
|
||||||
|
}
|
||||||
|
return (palette, row)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
model.reportError(error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// A later tap has already moved on; its own repaint is on the way.
|
||||||
|
if store, choice != next { return }
|
||||||
|
let (palette, row) = resolved
|
||||||
|
choice = next
|
||||||
|
swatches = row.mapValues(argb)
|
||||||
|
AppearanceState.shared.apply(palette)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension TitleNamesChoice {
|
||||||
|
/// What each name setting is called. The desktop client says the same.
|
||||||
|
var label: String {
|
||||||
|
switch self {
|
||||||
|
case .library: "As in the share"
|
||||||
|
case .english: "English"
|
||||||
|
case .romaji: "Romaji"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension FlavorChoice {
|
||||||
|
/// What each palette family is called. The desktop client says the same.
|
||||||
|
var label: String {
|
||||||
|
switch self {
|
||||||
|
case .proton: "Proton"
|
||||||
|
case .latte: "Catppuccin Latte"
|
||||||
|
case .frappe: "Catppuccin Frappé"
|
||||||
|
case .macchiato: "Catppuccin Macchiato"
|
||||||
|
case .mocha: "Catppuccin Mocha"
|
||||||
|
case .persona5: "Persona 5"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
struct SharesView: View {
|
||||||
|
@Environment(AppModel.self) private var model
|
||||||
|
@Environment(\.scheme) private var scheme
|
||||||
|
/// A link opened in this app: the Add form opens with it filled in.
|
||||||
|
@Binding var incomingLink: String?
|
||||||
|
|
||||||
|
@State private var showAdd = false
|
||||||
|
@State private var signingIn = false
|
||||||
|
@State private var browsing = false
|
||||||
|
@State private var repairing: ShareRecord?
|
||||||
|
@State private var removing: ShareRecord?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
TabPage("Shares") {
|
||||||
|
ZStack(alignment: .bottomTrailing) {
|
||||||
|
if model.shares.isEmpty && model.account.state == nil {
|
||||||
|
EmptyState("No shares yet", "Add a Proton Drive public link to build your library.")
|
||||||
|
} else {
|
||||||
|
ScrollView {
|
||||||
|
LazyVStack(alignment: .leading, spacing: 0) {
|
||||||
|
AccountCard(onSignIn: { signingIn = true }, onBrowse: { browsing = true })
|
||||||
|
if model.shares.isEmpty {
|
||||||
|
Text("No shares yet. Add a public link, or a folder of your Drive once signed in.")
|
||||||
|
.textStyle(.bodyMedium)
|
||||||
|
.foregroundStyle(scheme.onSurfaceVariant)
|
||||||
|
.padding(16)
|
||||||
|
}
|
||||||
|
ForEach(model.shares, id: \.id) { share in
|
||||||
|
ShareRow(
|
||||||
|
share: share,
|
||||||
|
onRefresh: { model.refreshShare(share.id) },
|
||||||
|
onRepair: { repairing = share },
|
||||||
|
onRemove: { removing = share }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.top, 8)
|
||||||
|
.padding(.bottom, 96)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Where a thumb is, and the one thing this page is for.
|
||||||
|
Button { showAdd = true } label: {
|
||||||
|
Label("Add share", systemImage: "plus")
|
||||||
|
.textStyle(.labelLarge)
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.frame(height: 56)
|
||||||
|
.foregroundStyle(scheme.onPrimary)
|
||||||
|
.background(scheme.primary, in: RoundedRectangle(cornerRadius: 16))
|
||||||
|
.shadow(color: .black.opacity(0.3), radius: 6, y: 3)
|
||||||
|
}
|
||||||
|
.buttonStyle(.pressable)
|
||||||
|
.padding(16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: Binding(get: { showAdd || incomingLink != nil }, set: { if !$0 { showAdd = false; incomingLink = nil } })) {
|
||||||
|
AddShareSheet(initialUrl: incomingLink ?? "") { name, url, password in
|
||||||
|
await model.addShare(name: name, url: url, password: password)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: Binding(
|
||||||
|
get: { signingIn || model.account.state == .secondFactor || model.account.state == .mailboxPassword },
|
||||||
|
set: { shown in
|
||||||
|
if shown { return }
|
||||||
|
signingIn = false
|
||||||
|
// Swiped away mid sign-in: the half-made session is spent.
|
||||||
|
if model.account.state == .secondFactor || model.account.state == .mailboxPassword { model.cancelSignIn() }
|
||||||
|
}
|
||||||
|
)) {
|
||||||
|
SignInSheet { signingIn = false }
|
||||||
|
}
|
||||||
|
.fullScreenCover(isPresented: $browsing) {
|
||||||
|
DriveBrowser()
|
||||||
|
}
|
||||||
|
.sheet(item: $repairing) { share in
|
||||||
|
RepairShareSheet(share: share) { url, password in
|
||||||
|
model.repairShare(id: share.id, url: url, password: password)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Asked, because it cannot be undone: the link's secret and its
|
||||||
|
// offline files go. Watch positions stay (`Catalog::remove_share`).
|
||||||
|
.alert(
|
||||||
|
removing.map { "Remove \($0.name)?" } ?? "",
|
||||||
|
isPresented: Binding(get: { removing != nil }, set: { if !$0 { removing = nil } }),
|
||||||
|
presenting: removing
|
||||||
|
) { share in
|
||||||
|
Button("Remove", role: .destructive) { model.removeShare(share.id) }
|
||||||
|
Button("Cancel", role: .cancel) {}
|
||||||
|
} message: { _ in
|
||||||
|
Text("Its titles leave the library and its offline episodes are deleted. Watch progress is kept, so adding the link again picks up where you left off.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ShareRecord: Identifiable {}
|
||||||
|
|
||||||
|
/// One share: its name, how it is unlocked, and its actions behind a menu.
|
||||||
|
private struct ShareRow: View {
|
||||||
|
let share: ShareRecord
|
||||||
|
let onRefresh: () -> Void
|
||||||
|
let onRepair: () -> Void
|
||||||
|
let onRemove: () -> Void
|
||||||
|
@Environment(\.scheme) private var scheme
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
Image(systemName: "folder.badge.person.crop").font(.system(size: 20)).foregroundStyle(scheme.primary).frame(width: 24)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(share.name).textStyle(.bodyLarge, semibold: true).foregroundStyle(scheme.onSurface)
|
||||||
|
Text(share.fromAccount ? "Folder of your Drive"
|
||||||
|
: share.hasCustomPassword ? "Link and custom password, stored securely" : "Public link")
|
||||||
|
.textStyle(.bodyMedium).foregroundStyle(scheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
Menu {
|
||||||
|
// One share, not the library: a link that has just had files
|
||||||
|
// added should not cost a walk of every other one.
|
||||||
|
Button(action: onRefresh) { Label("Refresh", systemImage: "arrow.clockwise") }
|
||||||
|
// Not a remove: a share whose secret has become unreadable
|
||||||
|
// cannot be removed either.
|
||||||
|
if !share.fromAccount {
|
||||||
|
Button(action: onRepair) { Label("Re-enter link", systemImage: "key") }
|
||||||
|
}
|
||||||
|
Button(role: .destructive, action: onRemove) { Label("Remove", systemImage: "trash") }
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "ellipsis").font(.system(size: 20)).frame(width: 48, height: 48)
|
||||||
|
.foregroundStyle(scheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
.accessibilityLabel("Actions for \(share.name)")
|
||||||
|
}
|
||||||
|
.padding(.leading, 16)
|
||||||
|
.padding(.trailing, 4)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
/// Rust enforces that; this only says so.
|
||||||
|
private struct RepairShareSheet: View {
|
||||||
|
let share: ShareRecord
|
||||||
|
let onRepair: (String, String?) -> Void
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@State private var url = ""
|
||||||
|
@State private var password = ""
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
DialogSheet("Re-enter link for \(share.name)") {
|
||||||
|
Text("The stored credentials for this share cannot be read — usually after a device restore or a Keychain reset. Entering the same link again restores access without losing the library or anything downloaded.")
|
||||||
|
.textStyle(.bodySmall)
|
||||||
|
Field(label: "Public share URL", text: $url, secret: true)
|
||||||
|
Field(label: "Custom password (optional)", text: $password, secret: true)
|
||||||
|
} buttons: {
|
||||||
|
Button("Cancel") { dismiss() }.buttonStyle(.tonal)
|
||||||
|
Button("Restore") {
|
||||||
|
onRepair(url.trimmingCharacters(in: .whitespaces), password)
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
.buttonStyle(.accent)
|
||||||
|
.disabled(url.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||||
|
}
|
||||||
|
.secureContent()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a link. The form stays up while the link is opened, and with the
|
||||||
|
/// reason under it when the link is refused, so fixing a typo is a retry
|
||||||
|
/// rather than starting over.
|
||||||
|
private struct AddShareSheet: View {
|
||||||
|
/// Resolves to why the link was refused, or nil once it is added.
|
||||||
|
let onAdd: (String, String, String?) async -> String?
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@Environment(\.scheme) private var scheme
|
||||||
|
@State private var name = ""
|
||||||
|
@State private var url: String
|
||||||
|
@State private var password = ""
|
||||||
|
@State private var adding = false
|
||||||
|
@State private var refusal: String?
|
||||||
|
|
||||||
|
init(initialUrl: String, onAdd: @escaping (String, String, String?) async -> String?) {
|
||||||
|
self.onAdd = onAdd
|
||||||
|
_url = State(initialValue: initialUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var ready: Bool {
|
||||||
|
!name.trimmingCharacters(in: .whitespaces).isEmpty && !url.trimmingCharacters(in: .whitespaces).isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
DialogSheet("Add Proton Drive share") {
|
||||||
|
Field(label: "Library name", text: $name)
|
||||||
|
// The URL fragment is a secret, so the field is a secure one: no
|
||||||
|
// keyboard learning, no suggestions, and paste still works.
|
||||||
|
Field(label: "Public share URL", text: $url, secret: true)
|
||||||
|
// There is no share sheet into a sideloaded app, so a link copied
|
||||||
|
// elsewhere comes in from the clipboard.
|
||||||
|
if url.isEmpty {
|
||||||
|
Button {
|
||||||
|
if let text = UIPasteboard.general.string, let link = shareLink(in: text) { url = link }
|
||||||
|
} label: {
|
||||||
|
Label("Paste link", systemImage: "doc.on.clipboard")
|
||||||
|
}
|
||||||
|
.buttonStyle(.quiet)
|
||||||
|
}
|
||||||
|
Field(label: "Custom password (optional)", text: $password, secret: true)
|
||||||
|
if adding {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
ProgressView().controlSize(.small).tint(scheme.primary)
|
||||||
|
Text("Opening the link…")
|
||||||
|
}
|
||||||
|
} else if let refusal {
|
||||||
|
Text(refusal).foregroundStyle(scheme.error)
|
||||||
|
}
|
||||||
|
} buttons: {
|
||||||
|
Button("Cancel") { dismiss() }.buttonStyle(.tonal)
|
||||||
|
Button(adding ? "Adding…" : "Add") {
|
||||||
|
adding = true
|
||||||
|
refusal = nil
|
||||||
|
Task {
|
||||||
|
refusal = await onAdd(name.trimmingCharacters(in: .whitespaces), url.trimmingCharacters(in: .whitespaces), password)
|
||||||
|
adding = false
|
||||||
|
if refusal == nil { dismiss() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.accent)
|
||||||
|
.disabled(!ready || adding)
|
||||||
|
}
|
||||||
|
.interactiveDismissDisabled(adding)
|
||||||
|
.secureContent()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// The four buttons the Android client draws, at the shared corner radius.
|
||||||
|
///
|
||||||
|
/// The primary button wears the accent ramp; a disabled one keeps a flat
|
||||||
|
/// disabled fill, because a greyed control that still wears the one strong
|
||||||
|
/// colour in the window reads as pressable.
|
||||||
|
enum ButtonKind {
|
||||||
|
case accent, tonal, edged, quiet
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ProtonButtonStyle: ButtonStyle {
|
||||||
|
let kind: ButtonKind
|
||||||
|
var expand = false
|
||||||
|
@Environment(\.scheme) private var scheme
|
||||||
|
@Environment(\.isEnabled) private var enabled
|
||||||
|
|
||||||
|
func makeBody(configuration: Configuration) -> some View {
|
||||||
|
configuration.label
|
||||||
|
.textStyle(.labelLarge)
|
||||||
|
.lineLimit(1)
|
||||||
|
.padding(.horizontal, kind == .quiet ? 12 : 24)
|
||||||
|
.frame(maxWidth: expand ? .infinity : nil)
|
||||||
|
// 40pt drawn, inside the 48pt a finger is given.
|
||||||
|
.frame(minHeight: 40)
|
||||||
|
.foregroundStyle(foreground)
|
||||||
|
.background { fill }
|
||||||
|
.overlay {
|
||||||
|
if kind == .edged {
|
||||||
|
RoundedRectangle(cornerRadius: Corner.standard)
|
||||||
|
.strokeBorder(enabled ? scheme.outline : scheme.onSurface.opacity(0.12))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.opacity(configuration.isPressed ? 0.8 : 1)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var foreground: Color {
|
||||||
|
guard enabled else { return scheme.onSurface.opacity(0.38) }
|
||||||
|
switch kind {
|
||||||
|
case .accent: return scheme.onPrimary
|
||||||
|
case .tonal: return scheme.onSecondaryContainer
|
||||||
|
case .edged, .quiet: return scheme.primary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder private var fill: some View {
|
||||||
|
let shape = RoundedRectangle(cornerRadius: Corner.standard)
|
||||||
|
switch kind {
|
||||||
|
case .accent:
|
||||||
|
if enabled { shape.fill(scheme.accentGradient) } else { shape.fill(scheme.onSurface.opacity(0.12)) }
|
||||||
|
case .tonal:
|
||||||
|
shape.fill(enabled ? scheme.secondaryContainer : scheme.onSurface.opacity(0.12))
|
||||||
|
case .edged, .quiet:
|
||||||
|
Color.clear
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ButtonStyle where Self == ProtonButtonStyle {
|
||||||
|
static var accent: ProtonButtonStyle { ProtonButtonStyle(kind: .accent) }
|
||||||
|
static var accentWide: ProtonButtonStyle { ProtonButtonStyle(kind: .accent, expand: true) }
|
||||||
|
static var tonal: ProtonButtonStyle { ProtonButtonStyle(kind: .tonal) }
|
||||||
|
static var edged: ProtonButtonStyle { ProtonButtonStyle(kind: .edged) }
|
||||||
|
static var edgedWide: ProtonButtonStyle { ProtonButtonStyle(kind: .edged, expand: true) }
|
||||||
|
static var quiet: ProtonButtonStyle { ProtonButtonStyle(kind: .quiet) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A button with no chrome of its own — an icon, a row, a chip — that still
|
||||||
|
/// shows it was pressed: a little smaller, with a wash behind it.
|
||||||
|
///
|
||||||
|
/// `.plain` shows nothing at all, and on a slow device, where what the press
|
||||||
|
/// does can take a second to appear, a press that shows nothing reads as one
|
||||||
|
/// that missed — and gets pressed again.
|
||||||
|
struct PressableStyle: ButtonStyle {
|
||||||
|
var circle = false
|
||||||
|
/// The wash's colour; the scheme's text colour when nil. White over video.
|
||||||
|
var wash: Color?
|
||||||
|
@Environment(\.scheme) private var scheme
|
||||||
|
|
||||||
|
func makeBody(configuration: Configuration) -> some View {
|
||||||
|
let pressed = configuration.isPressed
|
||||||
|
let fill = (wash ?? scheme.onSurface).opacity(pressed ? 0.12 : 0)
|
||||||
|
configuration.label
|
||||||
|
.background {
|
||||||
|
if circle {
|
||||||
|
Circle().fill(fill)
|
||||||
|
} else {
|
||||||
|
RoundedRectangle(cornerRadius: Corner.standard).fill(fill)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.scaleEffect(pressed ? 0.96 : 1)
|
||||||
|
.animation(.easeOut(duration: 0.1), value: pressed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ButtonStyle where Self == PressableStyle {
|
||||||
|
static var pressable: PressableStyle { PressableStyle() }
|
||||||
|
static var pressableIcon: PressableStyle { PressableStyle(circle: true) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A tap and a long press on something that is not a button — a poster, a
|
||||||
|
/// card, an episode row — showing the press the moment the finger lands, as
|
||||||
|
/// `PressableStyle` does for a button.
|
||||||
|
private struct TapAndHold: ViewModifier {
|
||||||
|
let onTap: () -> Void
|
||||||
|
let onHold: (() -> Void)?
|
||||||
|
@State private var pressed = false
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content
|
||||||
|
.scaleEffect(pressed ? 0.96 : 1)
|
||||||
|
.opacity(pressed ? 0.8 : 1)
|
||||||
|
.animation(.easeOut(duration: 0.1), value: pressed)
|
||||||
|
.onTapGesture(perform: onTap)
|
||||||
|
.onLongPressGesture(perform: { onHold?() }, onPressingChanged: { pressed = $0 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
/// `onTapGesture`, plus `onLongPressGesture` when `onHold` is given, with
|
||||||
|
/// the press drawn.
|
||||||
|
func tapAndHold(_ onTap: @escaping () -> Void, onHold: (() -> Void)? = nil) -> some View {
|
||||||
|
modifier(TapAndHold(onTap: onTap, onHold: onHold))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How far along something is, drawn in the accent.
|
||||||
|
///
|
||||||
|
/// `solid` is for the one bar that is deliberately not the accent: a download in
|
||||||
|
/// flight sits directly under the watch-progress bar of the same row, and two
|
||||||
|
/// ramps in the same colour stacked two points apart say nothing.
|
||||||
|
struct AccentProgress: View {
|
||||||
|
let progress: Double
|
||||||
|
var solid: Color?
|
||||||
|
@Environment(\.scheme) private var scheme
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
GeometryReader { geometry in
|
||||||
|
let height = geometry.size.height
|
||||||
|
let done = geometry.size.width * min(max(progress, 0), 1)
|
||||||
|
ZStack(alignment: .leading) {
|
||||||
|
Capsule().fill(scheme.surfaceContainerHighest)
|
||||||
|
// Below one bar's width there is nothing to round.
|
||||||
|
if done >= height {
|
||||||
|
if let solid {
|
||||||
|
Capsule().fill(solid).frame(width: done)
|
||||||
|
} else {
|
||||||
|
Capsule().fill(scheme.accentGradient).frame(width: done)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The seek bar, in the accent ramp, with what is already read ahead shaded
|
||||||
|
/// under the played part: a jump that lands in it costs no fetch.
|
||||||
|
///
|
||||||
|
/// `onCommit` fires once, when the finger lifts — a seek per drag step is a
|
||||||
|
/// block fetch per drag step.
|
||||||
|
struct AccentTrack: View {
|
||||||
|
@Binding var value: Double
|
||||||
|
let range: ClosedRange<Double>
|
||||||
|
var buffered: Double = 0
|
||||||
|
var ticks: [Double] = []
|
||||||
|
var onEditing: (Bool) -> Void = { _ in }
|
||||||
|
@Environment(\.scheme) private var scheme
|
||||||
|
@State private var dragging = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
GeometryReader { geometry in
|
||||||
|
let width = geometry.size.width
|
||||||
|
let span = max(range.upperBound - range.lowerBound, 0.001)
|
||||||
|
let fraction = min(max((value - range.lowerBound) / span, 0), 1)
|
||||||
|
ZStack(alignment: .leading) {
|
||||||
|
Capsule().fill(Color.white.opacity(0.30)).frame(height: 6)
|
||||||
|
if width * buffered >= 6 {
|
||||||
|
Capsule().fill(Color.white.opacity(0.55)).frame(width: width * min(max(buffered, 0), 1), height: 6)
|
||||||
|
}
|
||||||
|
if width * fraction >= 6 {
|
||||||
|
Capsule().fill(scheme.accentGradient).frame(width: width * fraction, height: 6)
|
||||||
|
}
|
||||||
|
ForEach(ticks.indices, id: \.self) { index in
|
||||||
|
let tick = min(max(ticks[index], 0), 1)
|
||||||
|
Rectangle().fill(Color.black.opacity(0.6)).frame(width: 2, height: 6)
|
||||||
|
.offset(x: width * tick - 1)
|
||||||
|
}
|
||||||
|
Circle().fill(scheme.primary)
|
||||||
|
.frame(width: dragging ? 22 : 16, height: dragging ? 22 : 16)
|
||||||
|
.offset(x: width * fraction - (dragging ? 11 : 8))
|
||||||
|
}
|
||||||
|
.frame(maxHeight: .infinity)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.gesture(
|
||||||
|
DragGesture(minimumDistance: 0)
|
||||||
|
.onChanged { drag in
|
||||||
|
if !dragging {
|
||||||
|
dragging = true
|
||||||
|
onEditing(true)
|
||||||
|
}
|
||||||
|
let x = min(max(drag.location.x / max(width, 1), 0), 1)
|
||||||
|
value = range.lowerBound + x * span
|
||||||
|
}
|
||||||
|
.onEnded { _ in
|
||||||
|
dragging = false
|
||||||
|
onEditing(false)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.frame(height: 32)
|
||||||
|
.accessibilityElement()
|
||||||
|
.accessibilityValue(Text(clock(value)))
|
||||||
|
.accessibilityAdjustableAction { direction in
|
||||||
|
onEditing(true)
|
||||||
|
value = min(max(value + (direction == .increment ? 10 : -10), range.lowerBound), range.upperBound)
|
||||||
|
onEditing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Seconds as `m:ss` or `h:mm:ss`.
|
||||||
|
func clock(_ seconds: Double) -> String {
|
||||||
|
let total = Int(max(seconds, 0).rounded(.down))
|
||||||
|
let hours = total / 3600
|
||||||
|
let minutes = (total % 3600) / 60
|
||||||
|
let rest = total % 60
|
||||||
|
return hours > 0
|
||||||
|
? String(format: "%d:%02d:%02d", hours, minutes, rest)
|
||||||
|
: String(format: "%d:%02d", minutes, rest)
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
import Observation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// The palette in force, shared across every view.
|
||||||
|
///
|
||||||
|
/// The choice is stored by Rust and resolved by Rust — the same flavours, the
|
||||||
|
/// same accent pairings and the same contrast rule the desktop client uses — so
|
||||||
|
/// what lives here is only the resolved answer and a way to replace it when the
|
||||||
|
/// viewer picks something else.
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
final class AppearanceState {
|
||||||
|
static let shared = AppearanceState()
|
||||||
|
|
||||||
|
private(set) var palette: PaletteRecord?
|
||||||
|
|
||||||
|
/// Before the first read, and if it fails, the palette this app shipped
|
||||||
|
/// with — a theme file that cannot be read must never be a reason the
|
||||||
|
/// library does not open.
|
||||||
|
var scheme: Scheme { Scheme(palette ?? protonFallback) }
|
||||||
|
|
||||||
|
/// The palette read before the first frame, from
|
||||||
|
/// `NativeRuntime.initialPalette()`.
|
||||||
|
func seed(_ palette: PaletteRecord?) {
|
||||||
|
if let palette { self.palette = palette }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the stored choice through the engine. A no-op once `seed` has run.
|
||||||
|
func load() async {
|
||||||
|
if palette != nil { return }
|
||||||
|
if let read = try? await Task.detached(operation: { try NativeRuntime.blockingEngine().palette() }).value {
|
||||||
|
palette = read
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the stored choice again, after sync rewrote it.
|
||||||
|
func reload() async {
|
||||||
|
if let read = try? await Task.detached(operation: { try NativeRuntime.blockingEngine().palette() }).value {
|
||||||
|
palette = read
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Repaint immediately, before the write that stores the choice lands.
|
||||||
|
func apply(_ palette: PaletteRecord) {
|
||||||
|
self.palette = palette
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One resolved palette as the roles the views draw with — Material's names,
|
||||||
|
/// so a view ported from the Android client asks for the same role.
|
||||||
|
///
|
||||||
|
/// Every role is answered from the palette, and the mapping is Android's
|
||||||
|
/// `schemeOf` line for line; `SchemeRolesTests` pins it.
|
||||||
|
struct Scheme: Equatable {
|
||||||
|
let light: Bool
|
||||||
|
let primary: Color
|
||||||
|
let onPrimary: Color
|
||||||
|
let primaryContainer: Color
|
||||||
|
let onPrimaryContainer: Color
|
||||||
|
let inversePrimary: Color
|
||||||
|
let secondary: Color
|
||||||
|
let onSecondary: Color
|
||||||
|
let secondaryContainer: Color
|
||||||
|
let onSecondaryContainer: Color
|
||||||
|
let tertiary: Color
|
||||||
|
let onTertiary: Color
|
||||||
|
let tertiaryContainer: Color
|
||||||
|
let onTertiaryContainer: Color
|
||||||
|
let background: Color
|
||||||
|
let onBackground: Color
|
||||||
|
let surface: Color
|
||||||
|
let onSurface: Color
|
||||||
|
let surfaceVariant: Color
|
||||||
|
let onSurfaceVariant: Color
|
||||||
|
let inverseSurface: Color
|
||||||
|
let inverseOnSurface: Color
|
||||||
|
let error: Color
|
||||||
|
let onError: Color
|
||||||
|
let errorContainer: Color
|
||||||
|
let onErrorContainer: Color
|
||||||
|
let outline: Color
|
||||||
|
let outlineVariant: Color
|
||||||
|
let scrim: Color
|
||||||
|
let surfaceBright: Color
|
||||||
|
let surfaceDim: Color
|
||||||
|
let surfaceContainerLowest: Color
|
||||||
|
let surfaceContainerLow: Color
|
||||||
|
let surfaceContainer: Color
|
||||||
|
let surfaceContainerHigh: Color
|
||||||
|
let surfaceContainerHighest: Color
|
||||||
|
|
||||||
|
init(_ palette: PaletteRecord) {
|
||||||
|
light = palette.light
|
||||||
|
primary = argb(palette.accent)
|
||||||
|
onPrimary = argb(palette.onAccent)
|
||||||
|
primaryContainer = argb(palette.accentDim)
|
||||||
|
onPrimaryContainer = argb(palette.text)
|
||||||
|
// Readable *on* `inverseSurface`, which is the ink colour: `accentDim`
|
||||||
|
// is the accent taken towards the page, so it inverts with the rest.
|
||||||
|
inversePrimary = argb(palette.accentDim)
|
||||||
|
secondary = argb(palette.accentAlt)
|
||||||
|
onSecondary = argb(palette.onAccent)
|
||||||
|
// The accent taken back towards the page, not the card's hover shade:
|
||||||
|
// it fills every tonal button.
|
||||||
|
secondaryContainer = argb(palette.accentDim)
|
||||||
|
onSecondaryContainer = argb(palette.text)
|
||||||
|
// The palette has two hues, not three. Tertiary takes the gradient
|
||||||
|
// partner rather than inventing a third that no flavour chose.
|
||||||
|
tertiary = argb(palette.accentAlt)
|
||||||
|
onTertiary = argb(palette.onAccent)
|
||||||
|
tertiaryContainer = argb(palette.accentDim)
|
||||||
|
onTertiaryContainer = argb(palette.text)
|
||||||
|
background = argb(palette.background)
|
||||||
|
onBackground = argb(palette.text)
|
||||||
|
surface = argb(palette.surface)
|
||||||
|
onSurface = argb(palette.text)
|
||||||
|
surfaceVariant = argb(palette.card)
|
||||||
|
onSurfaceVariant = argb(palette.muted)
|
||||||
|
// Genuinely inverted, both ways round: on a dark flavour this is a pale
|
||||||
|
// snackbar with dark ink, on Latte a dark one with pale ink.
|
||||||
|
inverseSurface = argb(palette.text)
|
||||||
|
inverseOnSurface = argb(palette.background)
|
||||||
|
error = argb(palette.danger)
|
||||||
|
onError = argb(palette.background)
|
||||||
|
errorContainer = argb(palette.dangerDim)
|
||||||
|
onErrorContainer = argb(palette.text)
|
||||||
|
// `outline` is a border meant to be seen, `outlineVariant` a divider
|
||||||
|
// meant to be barely there.
|
||||||
|
outline = argb(palette.muted)
|
||||||
|
outlineVariant = argb(palette.border)
|
||||||
|
scrim = .black
|
||||||
|
surfaceBright = argb(palette.elevated)
|
||||||
|
surfaceDim = argb(palette.background)
|
||||||
|
// The containment ladder, in the order the palette already runs.
|
||||||
|
surfaceContainerLowest = argb(palette.sunken)
|
||||||
|
surfaceContainerLow = argb(palette.surface)
|
||||||
|
surfaceContainer = argb(palette.card)
|
||||||
|
surfaceContainerHigh = argb(palette.cardHover)
|
||||||
|
surfaceContainerHighest = argb(palette.elevated)
|
||||||
|
}
|
||||||
|
|
||||||
|
var colorScheme: ColorScheme { light ? .light : .dark }
|
||||||
|
|
||||||
|
/// The accent, as a gradient when there is one to draw. With gradients
|
||||||
|
/// off, `Palette::resolve` sets `accent_alt` equal to `accent`, so this
|
||||||
|
/// collapses to a flat fill on its own.
|
||||||
|
var accentGradient: LinearGradient {
|
||||||
|
LinearGradient(colors: [primary, secondary], startPoint: .leading, endPoint: .trailing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A packed `0xAARRGGBB` from the bridge, as SwiftUI wants it.
|
||||||
|
func argb(_ value: UInt32) -> Color {
|
||||||
|
Color(
|
||||||
|
.sRGB,
|
||||||
|
red: Double((value >> 16) & 0xFF) / 255,
|
||||||
|
green: Double((value >> 8) & 0xFF) / 255,
|
||||||
|
blue: Double(value & 0xFF) / 255,
|
||||||
|
opacity: Double((value >> 24) & 0xFF) / 255
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The shipped default, spelled out for the case where Rust cannot be asked.
|
||||||
|
/// The values are `pstr_core::appearance::Palette::PROTON`.
|
||||||
|
let protonFallback = PaletteRecord(
|
||||||
|
background: 0xFF0E_0E12,
|
||||||
|
surface: 0xFF17_171D,
|
||||||
|
sunken: 0xFF0A_0A0D,
|
||||||
|
card: 0xFF1E_1E26,
|
||||||
|
cardHover: 0xFF2A_2A35,
|
||||||
|
border: 0xFF26_2630,
|
||||||
|
text: 0xFFEA_EAF0,
|
||||||
|
muted: 0xFF8E_8E9C,
|
||||||
|
accent: 0xFF7D_4DFF,
|
||||||
|
accentAlt: 0xFF3F_6FE0,
|
||||||
|
accentDim: 0xFF53_33AD,
|
||||||
|
onAccent: 0xFFEA_EAF0,
|
||||||
|
danger: 0xFFE0_5561,
|
||||||
|
elevated: 0xFF33_3340,
|
||||||
|
dangerDim: 0xFF7F_2F37,
|
||||||
|
light: false
|
||||||
|
)
|
||||||
|
|
||||||
|
private struct SchemeKey: EnvironmentKey {
|
||||||
|
static let defaultValue = Scheme(protonFallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
extension EnvironmentValues {
|
||||||
|
var scheme: Scheme {
|
||||||
|
get { self[SchemeKey.self] }
|
||||||
|
set { self[SchemeKey.self] = newValue }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One corner radius, which is the desktop client's corner radius: 8 for
|
||||||
|
/// everything, 4 for a badge over artwork, 16 for a sheet.
|
||||||
|
enum Corner {
|
||||||
|
static let standard: CGFloat = 8
|
||||||
|
static let tight: CGFloat = 4
|
||||||
|
static let sheet: CGFloat = 16
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// The type ramp, which is the desktop client's type ramp, under Material's
|
||||||
|
/// names so a view ported from the Android client asks for the same style.
|
||||||
|
///
|
||||||
|
/// Inter, Regular and SemiBold only, as on the desktop and on Android: a
|
||||||
|
/// `Medium` role resolves to Regular. The rungs, largest first:
|
||||||
|
/// 26 / 20 / 18 / 17 / 15 / 14 / 13 / 12 / 11. `TypeRampTests` pins them.
|
||||||
|
enum TypeRole: CaseIterable {
|
||||||
|
case displayLarge, displayMedium, displaySmall
|
||||||
|
case headlineLarge, headlineMedium, headlineSmall
|
||||||
|
case titleLarge, titleMedium, titleSmall
|
||||||
|
case bodyLarge, bodyMedium, bodySmall
|
||||||
|
case labelLarge, labelMedium, labelSmall
|
||||||
|
|
||||||
|
var size: CGFloat {
|
||||||
|
switch self {
|
||||||
|
case .displayLarge, .displayMedium, .headlineLarge, .headlineMedium: Ramp.display
|
||||||
|
case .displaySmall, .titleLarge: Ramp.title
|
||||||
|
case .headlineSmall: Ramp.heading
|
||||||
|
case .titleMedium: Ramp.section
|
||||||
|
case .titleSmall: Ramp.subhead
|
||||||
|
case .bodyLarge: Ramp.body
|
||||||
|
case .bodyMedium, .labelLarge: Ramp.label
|
||||||
|
case .bodySmall, .labelMedium: Ramp.caption
|
||||||
|
case .labelSmall: Ramp.micro
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var semibold: Bool {
|
||||||
|
switch self {
|
||||||
|
case .displayLarge, .displayMedium, .displaySmall, .headlineLarge, .headlineMedium, .headlineSmall, .titleLarge:
|
||||||
|
true
|
||||||
|
default:
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Dynamic Type style each rung scales with.
|
||||||
|
var relative: Font.TextStyle {
|
||||||
|
switch size {
|
||||||
|
case Ramp.display: .title
|
||||||
|
case Ramp.title: .title2
|
||||||
|
case Ramp.heading: .title3
|
||||||
|
case Ramp.section: .headline
|
||||||
|
case Ramp.subhead: .subheadline
|
||||||
|
case Ramp.body: .body
|
||||||
|
case Ramp.label: .callout
|
||||||
|
case Ramp.caption: .footnote
|
||||||
|
default: .caption
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Line height is derived rather than named per style: 1.35 of the size,
|
||||||
|
/// as on Android. Inter's own leading is about 1.21, so the rest is spacing.
|
||||||
|
var lineSpacing: CGFloat { size * (Ramp.lineHeight - 1.21) }
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Ramp {
|
||||||
|
static let display: CGFloat = 26
|
||||||
|
static let title: CGFloat = 20
|
||||||
|
static let heading: CGFloat = 18
|
||||||
|
static let section: CGFloat = 17
|
||||||
|
static let subhead: CGFloat = 15
|
||||||
|
static let body: CGFloat = 14
|
||||||
|
static let label: CGFloat = 13
|
||||||
|
static let caption: CGFloat = 12
|
||||||
|
static let micro: CGFloat = 11
|
||||||
|
static let lineHeight: CGFloat = 1.35
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Font {
|
||||||
|
static func inter(_ style: TypeRole, semibold: Bool? = nil) -> Font {
|
||||||
|
.custom((semibold ?? style.semibold) ? "Inter-SemiBold" : "Inter-Regular", size: style.size, relativeTo: style.relative)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func inter(size: CGFloat, semibold: Bool = false) -> Font {
|
||||||
|
.custom(semibold ? "Inter-SemiBold" : "Inter-Regular", size: size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
/// One rung of the ramp. `semibold` overrides the style's own weight, the
|
||||||
|
/// counterpart of Compose's `fontWeight = FontWeight.SemiBold`.
|
||||||
|
func textStyle(_ style: TypeRole, semibold: Bool? = nil) -> some View {
|
||||||
|
font(.inter(style, semibold: semibold)).lineSpacing(style.lineSpacing)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,569 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// How wide the page's text and controls run on a window wider than a phone.
|
||||||
|
private let readableWidth: CGFloat = 720
|
||||||
|
|
||||||
|
struct TitleView: View {
|
||||||
|
let title: TitleRecord
|
||||||
|
let onPlay: ([EpisodeRecord], Int) -> Void
|
||||||
|
let onBack: () -> Void
|
||||||
|
/// Every title of its franchise, in release order, this one included.
|
||||||
|
var franchise: [TitleRecord] = []
|
||||||
|
var onOpenTitle: (TitleRecord) -> Void = { _ in }
|
||||||
|
|
||||||
|
@Environment(AppModel.self) private var model
|
||||||
|
@Environment(\.scheme) private var scheme
|
||||||
|
@Environment(\.openURL) private var openURL
|
||||||
|
@State private var showMatch = false
|
||||||
|
@State private var overviewOpen = false
|
||||||
|
@State private var seasonIndex: Int
|
||||||
|
// Back by an edge swipe: the page shrinks towards the library as the
|
||||||
|
// gesture is dragged, and springs back if it is let go of — Android's
|
||||||
|
// predictive back, so the viewer sees where back goes before committing.
|
||||||
|
@State private var backProgress: CGFloat = 0
|
||||||
|
|
||||||
|
init(
|
||||||
|
title: TitleRecord,
|
||||||
|
onPlay: @escaping ([EpisodeRecord], Int) -> Void,
|
||||||
|
onBack: @escaping () -> Void,
|
||||||
|
franchise: [TitleRecord] = [],
|
||||||
|
onOpenTitle: @escaping (TitleRecord) -> Void = { _ in }
|
||||||
|
) {
|
||||||
|
self.title = title
|
||||||
|
self.onPlay = onPlay
|
||||||
|
self.onBack = onBack
|
||||||
|
self.franchise = franchise
|
||||||
|
self.onOpenTitle = onOpenTitle
|
||||||
|
// One season on screen at a time, opened on the one Play would land in.
|
||||||
|
let playlist = title.playlist
|
||||||
|
let nextUp = nextUpIndex(playlist)
|
||||||
|
let upNext = playlist.indices.contains(nextUp) ? playlist[nextUp] : nil
|
||||||
|
_seasonIndex = State(initialValue: max(title.seasons.firstIndex { $0.episodes.contains { $0.linkId == upNext?.linkId } } ?? 0, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
// Display order across seasons: what previous/next and autoplay walk.
|
||||||
|
let playlist = title.playlist
|
||||||
|
let nextUp = nextUpIndex(playlist)
|
||||||
|
let upNext = playlist.indices.contains(nextUp) ? playlist[nextUp] : nil
|
||||||
|
let season = title.seasons.indices.contains(seasonIndex) ? title.seasons[seasonIndex] : nil
|
||||||
|
let downloads = DownloadCoordinator.shared.records
|
||||||
|
|
||||||
|
GeometryReader { geometry in
|
||||||
|
// On a tablet the page keeps a phone's reading width, centred. The
|
||||||
|
// backdrop still spans the window, but no taller than about half.
|
||||||
|
let side = max((geometry.size.width - readableWidth) / 2, 0)
|
||||||
|
let backdropHeight = min(geometry.size.width * 9 / 16, geometry.size.height * 0.55)
|
||||||
|
ScrollView {
|
||||||
|
LazyVStack(alignment: .leading, spacing: 8) {
|
||||||
|
backdrop(height: backdropHeight + geometry.safeAreaInsets.top, top: geometry.safeAreaInsets.top)
|
||||||
|
heading(playlist: playlist, nextUp: nextUp, upNext: upNext)
|
||||||
|
.padding(.horizontal, side + 16)
|
||||||
|
if franchise.count > 1 {
|
||||||
|
franchiseRow(side: side)
|
||||||
|
}
|
||||||
|
seasonChips(season: season, side: side)
|
||||||
|
if let season {
|
||||||
|
ForEach(Array(season.episodes.enumerated()), id: \.element.linkId) { position, episode in
|
||||||
|
EpisodeRow(
|
||||||
|
episode: episode,
|
||||||
|
numbering: episode.numbering(season.number, position),
|
||||||
|
position: position,
|
||||||
|
download: downloads.first { $0.shareId == episode.shareId && $0.linkId == episode.linkId },
|
||||||
|
onPlay: { onPlay(playlist, playlist.firstIndex { $0.linkId == episode.linkId } ?? 0) },
|
||||||
|
onSetWatched: { model.setWatched(episode, $0) }
|
||||||
|
)
|
||||||
|
.padding(.horizontal, side)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.bottom, 24)
|
||||||
|
}
|
||||||
|
.ignoresSafeArea(edges: .top)
|
||||||
|
.background(scheme.background)
|
||||||
|
}
|
||||||
|
.scaleEffect(1 - 0.1 * backProgress)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 32 * backProgress))
|
||||||
|
.overlay(alignment: .leading) {
|
||||||
|
Color.clear
|
||||||
|
.frame(width: 20)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.gesture(
|
||||||
|
DragGesture(minimumDistance: 10)
|
||||||
|
.onChanged { drag in backProgress = min(max(drag.translation.width / 300, 0), 1) }
|
||||||
|
.onEnded { drag in
|
||||||
|
if drag.translation.width > 100 || drag.predictedEndTranslation.width > 250 {
|
||||||
|
onBack()
|
||||||
|
}
|
||||||
|
withAnimation(.spring) { backProgress = 0 }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showMatch) {
|
||||||
|
ChangeMatchSheet(title: title) { showMatch = false }
|
||||||
|
}
|
||||||
|
.onChange(of: title.key) {
|
||||||
|
overviewOpen = false
|
||||||
|
let playlist = title.playlist
|
||||||
|
let upNext = playlist.indices.contains(nextUpIndex(playlist)) ? playlist[nextUpIndex(playlist)] : nil
|
||||||
|
seasonIndex = title.seasons.firstIndex { $0.episodes.contains { $0.linkId == upNext?.linkId } } ?? 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Edge to edge, fading into the page: the art is the first thing the page
|
||||||
|
/// says.
|
||||||
|
private func backdrop(height: CGFloat, top: CGFloat) -> some View {
|
||||||
|
RemoteArtwork(title.backdropUrl ?? title.posterUrl, title.displayName, fallback: title.thumbnailSource, labelled: false)
|
||||||
|
.frame(height: height)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.clipped()
|
||||||
|
.overlay {
|
||||||
|
LinearGradient(
|
||||||
|
stops: [.init(color: .clear, location: 0.45), .init(color: scheme.background, location: 1)],
|
||||||
|
startPoint: .top,
|
||||||
|
endPoint: .bottom
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.overlay(alignment: .topLeading) {
|
||||||
|
Button(action: onBack) {
|
||||||
|
Image(systemName: "chevron.backward")
|
||||||
|
.font(.system(size: 18, weight: .semibold))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.frame(width: 40, height: 40)
|
||||||
|
.background(.black.opacity(0.4), in: Circle())
|
||||||
|
.frame(width: 48, height: 48)
|
||||||
|
}
|
||||||
|
.accessibilityLabel("Back to library")
|
||||||
|
.padding(8)
|
||||||
|
.padding(.top, top)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder private func heading(playlist: [EpisodeRecord], nextUp: Int, upNext: EpisodeRecord?) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
Text(title.displayName).textStyle(.headlineMedium).foregroundStyle(scheme.onSurface)
|
||||||
|
// The other names it goes by: the share's when the shown one is the
|
||||||
|
// provider's, and the original-language one.
|
||||||
|
let names: [String?] = [
|
||||||
|
title.name != title.displayName ? title.name : nil,
|
||||||
|
title.originalName.flatMap { $0 != title.displayName && $0 != title.canonicalName ? $0 : nil },
|
||||||
|
]
|
||||||
|
let aliases = names.compactMap { $0 }.joined(separator: " · ")
|
||||||
|
if !aliases.isEmpty {
|
||||||
|
Text(aliases).textStyle(.bodyMedium).foregroundStyle(scheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
Text(factLine)
|
||||||
|
.textStyle(.bodySmall).foregroundStyle(scheme.onSurfaceVariant).padding(.top, 6)
|
||||||
|
// Who made it, and whether more is coming.
|
||||||
|
let makers: [String?] = [
|
||||||
|
title.studios.isEmpty ? nil : title.studios.prefix(2).joined(separator: ", "),
|
||||||
|
title.directors.first.map { "dir. \($0)" },
|
||||||
|
airingNote(title),
|
||||||
|
]
|
||||||
|
let facts = makers.compactMap { $0 }.joined(separator: " · ")
|
||||||
|
if !facts.isEmpty {
|
||||||
|
Text(facts).textStyle(.bodySmall).foregroundStyle(scheme.onSurfaceVariant).padding(.top, 2)
|
||||||
|
}
|
||||||
|
// The one thing the page is for, full width and naming what it will
|
||||||
|
// play: "Resume" alone left the viewer guessing which episode.
|
||||||
|
Button { onPlay(playlist, nextUp) } label: {
|
||||||
|
Label(playLabel(playlist: playlist, upNext: upNext), systemImage: "play.fill")
|
||||||
|
}
|
||||||
|
.buttonStyle(.accentWide)
|
||||||
|
.disabled(playlist.isEmpty)
|
||||||
|
.padding(.top, 16)
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
// Only where it would do something different: an unstarted show
|
||||||
|
// already starts at the beginning.
|
||||||
|
if upNext?.resumeAt != nil || title.watchedCount > 0 {
|
||||||
|
TitleAction(icon: "arrow.counterclockwise", label: "Start over") { onPlay(playlist, 0) }
|
||||||
|
}
|
||||||
|
TitleAction(icon: "arrow.down.circle", label: "Download") { DownloadCoordinator.shared.enqueue(playlist) }
|
||||||
|
TitleAction(icon: "pencil", label: "Match") { 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.
|
||||||
|
if let link = title.externalUrl.flatMap(URL.init(string:)) {
|
||||||
|
TitleAction(icon: "arrow.up.right.square", label: title.metadataProvider?.displayName ?? "Provider") {
|
||||||
|
openURL(link)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.top, 12)
|
||||||
|
if let overview = title.overview {
|
||||||
|
// Three lines, then a tap for the rest.
|
||||||
|
Text(overview.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||||
|
.textStyle(.bodyLarge)
|
||||||
|
.foregroundStyle(scheme.onSurface)
|
||||||
|
.lineLimit(overviewOpen ? nil : 3)
|
||||||
|
.padding(.top, 12)
|
||||||
|
.onTapGesture { overviewOpen.toggle() }
|
||||||
|
}
|
||||||
|
if !title.genres.isEmpty || !title.tags.isEmpty {
|
||||||
|
Text((title.genres + title.tags.prefix(4)).joined(separator: " · "))
|
||||||
|
.textStyle(.labelMedium).foregroundStyle(scheme.onSurfaceVariant).padding(.top, 10)
|
||||||
|
}
|
||||||
|
// Said plainly, because it changes what a re-match will do.
|
||||||
|
if title.manualMatch {
|
||||||
|
Text("Matched by hand").textStyle(.labelMedium).foregroundStyle(scheme.onSurfaceVariant).padding(.top, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var factLine: String {
|
||||||
|
let year: String? = (title.metadataYear ?? title.year).map { String($0) }
|
||||||
|
let rating: String? = title.rating.map { String(format: "★ %.1f", $0) }
|
||||||
|
let watched: String? = title.episodeCount > 1 ? "\(title.watchedCount) of \(title.episodeCount) watched" : nil
|
||||||
|
let parts: [String?] = [title.formatLabel, title.seasonLabel ?? year, rating, watched]
|
||||||
|
return parts.compactMap { $0 }.joined(separator: " · ")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func playLabel(playlist: [EpisodeRecord], upNext: EpisodeRecord?) -> String {
|
||||||
|
guard let upNext else { return "Play" }
|
||||||
|
if upNext.resumeAt != nil { return "Resume \(upNext.label)" }
|
||||||
|
if title.watchedCount > 0 { return "Continue with \(upNext.label)" }
|
||||||
|
if playlist.count == 1 { return "Play" }
|
||||||
|
return "Play \(upNext.label)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func franchiseRow(side: CGFloat) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
Text("In this franchise").textStyle(.titleMedium).foregroundStyle(scheme.onSurface)
|
||||||
|
.padding(.horizontal, side + 16)
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
LazyHStack(alignment: .top, spacing: 12) {
|
||||||
|
ForEach(franchise, id: \.key) { member in
|
||||||
|
FranchiseTile(title: member, here: member.key == title.key)
|
||||||
|
.tapAndHold { if member.key != title.key { onOpenTitle(member) } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, side + 16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.top, 12)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func seasonChips(season: SeasonRecord?, side: CGFloat) -> some View {
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ForEach(Array(title.seasons.enumerated()), id: \.element.label) { index, entry in
|
||||||
|
FilterChip(label: entry.label, selected: index == seasonIndex) { seasonIndex = index }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let season {
|
||||||
|
IconButton("arrow.down.circle", "Download \(season.label)") {
|
||||||
|
DownloadCoordinator.shared.enqueue(season.episodes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.leading, side + 16)
|
||||||
|
.padding(.trailing, side + 4)
|
||||||
|
.padding(.top, 12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Material's filter chip: outlined, or filled with a tick when selected.
|
||||||
|
struct FilterChip: View {
|
||||||
|
let label: String
|
||||||
|
let selected: Bool
|
||||||
|
let action: () -> Void
|
||||||
|
@Environment(\.scheme) private var scheme
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: action) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
if selected { Image(systemName: "checkmark").font(.system(size: 13, weight: .semibold)) }
|
||||||
|
Text(label).textStyle(.labelLarge)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.frame(height: 32)
|
||||||
|
.foregroundStyle(selected ? scheme.onSecondaryContainer : scheme.onSurfaceVariant)
|
||||||
|
.background(selected ? scheme.secondaryContainer : .clear, in: RoundedRectangle(cornerRadius: Corner.standard))
|
||||||
|
.overlay {
|
||||||
|
if !selected { RoundedRectangle(cornerRadius: Corner.standard).strokeBorder(scheme.outlineVariant) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.pressable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One part of the franchise: its cover, what it is and when. This title's own
|
||||||
|
/// is marked rather than left out, so the row says where it sits in the story.
|
||||||
|
private struct FranchiseTile: View {
|
||||||
|
let title: TitleRecord
|
||||||
|
let here: Bool
|
||||||
|
@Environment(\.scheme) private var scheme
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
RemoteArtwork(title.posterUrl ?? title.backdropUrl, title.displayName, fallback: title.thumbnailSource)
|
||||||
|
.aspectRatio(2 / 3, contentMode: .fit)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: Corner.standard))
|
||||||
|
.overlay {
|
||||||
|
if here { RoundedRectangle(cornerRadius: Corner.standard).strokeBorder(scheme.primary, lineWidth: 2) }
|
||||||
|
}
|
||||||
|
Text(title.displayName).textStyle(.labelMedium, semibold: here).foregroundStyle(scheme.onSurface)
|
||||||
|
.lineLimit(2).padding(.top, 6)
|
||||||
|
Text([title.formatLabel ?? (title.kind == .film ? "Film" : "Series"), (title.metadataYear ?? title.year).map(String.init)]
|
||||||
|
.compactMap { $0 }.joined(separator: " · "))
|
||||||
|
.textStyle(.labelSmall).foregroundStyle(scheme.onSurfaceVariant).lineLimit(1)
|
||||||
|
}
|
||||||
|
.frame(width: 96)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "episode 7 on Sat 11 Oct" while one is due, "airing" while it is airing
|
||||||
|
/// with no date yet, else nothing.
|
||||||
|
private func airingNote(_ title: TitleRecord) -> String? {
|
||||||
|
if let at = title.nextAiringAt, let episode = title.nextEpisode {
|
||||||
|
let format = DateFormatter()
|
||||||
|
format.setLocalizedDateFormatFromTemplate("EEE d MMM")
|
||||||
|
return "episode \(episode) on " + format.string(from: Date(timeIntervalSince1970: TimeInterval(at)))
|
||||||
|
}
|
||||||
|
return title.airing ? "airing" : nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One of the title's secondary actions: an icon with its name under it.
|
||||||
|
private struct TitleAction: View {
|
||||||
|
let icon: String
|
||||||
|
let label: String
|
||||||
|
let action: () -> Void
|
||||||
|
@Environment(\.scheme) private var scheme
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: action) {
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
Image(systemName: icon).font(.system(size: 22))
|
||||||
|
Text(label).textStyle(.labelMedium).lineLimit(1)
|
||||||
|
}
|
||||||
|
.foregroundStyle(scheme.onSurface)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.pressable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension MetadataProvider {
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .aniList: "AniList"
|
||||||
|
case .tmdb: "TMDB"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension EpisodeRecord {
|
||||||
|
/// `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 — `#4` says "fourth in the list"
|
||||||
|
/// where `E04` would be a claim about the show.
|
||||||
|
func numbering(_ fallbackSeason: UInt32?, _ position: Int) -> String {
|
||||||
|
switch (season ?? fallbackSeason, number) {
|
||||||
|
case let (season?, number?): String(format: "S%02dE%02d", season, number)
|
||||||
|
case let (nil, number?): String(format: "E%02d", number)
|
||||||
|
default: "#\(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.
|
||||||
|
private struct EpisodeRow: View {
|
||||||
|
let episode: EpisodeRecord
|
||||||
|
let numbering: String
|
||||||
|
let position: Int
|
||||||
|
let download: RetainedDownload?
|
||||||
|
let onPlay: () -> Void
|
||||||
|
let onSetWatched: (Bool) -> Void
|
||||||
|
@Environment(\.scheme) private var scheme
|
||||||
|
|
||||||
|
private var subline: String {
|
||||||
|
let size: String? = episode.size.map { formatBytes($0) }
|
||||||
|
let parts: [String?] = [numbering, size, episode.airDate]
|
||||||
|
return parts.compactMap { $0 }.joined(separator: " · ")
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let running = download?.status == RetainedDownload.statusRunning || download?.status == RetainedDownload.statusQueued
|
||||||
|
let heading = episode.heading(position)
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
RemoteArtwork(episode.stillUrl, heading, fallback: episode.thumbnailSource, labelled: false)
|
||||||
|
.frame(width: 128)
|
||||||
|
.aspectRatio(16 / 9, contentMode: .fit)
|
||||||
|
.overlay(alignment: .bottom) {
|
||||||
|
if let progress = episode.progress, !episode.watched { AccentProgress(progress: progress) }
|
||||||
|
}
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: Corner.standard))
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
// Seen episodes stay legible but stop competing with the
|
||||||
|
// one the viewer has not watched yet.
|
||||||
|
Text(heading).textStyle(.titleSmall)
|
||||||
|
.foregroundStyle(episode.watched ? scheme.onSurfaceVariant : scheme.onSurface)
|
||||||
|
.lineLimit(2)
|
||||||
|
Text(subline)
|
||||||
|
.textStyle(.bodySmall).foregroundStyle(scheme.onSurfaceVariant).lineLimit(1)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
// Watched is a judgement the viewer is allowed to overrule.
|
||||||
|
IconButton(
|
||||||
|
episode.watched ? "checkmark.circle.fill" : "checkmark.circle",
|
||||||
|
episode.watched ? "Mark unwatched" : "Mark watched",
|
||||||
|
tint: episode.watched ? scheme.primary : scheme.outline
|
||||||
|
) { onSetWatched(!episode.watched) }
|
||||||
|
// One slot, always the same width, whatever state the download
|
||||||
|
// is in.
|
||||||
|
Group {
|
||||||
|
if episode.offline {
|
||||||
|
Image(systemName: "checkmark.icloud")
|
||||||
|
.font(.system(size: 20))
|
||||||
|
.foregroundStyle(scheme.tertiary)
|
||||||
|
.accessibilityLabel("Saved offline")
|
||||||
|
} else if running, let download {
|
||||||
|
IconButton("pause.fill", "Pause download") { DownloadCoordinator.shared.pause(download) }
|
||||||
|
} else if let download {
|
||||||
|
IconButton("arrow.down.circle", "Resume download") { DownloadCoordinator.shared.resume(download) }
|
||||||
|
} else {
|
||||||
|
IconButton("arrow.down.circle", "Download") { DownloadCoordinator.shared.enqueue(episode) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: 48, height: 48)
|
||||||
|
}
|
||||||
|
if let overview = episode.providerOverview {
|
||||||
|
Text(overview).textStyle(.bodySmall).foregroundStyle(scheme.onSurfaceVariant).lineLimit(2).padding(.top, 6)
|
||||||
|
}
|
||||||
|
if let active = download, active.total > 0, !episode.offline {
|
||||||
|
AccentProgress(progress: Double(active.downloaded) / Double(active.total), solid: scheme.tertiary)
|
||||||
|
.padding(.top, 8)
|
||||||
|
Text(downloadStatusLine(active)).textStyle(.bodySmall).foregroundStyle(scheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.tapAndHold(onPlay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Search the configured provider for the right entry, choose it, or forget
|
||||||
|
/// the match altogether.
|
||||||
|
struct ChangeMatchSheet: View {
|
||||||
|
let title: TitleRecord
|
||||||
|
let onDone: () -> Void
|
||||||
|
@Environment(AppModel.self) private var model
|
||||||
|
@Environment(\.scheme) private var scheme
|
||||||
|
@State private var term: String
|
||||||
|
@State private var searching = false
|
||||||
|
// Choosing fetches the entry's episodes too, which is a few requests.
|
||||||
|
@State private var choosing = false
|
||||||
|
@State private var options: [MatchRecord] = []
|
||||||
|
/// Said here rather than in the snackbar, which this sheet covers.
|
||||||
|
@State private var failure: String?
|
||||||
|
@State private var searched = false
|
||||||
|
@State private var forgetting = false
|
||||||
|
|
||||||
|
init(title: TitleRecord, onDone: @escaping () -> Void) {
|
||||||
|
self.title = title
|
||||||
|
self.onDone = onDone
|
||||||
|
_term = State(initialValue: title.canonicalName ?? title.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
DialogSheet("Change match") {
|
||||||
|
Text("Search the configured metadata provider. Nothing is stored until you choose an entry.")
|
||||||
|
Field(label: "Title", text: $term, submit: .search)
|
||||||
|
.onSubmit(search)
|
||||||
|
Button(searching ? "Searching…" : "Search", action: search)
|
||||||
|
.buttonStyle(.accent)
|
||||||
|
.disabled(term.trimmingCharacters(in: .whitespaces).isEmpty || searching)
|
||||||
|
if choosing {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
ProgressView().controlSize(.small).tint(scheme.primary)
|
||||||
|
Text("Fetching its episodes…").textStyle(.bodySmall)
|
||||||
|
}
|
||||||
|
.padding(.top, 8)
|
||||||
|
}
|
||||||
|
if let failure {
|
||||||
|
Text(failure).foregroundStyle(scheme.error)
|
||||||
|
}
|
||||||
|
if !searching, options.isEmpty, searched {
|
||||||
|
Text("Nothing found. Try the title's original or English name.").textStyle(.bodySmall)
|
||||||
|
}
|
||||||
|
ForEach(options, id: \.remoteId) { option in
|
||||||
|
Button { choose(option) } label: {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(option.name).textStyle(.labelLarge, semibold: true)
|
||||||
|
Text([option.year.map(String.init), option.originalName].compactMap { $0 }.joined(separator: " · "))
|
||||||
|
.textStyle(.labelLarge)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
.buttonStyle(.edgedWide)
|
||||||
|
.disabled(choosing)
|
||||||
|
}
|
||||||
|
} buttons: {
|
||||||
|
Button(forgetting ? "Forgetting…" : "Forget match") {
|
||||||
|
forgetting = true
|
||||||
|
failure = nil
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
try await model.run { try $0.forgetMatch(titleKey: title.key) }
|
||||||
|
changed()
|
||||||
|
} catch {
|
||||||
|
failure = errorMessage(error)
|
||||||
|
}
|
||||||
|
forgetting = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.quiet)
|
||||||
|
.disabled(forgetting || choosing)
|
||||||
|
Button("Close", action: onDone).buttonStyle(.tonal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func search() {
|
||||||
|
let term = term
|
||||||
|
guard !term.trimmingCharacters(in: .whitespaces).isEmpty, !searching else { return }
|
||||||
|
searching = true
|
||||||
|
failure = nil
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
options = try await NativeRuntime.engine().searchMatches(titleKey: title.key, term: term)
|
||||||
|
} catch {
|
||||||
|
failure = errorMessage(error)
|
||||||
|
}
|
||||||
|
searching = false
|
||||||
|
searched = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func choose(_ option: MatchRecord) {
|
||||||
|
choosing = true
|
||||||
|
failure = nil
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
try await NativeRuntime.engine().chooseMatch(titleKey: title.key, found: option)
|
||||||
|
changed()
|
||||||
|
} catch {
|
||||||
|
failure = errorMessage(error)
|
||||||
|
}
|
||||||
|
choosing = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func changed() {
|
||||||
|
onDone()
|
||||||
|
model.reloadAfterMetadataChange()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
@testable import ProtonStream
|
||||||
|
import XCTest
|
||||||
|
|
||||||
|
final class DownloadStateStoreTests: XCTestCase {
|
||||||
|
private var file: URL!
|
||||||
|
private var store: DownloadStateStore!
|
||||||
|
|
||||||
|
override func setUp() {
|
||||||
|
file = FileManager.default.temporaryDirectory.appendingPathComponent("downloads-\(UUID().uuidString).json")
|
||||||
|
store = DownloadStateStore(file: file)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tearDown() {
|
||||||
|
try? FileManager.default.removeItem(at: file)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func record(
|
||||||
|
shareId: String = "share-1",
|
||||||
|
linkId: String = "link-1",
|
||||||
|
label: String = "S01E01.mkv",
|
||||||
|
status: String = RetainedDownload.statusRunning,
|
||||||
|
error: String? = nil
|
||||||
|
) -> RetainedDownload {
|
||||||
|
RetainedDownload(
|
||||||
|
shareId: shareId,
|
||||||
|
volumeId: "volume-1",
|
||||||
|
linkId: linkId,
|
||||||
|
label: label,
|
||||||
|
downloaded: 4 * 1024 * 1024,
|
||||||
|
total: 761 * 1024 * 1024,
|
||||||
|
status: status,
|
||||||
|
error: error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func baseJson() -> [String: Any] {
|
||||||
|
[
|
||||||
|
"share": "share-1",
|
||||||
|
"volume": "volume-1",
|
||||||
|
"link": "link-1",
|
||||||
|
"label": "S01E01.mkv",
|
||||||
|
"downloaded": 0,
|
||||||
|
"total": 0,
|
||||||
|
"status": RetainedDownload.statusQueued,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A row as an older or another writer left it, read by a fresh store.
|
||||||
|
private func writeRaw(_ rows: [String: String]) throws {
|
||||||
|
try JSONEncoder().encode(rows).write(to: file)
|
||||||
|
store = DownloadStateStore(file: file)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func encoded(_ json: [String: Any]) throws -> String {
|
||||||
|
String(decoding: try JSONSerialization.data(withJSONObject: json), as: UTF8.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testARecordSurvivesARoundTripUnchanged() {
|
||||||
|
let original = record()
|
||||||
|
store.put(original)
|
||||||
|
XCTAssertEqual(store.get("share-1", "link-1"), original)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testARecordSurvivesARestartUnchanged() {
|
||||||
|
let original = record()
|
||||||
|
store.put(original)
|
||||||
|
XCTAssertEqual(DownloadStateStore(file: file).get("share-1", "link-1"), original)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAnAbsentRecordReadsAsNilRatherThanADefault() {
|
||||||
|
XCTAssertNil(store.get("share-1", "link-1"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAnErrorMessageRoundTripsAndANilOneStaysNil() {
|
||||||
|
store.put(record(error: "no space left on device"))
|
||||||
|
XCTAssertEqual(store.get("share-1", "link-1")?.error, "no space left on device")
|
||||||
|
store.put(record(error: nil))
|
||||||
|
XCTAssertNil(store.get("share-1", "link-1")?.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testABlankErrorIsNotReadAsAFailureMessage() throws {
|
||||||
|
var json = baseJson()
|
||||||
|
json["error"] = ""
|
||||||
|
try writeRaw([downloadKey("share-1", "link-1"): encoded(json)])
|
||||||
|
XCTAssertNil(store.get("share-1", "link-1")?.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testARecordWithNoLabelIsCaptionedWithItsLinkId() throws {
|
||||||
|
var json = baseJson()
|
||||||
|
json["label"] = nil
|
||||||
|
try writeRaw([downloadKey("share-1", "link-1"): encoded(json)])
|
||||||
|
XCTAssertEqual(store.get("share-1", "link-1")?.label, "link-1")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testACorruptRowIsSkippedRatherThanFailingTheWholeList() throws {
|
||||||
|
try writeRaw([
|
||||||
|
downloadKey("share-1", "link-1"): DownloadStateStore.encode(record(label: "good.mkv")),
|
||||||
|
downloadKey("share-1", "link-broken"): "{ not json",
|
||||||
|
])
|
||||||
|
XCTAssertEqual(store.records().map(\.label), ["good.mkv"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRecordsAreOrderedByLabelWithoutRegardToCase() {
|
||||||
|
store.put(record(linkId: "a", label: "beta.mkv"))
|
||||||
|
store.put(record(linkId: "b", label: "Alpha.mkv"))
|
||||||
|
XCTAssertEqual(store.records().map(\.label), ["Alpha.mkv", "beta.mkv"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRemovingOneShareLeavesTheOthersAlone() {
|
||||||
|
store.put(record(shareId: "share-1", linkId: "a"))
|
||||||
|
store.put(record(shareId: "share-2", linkId: "b"))
|
||||||
|
store.removeShare("share-1")
|
||||||
|
XCTAssertEqual(store.records().map(\.shareId), ["share-2"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testClearForgetsEverything() {
|
||||||
|
store.put(record(linkId: "a"))
|
||||||
|
store.put(record(linkId: "b"))
|
||||||
|
store.clear()
|
||||||
|
XCTAssertTrue(store.records().isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testTheKeySeparatesShareFromLinkSoIdsCannotRunTogether() {
|
||||||
|
store.put(record(shareId: "a", linkId: "b-c"))
|
||||||
|
store.put(record(shareId: "a-b", linkId: "c"))
|
||||||
|
XCTAssertEqual(store.records().count, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAnUpdateThatReturnsNilLeavesTheRecordAlone() {
|
||||||
|
store.put(record(status: RetainedDownload.statusPaused))
|
||||||
|
store.update("share-1", "link-1") { _ in nil }
|
||||||
|
XCTAssertEqual(store.get("share-1", "link-1")?.status, RetainedDownload.statusPaused)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class TransferRateTests: XCTestCase {
|
||||||
|
func testTheFirstSampleHasNoRateToReport() {
|
||||||
|
var rate = TransferRate()
|
||||||
|
XCTAssertEqual(rate.sample(4 * 1024 * 1024, now: 1000), 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testASteadyTransferReportsItsRate() {
|
||||||
|
var rate = TransferRate()
|
||||||
|
_ = rate.sample(0, now: 0)
|
||||||
|
XCTAssertEqual(rate.sample(1_000_000, now: 1000), 1_000_000)
|
||||||
|
XCTAssertEqual(rate.sample(2_000_000, now: 2000), 1_000_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOneSlowBlockMovesTheRateWithoutHalvingIt() {
|
||||||
|
var rate = TransferRate()
|
||||||
|
_ = rate.sample(0, now: 0)
|
||||||
|
_ = rate.sample(1_000_000, now: 1000)
|
||||||
|
let after = rate.sample(2_000_000, now: 3000)
|
||||||
|
XCTAssertTrue((600_000 ... 999_999).contains(after))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAResumedDownloadStartingBelowTheLastSampleStartsOver() {
|
||||||
|
var rate = TransferRate()
|
||||||
|
_ = rate.sample(0, now: 0)
|
||||||
|
_ = rate.sample(1_000_000, now: 1000)
|
||||||
|
XCTAssertEqual(rate.sample(0, now: 2000), 1_000_000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class DownloadStatusLineTests: XCTestCase {
|
||||||
|
func testARunningDownloadSaysHowFarAndHowFast() {
|
||||||
|
let download = RetainedDownload(
|
||||||
|
shareId: "s", volumeId: "v", linkId: "l", label: "E01",
|
||||||
|
downloaded: 120 * 1024 * 1024, total: 1288 * 1024 * 1024,
|
||||||
|
status: RetainedDownload.statusRunning, bytesPerSecond: 2_516_582
|
||||||
|
)
|
||||||
|
XCTAssertEqual(downloadStatusLine(download), "Running · 120.0 MiB of 1.3 GiB · 2.4 MiB/s")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAQueuedDownloadSaysOnlyThat() {
|
||||||
|
let download = RetainedDownload(shareId: "s", volumeId: "v", linkId: "l", label: "E01")
|
||||||
|
XCTAssertEqual(downloadStatusLine(download), "Queued")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAPausedDownloadGivesNoRate() {
|
||||||
|
let download = RetainedDownload(
|
||||||
|
shareId: "s", volumeId: "v", linkId: "l", label: "E01",
|
||||||
|
downloaded: 1024, total: 2048, status: RetainedDownload.statusPaused, bytesPerSecond: 500
|
||||||
|
)
|
||||||
|
XCTAssertEqual(downloadStatusLine(download), "Paused · 1.0 KiB of 2.0 KiB")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
@testable import ProtonStream
|
||||||
|
import SwiftUI
|
||||||
|
import XCTest
|
||||||
|
|
||||||
|
private func episode(_ link: String, watched: Bool = false, progress: Double? = nil, lastPlayed: Int64 = 0, number: UInt32? = nil) -> EpisodeRecord {
|
||||||
|
EpisodeRecord(
|
||||||
|
shareId: "s", volumeId: "v", linkId: link, name: "\(link).mkv", label: link, detail: link,
|
||||||
|
season: 1, number: number, size: nil, progress: progress, resumeAt: progress.map { $0 * 1000 },
|
||||||
|
watched: watched, offline: false, providerName: nil, providerOverview: nil, stillUrl: nil,
|
||||||
|
airDate: nil, lastPlayed: lastPlayed
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func title(_ key: String, _ episodes: [EpisodeRecord]) -> TitleRecord {
|
||||||
|
TitleRecord(
|
||||||
|
key: key, name: key, year: nil, kind: .series, watchedCount: 0,
|
||||||
|
episodeCount: UInt64(episodes.count), canonicalName: nil, originalName: nil, overview: nil,
|
||||||
|
metadataProvider: nil, metadataId: nil, metadataYear: nil, metadataKind: nil, posterUrl: nil,
|
||||||
|
backdropUrl: nil, rating: nil, genres: [], providerEpisodeCount: nil, externalUrl: nil,
|
||||||
|
manualMatch: false, seasons: [SeasonRecord(number: 1, label: "Season 1", episodes: episodes)],
|
||||||
|
displayName: key, wideUrl: nil, formatLabel: nil, seasonLabel: nil, studios: [],
|
||||||
|
directors: [], tags: [], airing: false, nextEpisode: nil, nextAiringAt: nil,
|
||||||
|
franchise: []
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
final class IncomingShareLinkTests: XCTestCase {
|
||||||
|
private let link = "https://drive.proton.me/urls/ABC123#s3cr3t"
|
||||||
|
|
||||||
|
func testTheSchemeSwappedLinkKeepsItsFragment() {
|
||||||
|
XCTAssertEqual(shareLink(from: URL(string: "protonstream://drive.proton.me/urls/ABC123#s3cr3t")!), link)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testTheAddFormCarriesTheWholeLink() {
|
||||||
|
let encoded = link.addingPercentEncoding(withAllowedCharacters: .alphanumerics)!
|
||||||
|
XCTAssertEqual(shareLink(from: URL(string: "protonstream://add?url=\(encoded)")!), link)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAnythingButAShareLinkIsIgnored() {
|
||||||
|
XCTAssertNil(shareLink(from: URL(string: "https://drive.proton.me/urls/ABC123#s3cr3t")!))
|
||||||
|
XCTAssertNil(shareLink(from: URL(string: "protonstream://example.com/urls/ABC")!))
|
||||||
|
XCTAssertNil(shareLink(from: URL(string: "protonstream://add?url=http%3A%2F%2Fdrive.proton.me%2Furls%2FA")!))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testALinkIsFoundInsideTextAndStopsAtItsQuotes() {
|
||||||
|
XCTAssertEqual(shareLink(in: "Watch this: \"\(link)\" — enjoy"), link)
|
||||||
|
XCTAssertNil(shareLink(in: "no link here"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class AccountUiTests: XCTestCase {
|
||||||
|
func testTheSyncLineSaysHowLongAgoAndWhatCameIn() {
|
||||||
|
let zero = Date(timeIntervalSince1970: 0)
|
||||||
|
XCTAssertEqual(syncLine(AccountUiState(), now: zero), "Library, settings and watch history sync through your Drive")
|
||||||
|
XCTAssertEqual(syncLine(AccountUiState(syncedAt: zero), now: zero.addingTimeInterval(30)), "Synced just now")
|
||||||
|
XCTAssertEqual(syncLine(AccountUiState(syncedAt: zero), now: zero.addingTimeInterval(300)), "Synced 5 min ago")
|
||||||
|
XCTAssertEqual(
|
||||||
|
syncLine(AccountUiState(syncedAt: zero, applied: 3), now: zero.addingTimeInterval(7200)),
|
||||||
|
"Synced 2 h ago · 3 from other devices"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAFailedSyncIsSaidInPlaceOfWhenItLastWorked() {
|
||||||
|
let zero = Date(timeIntervalSince1970: 0)
|
||||||
|
XCTAssertEqual(
|
||||||
|
syncLine(AccountUiState(syncedAt: zero, syncError: "offline"), now: zero),
|
||||||
|
"Did not sync: offline"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testTheVerificationPageMayOnlyNavigateWithinProtonOverHttps() {
|
||||||
|
XCTAssertTrue(isProtonPage(scheme: "https", host: "verify.proton.me"))
|
||||||
|
XCTAssertTrue(isProtonPage(scheme: "https", host: "proton.me"))
|
||||||
|
XCTAssertFalse(isProtonPage(scheme: "http", host: "verify.proton.me"))
|
||||||
|
XCTAssertFalse(isProtonPage(scheme: "https", host: "proton.me.example.com"))
|
||||||
|
XCTAssertFalse(isProtonPage(scheme: "https", host: "notproton.me"))
|
||||||
|
XCTAssertFalse(isProtonPage(scheme: "https", host: nil))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class ArtworkTests: XCTestCase {
|
||||||
|
func testInitialsSkipTheLowerCaseParticlesOfARomanisedName() {
|
||||||
|
XCTAssertEqual(initials("Oshi no Ko"), "OK")
|
||||||
|
XCTAssertEqual(initials("Ano Hi Mita Hana no Namae o Bokutachi wa Mada Shiranai."), "AH")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAOneWordNameHasOneInitial() {
|
||||||
|
XCTAssertEqual(initials("Akira"), "A")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testANameWithNoCapitalsFallsBackToItsFirstTwoWords() {
|
||||||
|
XCTAssertEqual(initials("blue world order"), "BW")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPunctuationAndSeparatorsAreNotWords() {
|
||||||
|
XCTAssertEqual(initials("Steins;Gate - 2"), "S2")
|
||||||
|
XCTAssertEqual(initials(" - "), "")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The placeholder's colour comes from the name's hash, so a title is the
|
||||||
|
/// same colour here as on Android only if the hash is the JVM's.
|
||||||
|
func testTheNameHashIsTheJvmStringHash() {
|
||||||
|
XCTAssertEqual(javaHash(""), 0)
|
||||||
|
XCTAssertEqual(javaHash("Akira"), 63_321_038)
|
||||||
|
XCTAssertEqual(javaHash("Oshi no Ko"), 539_783_784)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class HistoryTests: XCTestCase {
|
||||||
|
func testHistoryIsEveryStartedOrFinishedEpisodeNewestFirstAcrossTitles() {
|
||||||
|
let titles = [
|
||||||
|
title("a", [
|
||||||
|
episode("a1", watched: true, progress: 1, lastPlayed: 30),
|
||||||
|
// Marked unwatched again: a row at zero, which is not a play.
|
||||||
|
episode("a2", progress: 0, lastPlayed: 40),
|
||||||
|
episode("a3"),
|
||||||
|
]),
|
||||||
|
title("b", [episode("b1", progress: 0.4, lastPlayed: 50)]),
|
||||||
|
]
|
||||||
|
let played = history(titles)
|
||||||
|
XCTAssertEqual(played.map(\.episode.linkId), ["b1", "a1"])
|
||||||
|
XCTAssertEqual(played.last?.index, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDaysAreHeadedRelativeToTodayThenByDate() {
|
||||||
|
var calendar = Calendar(identifier: .gregorian)
|
||||||
|
calendar.timeZone = TimeZone(identifier: "UTC")!
|
||||||
|
let english = Locale(identifier: "en_GB")
|
||||||
|
func day(_ year: Int, _ month: Int, _ day: Int) -> Date {
|
||||||
|
calendar.date(from: DateComponents(year: year, month: month, day: day))!
|
||||||
|
}
|
||||||
|
let today = day(2026, 10, 3) // a Saturday
|
||||||
|
XCTAssertEqual(dayHeading(today, today: today, calendar: calendar, locale: english), "Today")
|
||||||
|
XCTAssertEqual(dayHeading(day(2026, 10, 2), today: today, calendar: calendar, locale: english), "Yesterday")
|
||||||
|
XCTAssertEqual(dayHeading(day(2026, 9, 28), today: today, calendar: calendar, locale: english), "Monday")
|
||||||
|
XCTAssertEqual(dayHeading(day(2026, 9, 26), today: today, calendar: calendar, locale: english), "26 September")
|
||||||
|
XCTAssertEqual(dayHeading(day(2025, 10, 3), today: today, calendar: calendar, locale: english), "3 October 2025")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class CommonTests: XCTestCase {
|
||||||
|
func testPlayLandsOnTheLastPartWatchedEpisodeThenTheFirstUnwatched() {
|
||||||
|
XCTAssertEqual(nextUpIndex([episode("1", watched: true), episode("2", progress: 0.5, lastPlayed: 10), episode("3", progress: 0.2, lastPlayed: 20)]), 2)
|
||||||
|
XCTAssertEqual(nextUpIndex([episode("1", watched: true), episode("2"), episode("3")]), 1)
|
||||||
|
XCTAssertEqual(nextUpIndex([episode("1", watched: true)]), 0)
|
||||||
|
XCTAssertEqual(nextUpIndex([]), 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testBytesAreWrittenInBinaryUnitsToOnePlace() {
|
||||||
|
XCTAssertEqual(formatBytes(512), "512 bytes")
|
||||||
|
XCTAssertEqual(formatBytes(1536), "1.5 KiB")
|
||||||
|
XCTAssertEqual(formatBytes(4 * 1024 * 1024), "4.0 MiB")
|
||||||
|
XCTAssertEqual(formatBytes(3 * 1024 * 1024 * 1024 / 2), "1.5 GiB")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAnEpisodeIsHeadedByItsNameAndNumberBeforeItsFilename() {
|
||||||
|
var named = episode("e", number: 3)
|
||||||
|
named.providerName = "Mother and Children"
|
||||||
|
XCTAssertEqual(named.heading(0), "3. Mother and Children")
|
||||||
|
XCTAssertEqual(episode("e", number: 7).heading(0), "7. e")
|
||||||
|
var bare = episode("e")
|
||||||
|
bare.detail = bare.name
|
||||||
|
XCTAssertEqual(bare.heading(0), "e")
|
||||||
|
bare.number = nil
|
||||||
|
bare.name = ".mkv"
|
||||||
|
bare.detail = bare.name
|
||||||
|
XCTAssertEqual(bare.heading(2), "Episode 3")
|
||||||
|
bare.name = "e.mkv"
|
||||||
|
bare.detail = bare.name
|
||||||
|
bare.number = 4
|
||||||
|
XCTAssertEqual(bare.heading(0), "Episode 4")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testEpisodesAreNumberedBySeasonThenByPlaceInTheList() {
|
||||||
|
var numbered = episode("e", number: 38)
|
||||||
|
numbered.season = 3
|
||||||
|
XCTAssertEqual(numbered.numbering(nil, 0), "S03E38")
|
||||||
|
numbered.season = nil
|
||||||
|
XCTAssertEqual(numbered.numbering(nil, 0), "E38")
|
||||||
|
XCTAssertEqual(numbered.numbering(2, 0), "S02E38")
|
||||||
|
numbered.number = nil
|
||||||
|
XCTAssertEqual(numbered.numbering(nil, 3), "#4")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testClockReadsMinutesThenHours() {
|
||||||
|
XCTAssertEqual(clock(0), "0:00")
|
||||||
|
XCTAssertEqual(clock(724), "12:04")
|
||||||
|
XCTAssertEqual(clock(3725), "1:02:05")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class LanguagePickerTests: XCTestCase {
|
||||||
|
func testAStoredTagReadsAsItsLanguageInEitherIsoForm() {
|
||||||
|
XCTAssertEqual(languageLabel("jpn"), "Japanese")
|
||||||
|
XCTAssertEqual(languageLabel("ja"), "Japanese")
|
||||||
|
XCTAssertEqual(languageLabel("deu"), "German")
|
||||||
|
XCTAssertEqual(languageLabel("ger"), "German")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testARegionSuffixAndCapitalsDoNotHideTheLanguage() {
|
||||||
|
XCTAssertEqual(languageLabel("PT-br"), "Portuguese")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testATagNotOnTheListIsShownAsTypedAndBlankIsNoPreference() {
|
||||||
|
XCTAssertEqual(languageLabel("tgl"), "tgl")
|
||||||
|
XCTAssertEqual(languageLabel(nil), "No preference")
|
||||||
|
XCTAssertEqual(languageLabel(" "), "No preference")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testEveryListedLanguageHasATwoLetterForm() {
|
||||||
|
for language in languages {
|
||||||
|
XCTAssertEqual(languageLabel(twoLetter[language.code]), language.name, language.code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class ThemeTests: XCTestCase {
|
||||||
|
/// The same mapping as Android's `schemeOf`: a role that drifted to a
|
||||||
|
/// different palette colour on one client is the two clients disagreeing.
|
||||||
|
func testEveryRoleIsTheSamePaletteColourAndroidUses() {
|
||||||
|
let palette = protonFallback
|
||||||
|
let scheme = Scheme(palette)
|
||||||
|
XCTAssertEqual(scheme.primary, argb(palette.accent))
|
||||||
|
XCTAssertEqual(scheme.onPrimary, argb(palette.onAccent))
|
||||||
|
XCTAssertEqual(scheme.secondary, argb(palette.accentAlt))
|
||||||
|
XCTAssertEqual(scheme.secondaryContainer, argb(palette.accentDim))
|
||||||
|
XCTAssertEqual(scheme.tertiary, argb(palette.accentAlt))
|
||||||
|
XCTAssertEqual(scheme.background, argb(palette.background))
|
||||||
|
XCTAssertEqual(scheme.surface, argb(palette.surface))
|
||||||
|
XCTAssertEqual(scheme.onSurface, argb(palette.text))
|
||||||
|
XCTAssertEqual(scheme.onSurfaceVariant, argb(palette.muted))
|
||||||
|
XCTAssertEqual(scheme.inverseSurface, argb(palette.text))
|
||||||
|
XCTAssertEqual(scheme.inverseOnSurface, argb(palette.background))
|
||||||
|
XCTAssertEqual(scheme.error, argb(palette.danger))
|
||||||
|
XCTAssertEqual(scheme.errorContainer, argb(palette.dangerDim))
|
||||||
|
XCTAssertEqual(scheme.outline, argb(palette.muted))
|
||||||
|
XCTAssertEqual(scheme.outlineVariant, argb(palette.border))
|
||||||
|
XCTAssertEqual(scheme.surfaceContainerLowest, argb(palette.sunken))
|
||||||
|
XCTAssertEqual(scheme.surfaceContainer, argb(palette.card))
|
||||||
|
XCTAssertEqual(scheme.surfaceContainerHigh, argb(palette.cardHover))
|
||||||
|
XCTAssertEqual(scheme.surfaceContainerHighest, argb(palette.elevated))
|
||||||
|
XCTAssertFalse(scheme.light)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testArgbUnpacksEveryChannel() {
|
||||||
|
let colour = UIColor(argb(0x80FF_4000))
|
||||||
|
var red: CGFloat = 0, green: CGFloat = 0, blue: CGFloat = 0, alpha: CGFloat = 0
|
||||||
|
colour.getRed(&red, green: &green, blue: &blue, alpha: &alpha)
|
||||||
|
XCTAssertEqual(red, 1, accuracy: 0.001)
|
||||||
|
XCTAssertEqual(green, 64 / 255, accuracy: 0.001)
|
||||||
|
XCTAssertEqual(blue, 0, accuracy: 0.001)
|
||||||
|
XCTAssertEqual(alpha, 128 / 255, accuracy: 0.001)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every style on one of the nine rungs, every rung used, and leading
|
||||||
|
/// derived from the size — Android's `TypeRampTest`.
|
||||||
|
func testEveryStyleIsOnTheDesktopRamp() {
|
||||||
|
let rungs: Set<CGFloat> = [26, 20, 18, 17, 15, 14, 13, 12, 11]
|
||||||
|
let used = Set(TypeRole.allCases.map(\.size))
|
||||||
|
XCTAssertEqual(used, rungs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOnlyHeadingsAreSemibold() {
|
||||||
|
XCTAssertEqual(
|
||||||
|
Set(TypeRole.allCases.filter(\.semibold)),
|
||||||
|
[.displayLarge, .displayMedium, .displaySmall, .headlineLarge, .headlineMedium, .headlineSmall, .titleLarge]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,8 +17,24 @@ targets:
|
|||||||
platform: iOS
|
platform: iOS
|
||||||
sources:
|
sources:
|
||||||
- path: ProtonStream
|
- path: ProtonStream
|
||||||
|
excludes:
|
||||||
|
# The icon's source; the asset catalog holds what it renders to.
|
||||||
|
- Resources/AppIcon.svg
|
||||||
|
- Resources/THIRD_PARTY_NOTICES.md
|
||||||
|
- path: ProtonStream/Resources/THIRD_PARTY_NOTICES.md
|
||||||
|
buildPhase: resources
|
||||||
# UniFFI output, written by `scripts/build-ios.sh bindings`.
|
# UniFFI output, written by `scripts/build-ios.sh bindings`.
|
||||||
- path: build/generated/PstrBridge.swift
|
- path: build/generated/PstrBridge.swift
|
||||||
|
# Shared with the Android client rather than copied: Inter, and the
|
||||||
|
# licence texts the About page shows.
|
||||||
|
- path: ../android/app/src/main/res/font/inter_regular.ttf
|
||||||
|
buildPhase: resources
|
||||||
|
- path: ../android/app/src/main/res/font/inter_semibold.ttf
|
||||||
|
buildPhase: resources
|
||||||
|
- path: ../android/app/src/main/assets/licenses/GPL-3.0.txt
|
||||||
|
buildPhase: resources
|
||||||
|
- path: ../android/app/src/main/assets/licenses/Inter-OFL.txt
|
||||||
|
buildPhase: resources
|
||||||
dependencies:
|
dependencies:
|
||||||
# The GPL build is the one with libass and every demuxer; the app is
|
# The GPL build is the one with libass and every demuxer; the app is
|
||||||
# sideloaded, never distributed through the App Store.
|
# sideloaded, never distributed through the App Store.
|
||||||
@@ -26,6 +42,9 @@ targets:
|
|||||||
product: MPVKit-GPL
|
product: MPVKit-GPL
|
||||||
- sdk: Security.framework
|
- sdk: Security.framework
|
||||||
- sdk: SystemConfiguration.framework
|
- sdk: SystemConfiguration.framework
|
||||||
|
scheme:
|
||||||
|
testTargets:
|
||||||
|
- ProtonStreamTests
|
||||||
settings:
|
settings:
|
||||||
base:
|
base:
|
||||||
PRODUCT_BUNDLE_IDENTIFIER: io.narl.protonstream
|
PRODUCT_BUNDLE_IDENTIFIER: io.narl.protonstream
|
||||||
@@ -33,9 +52,11 @@ targets:
|
|||||||
CURRENT_PROJECT_VERSION: ${PSTR_BUILD}
|
CURRENT_PROJECT_VERSION: ${PSTR_BUILD}
|
||||||
SWIFT_VERSION: "5.0"
|
SWIFT_VERSION: "5.0"
|
||||||
TARGETED_DEVICE_FAMILY: "1,2"
|
TARGETED_DEVICE_FAMILY: "1,2"
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||||
SWIFT_OBJC_BRIDGING_HEADER: ProtonStream/Bridge/ProtonStream-Bridging-Header.h
|
SWIFT_OBJC_BRIDGING_HEADER: ProtonStream/Bridge/ProtonStream-Bridging-Header.h
|
||||||
HEADER_SEARCH_PATHS: $(PROJECT_DIR)/build/generated
|
HEADER_SEARCH_PATHS: $(PROJECT_DIR)/build/generated
|
||||||
LIBRARY_SEARCH_PATHS: $(PROJECT_DIR)/../target/aarch64-apple-ios/release
|
"LIBRARY_SEARCH_PATHS[sdk=iphoneos*]": $(PROJECT_DIR)/../target/aarch64-apple-ios/release
|
||||||
|
"LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*]": $(PROJECT_DIR)/../target/aarch64-apple-ios-sim/release
|
||||||
OTHER_LDFLAGS: -lpstr_android
|
OTHER_LDFLAGS: -lpstr_android
|
||||||
info:
|
info:
|
||||||
path: ProtonStream/Info.plist
|
path: ProtonStream/Info.plist
|
||||||
@@ -44,8 +65,18 @@ targets:
|
|||||||
LSRequiresIPhoneOS: true
|
LSRequiresIPhoneOS: true
|
||||||
ITSAppUsesNonExemptEncryption: false
|
ITSAppUsesNonExemptEncryption: false
|
||||||
UIBackgroundModes: [audio]
|
UIBackgroundModes: [audio]
|
||||||
UILaunchScreen: {}
|
# The Proton palette's page colour, so a dark app does not open on white.
|
||||||
|
UILaunchScreen:
|
||||||
|
UIColorName: LaunchBackground
|
||||||
UIRequiresFullScreen: false
|
UIRequiresFullScreen: false
|
||||||
|
UIAppFonts:
|
||||||
|
- inter_regular.ttf
|
||||||
|
- inter_semibold.ttf
|
||||||
|
# A share link handed over from elsewhere: protonstream://add?url=…,
|
||||||
|
# or the link with its scheme swapped (IncomingShareLink.swift).
|
||||||
|
CFBundleURLTypes:
|
||||||
|
- CFBundleURLName: io.narl.protonstream.share-link
|
||||||
|
CFBundleURLSchemes: [protonstream]
|
||||||
UISupportedInterfaceOrientations:
|
UISupportedInterfaceOrientations:
|
||||||
- UIInterfaceOrientationPortrait
|
- UIInterfaceOrientationPortrait
|
||||||
- UIInterfaceOrientationLandscapeLeft
|
- UIInterfaceOrientationLandscapeLeft
|
||||||
@@ -55,3 +86,20 @@ targets:
|
|||||||
- UIInterfaceOrientationPortraitUpsideDown
|
- UIInterfaceOrientationPortraitUpsideDown
|
||||||
- UIInterfaceOrientationLandscapeLeft
|
- UIInterfaceOrientationLandscapeLeft
|
||||||
- UIInterfaceOrientationLandscapeRight
|
- UIInterfaceOrientationLandscapeRight
|
||||||
|
|
||||||
|
# The pure logic the Android unit tests pin, pinned again here: hosted in the
|
||||||
|
# app, so `@testable import` reaches it, on the simulator.
|
||||||
|
ProtonStreamTests:
|
||||||
|
type: bundle.unit-test
|
||||||
|
platform: iOS
|
||||||
|
sources:
|
||||||
|
- path: ProtonStreamTests
|
||||||
|
dependencies:
|
||||||
|
- target: ProtonStream
|
||||||
|
settings:
|
||||||
|
base:
|
||||||
|
SWIFT_VERSION: "5.0"
|
||||||
|
GENERATE_INFOPLIST_FILE: YES
|
||||||
|
# `@testable import ProtonStream` pulls in the app's bridging header,
|
||||||
|
# which includes the generated FFI header.
|
||||||
|
HEADER_SEARCH_PATHS: $(PROJECT_DIR)/build/generated
|
||||||