Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
|
||||
|
||||
# libmpv for pstr-player.
|
||||
- name: Install libmpv
|
||||
# libmpv for pstr-player, WebKitGTK for the CAPTCHA window.
|
||||
- name: Install libmpv and WebKitGTK
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libmpv-dev
|
||||
sudo apt-get install -y libmpv-dev libwebkit2gtk-4.1-dev
|
||||
|
||||
- run: cargo fmt --all -- --check
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
name: iOS
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_call:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'ios/**'
|
||||
- 'android/app/src/main/res/font/**'
|
||||
- 'android/app/src/main/assets/licenses/**'
|
||||
- 'crates/pstr-android/**'
|
||||
- 'crates/pstr-core/**'
|
||||
- 'crates/pstr-meta/**'
|
||||
- 'crates/pstr-stream/**'
|
||||
- 'Cargo.toml'
|
||||
- 'Cargo.lock'
|
||||
- 'scripts/build-ios.sh'
|
||||
- '.github/workflows/ios.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Unit tests and unsigned .ipa
|
||||
runs-on: macos-15
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- id: toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: '1.96'
|
||||
targets: aarch64-apple-ios,aarch64-apple-ios-sim
|
||||
|
||||
- name: Restore the Rust target tree
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: rust-target-ios-${{ steps.toolchain.outputs.cachekey }}-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: rust-target-ios-${{ steps.toolchain.outputs.cachekey }}-
|
||||
|
||||
- name: Install XcodeGen
|
||||
run: brew install xcodegen
|
||||
|
||||
- name: Bindings
|
||||
run: scripts/build-ios.sh bindings
|
||||
|
||||
- name: Unit tests on the simulator
|
||||
run: scripts/build-ios.sh test
|
||||
|
||||
# SideStore and AltStore re-sign whatever they install with the viewer's
|
||||
# own Apple ID, so the release artifact is unsigned.
|
||||
- name: Unsigned .ipa
|
||||
run: |
|
||||
scripts/build-ios.sh rust
|
||||
scripts/build-ios.sh ipa
|
||||
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ios
|
||||
path: dist/*.ipa
|
||||
if-no-files-found: error
|
||||
@@ -76,7 +76,7 @@ jobs:
|
||||
- name: Install build dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libmpv-dev rpm
|
||||
sudo apt-get install -y libmpv-dev libwebkit2gtk-4.1-dev rpm
|
||||
|
||||
- name: Gate
|
||||
run: |
|
||||
@@ -150,10 +150,17 @@ jobs:
|
||||
signed_release: true
|
||||
secrets: inherit
|
||||
|
||||
# The unsigned .ipa: SideStore and AltStore re-sign it with the viewer's own
|
||||
# Apple ID when they install it.
|
||||
ios:
|
||||
name: iOS
|
||||
needs: verify
|
||||
uses: ./.github/workflows/ios.yml
|
||||
|
||||
publish:
|
||||
name: Publish
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
needs: [linux, windows, android]
|
||||
needs: [linux, windows, android, ios]
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -172,6 +179,10 @@ jobs:
|
||||
with:
|
||||
name: android
|
||||
path: artifacts
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: ios
|
||||
path: artifacts
|
||||
|
||||
- name: Checksums
|
||||
run: |
|
||||
|
||||
@@ -11,7 +11,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.1.1"
|
||||
version = "1.3.0"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
repository = "https://github.com/narrrl/proton-stream"
|
||||
@@ -28,8 +28,8 @@ authors = ["Nils Pukropp <contact@narl.io>"]
|
||||
opt-level = 2
|
||||
|
||||
[workspace.dependencies]
|
||||
proton-sdk = { version = "0.7.2" }
|
||||
proton-drive-rs = { version = "0.7.2" }
|
||||
proton-sdk = { version = "0.7.3" }
|
||||
proton-drive-rs = { version = "0.7.3" }
|
||||
|
||||
# Internal crates.
|
||||
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.
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||
sha2 = "0.10"
|
||||
# The id a watch-history file is named by. Already in the tree through the SDK.
|
||||
getrandom = "0.3"
|
||||
# `bundled` so a Windows build needs no system SQLite.
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
# The share URL fragment and the custom password are both secrets; they go in
|
||||
@@ -103,6 +105,14 @@ dbus = "0.9"
|
||||
windows-sys = "0.61"
|
||||
# Toasts on Windows. Linux notifications go over the `dbus` above.
|
||||
tauri-winrt-notification = "0.8"
|
||||
# The exe's own icon resource, which is what Explorer and a taskbar pin draw.
|
||||
embed-resource = "3"
|
||||
# Proton's CAPTCHA page, which reports success by posting a message to its
|
||||
# host, so it needs a webview this app controls. Run in a child process of its
|
||||
# own (`pstr-app::verify`): tao and eframe's winit cannot share an event loop.
|
||||
# WebKitGTK on Linux, WebView2 on Windows, WKWebView on macOS.
|
||||
wry = { version = "0.57", default-features = false, features = ["os-webview"] }
|
||||
tao = { version = "0.37", default-features = false, features = ["rwh_06"] }
|
||||
rpassword = "7"
|
||||
libloading = "0.8"
|
||||
lru = "0.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.
|
||||
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
|
||||
cargo run --release -p pstr-app # the app
|
||||
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. |
|
||||
| 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). |
|
||||
| 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
|
||||
|
||||
@@ -83,7 +87,9 @@ Rust 2024, MSRV 1.96.
|
||||
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
|
||||
`libmpv-2.dll` ships next to the executable. The Proton SDK crates come from
|
||||
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;
|
||||
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
|
||||
|
||||
- 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
|
||||
|
||||
The shared Rust crates and desktop application are MIT licensed. The Android
|
||||
application and its combined native distribution are GPL-3.0-or-later; see
|
||||
[`android/LICENSE.md`](android/LICENSE.md) and the bundled-component notices.
|
||||
The shared Rust crates and desktop application are MIT licensed. The Android and
|
||||
iOS applications and their combined native distributions are GPL-3.0-or-later; see
|
||||
[`android/LICENSE.md`](android/LICENSE.md), [`ios/LICENSE.md`](ios/LICENSE.md) and
|
||||
the bundled-component notices.
|
||||
The desktop application embeds the Inter typeface, under the SIL Open Font
|
||||
License 1.1 ([`crates/pstr-app/assets/fonts/Inter-OFL.txt`](crates/pstr-app/assets/fonts/Inter-OFL.txt)),
|
||||
and the Phosphor icon font, under MIT.
|
||||
@@ -33,6 +33,7 @@ import io.narl.protonstream.playback.MpvPlaybackState
|
||||
import io.narl.protonstream.playback.NativeMpvHost
|
||||
import io.narl.protonstream.playback.PlaybackService
|
||||
import io.narl.protonstream.settings.SettingsStore
|
||||
import io.narl.protonstream.sync.WatchSyncWorker
|
||||
import io.narl.protonstream.ui.ProtonStreamApp
|
||||
import io.narl.protonstream.ui.theme.AppearanceState
|
||||
import io.narl.protonstream.ui.theme.ProtonStreamTheme
|
||||
@@ -207,6 +208,9 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
// Where the viewer stopped is what their other devices want next, and
|
||||
// a process in the background may not live to the next timed sync.
|
||||
if (!isChangingConfigurations) WatchSyncWorker.enqueue(this)
|
||||
if (
|
||||
!isChangingConfigurations &&
|
||||
!isInPictureInPictureMode &&
|
||||
|
||||
@@ -13,6 +13,8 @@ data class RetainedDownload(
|
||||
val total: Long = 0,
|
||||
val status: String = STATUS_QUEUED,
|
||||
val error: String? = null,
|
||||
/** Recent transfer rate while running; 0 when unknown or not running. */
|
||||
val bytesPerSecond: Long = 0,
|
||||
) {
|
||||
val key: String get() = "$shareId\u001f$linkId"
|
||||
|
||||
@@ -99,6 +101,7 @@ class DownloadStateStore(context: Context) {
|
||||
.put("total", record.total)
|
||||
.put("status", record.status)
|
||||
.put("error", record.error)
|
||||
.put("rate", record.bytesPerSecond)
|
||||
.toString()
|
||||
|
||||
private fun decode(encoded: String): RetainedDownload {
|
||||
@@ -112,6 +115,7 @@ class DownloadStateStore(context: Context) {
|
||||
total = json.optLong("total"),
|
||||
status = json.optString("status", RetainedDownload.STATUS_QUEUED),
|
||||
error = json.optString("error").takeIf(String::isNotBlank),
|
||||
bytesPerSecond = json.optLong("rate"),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -25,20 +25,24 @@ class OfflineDownloadWorker(
|
||||
) : CoroutineWorker(appContext, parameters) {
|
||||
override suspend fun doWork(): Result {
|
||||
createNotificationChannel()
|
||||
setForeground(createForegroundInfo(0))
|
||||
setForeground(createForegroundInfo(null))
|
||||
val shareId = inputData.getString(KEY_SHARE_ID) ?: return Result.failure(error("missing share"))
|
||||
val volumeId = inputData.getString(KEY_VOLUME_ID) ?: return Result.failure(error("missing volume"))
|
||||
val linkId = inputData.getString(KEY_LINK_ID) ?: return Result.failure(error("missing link"))
|
||||
val label = inputData.getString(KEY_LABEL) ?: linkId
|
||||
val store = DownloadStateStore(applicationContext)
|
||||
// Queued, not running: the engine lets two downloads transfer at once
|
||||
// and holds the rest back, and only the first progress report means
|
||||
// this one has a slot.
|
||||
var retained = store.get(shareId, linkId)
|
||||
?: RetainedDownload(shareId, volumeId, linkId, label)
|
||||
retained = retained.copy(status = RetainedDownload.STATUS_RUNNING, error = null)
|
||||
retained = retained.copy(status = RetainedDownload.STATUS_QUEUED, error = null, bytesPerSecond = 0)
|
||||
store.put(retained)
|
||||
|
||||
val observer = object : DownloadObserver {
|
||||
private var reportedAt = 0L
|
||||
private var reportedPercent = -1
|
||||
private val rate = TransferRate()
|
||||
|
||||
/**
|
||||
* Called once per 4 MiB block — a dozen times a second on a fast
|
||||
@@ -51,6 +55,7 @@ class OfflineDownloadWorker(
|
||||
val percent = if (total == 0UL) 0 else ((downloaded * 100UL) / total).toInt()
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
val complete = total > 0UL && downloaded >= total
|
||||
val bytesPerSecond = rate.sample(downloaded.toLong(), now)
|
||||
if (!complete && percent == reportedPercent && now - reportedAt < PROGRESS_INTERVAL_MS) {
|
||||
return
|
||||
}
|
||||
@@ -66,6 +71,7 @@ class OfflineDownloadWorker(
|
||||
current.copy(
|
||||
downloaded = downloaded.toLong(),
|
||||
total = total.toLong(),
|
||||
bytesPerSecond = bytesPerSecond,
|
||||
status = when (current.status) {
|
||||
RetainedDownload.STATUS_PAUSED,
|
||||
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)
|
||||
.setSmallIcon(io.narl.protonstream.R.drawable.ic_launcher_foreground)
|
||||
.setContentTitle("Making episode available offline")
|
||||
.setProgress(100, progress, progress == 0)
|
||||
.setContentTitle(
|
||||
if (progress == null) "Waiting to download" else "Making episode available offline",
|
||||
)
|
||||
.setProgress(100, progress ?: 0, progress == null || progress == 0)
|
||||
.setOngoing(true)
|
||||
.build()
|
||||
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 =
|
||||
(workId.hashCode() and Int.MAX_VALUE).coerceAtLeast(1)
|
||||
@@ -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,506 @@
|
||||
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, watch history is kept in your own Drive so every device " +
|
||||
"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 "Watch history did not sync: $it" }
|
||||
val at = account.syncedAt ?: return "Watch history syncs with 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,
|
||||
)
|
||||
}
|
||||
@@ -36,6 +36,10 @@ import uniffi.pstr_android.MetadataSettingsRecord
|
||||
import uniffi.pstr_android.StorageUsageRecord
|
||||
import uniffi.pstr_android.EpisodeRecord
|
||||
import uniffi.pstr_android.WatchStateRecord
|
||||
import uniffi.pstr_android.AccountState
|
||||
import uniffi.pstr_android.SignInOutcome
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
|
||||
data class AppUiState(
|
||||
val loading: Boolean = true,
|
||||
@@ -62,6 +66,25 @@ data class AppUiState(
|
||||
val message: String? = null,
|
||||
/** What Undo on [message] puts back, when the message offers one. */
|
||||
val undo: List<WatchSnapshot>? = null,
|
||||
val account: AccountUiState = AccountUiState(),
|
||||
)
|
||||
|
||||
/** The Proton account: where a sign-in stands, and how sync is doing. */
|
||||
data class AccountUiState(
|
||||
/** Null until the bridge has said. */
|
||||
val state: AccountState? = null,
|
||||
/** A sign-in step is out and not answered yet. */
|
||||
val busy: Boolean = false,
|
||||
/** Why the last sign-in step was refused. */
|
||||
val error: String? = null,
|
||||
/** The CAPTCHA page Proton wants solved before it accepts the sign-in. */
|
||||
val verificationUrl: String? = null,
|
||||
/** When watch history last synced, in epoch milliseconds. */
|
||||
val syncedAt: Long? = null,
|
||||
/** Positions the last sync took from other devices. */
|
||||
val applied: Int = 0,
|
||||
/** Why the last sync failed, until one succeeds. */
|
||||
val syncError: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -84,6 +107,18 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
||||
}
|
||||
}
|
||||
reload()
|
||||
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()
|
||||
delay(SYNC_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
searchQuery.debounce(300).distinctUntilChanged().collect { reloadLibrary(it) }
|
||||
}
|
||||
@@ -212,6 +247,148 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
if (report.applied > 0u) reload()
|
||||
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
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
mutableState.update { it.copy(refreshing = true) }
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
val engine = NativeRuntime.engine()
|
||||
val share = engine.addAccountFolder(name, volumeId, linkId)
|
||||
engine.crawl(share.id)
|
||||
}
|
||||
}.onFailure(::reportError)
|
||||
mutableState.update { it.copy(refreshing = false) }
|
||||
reload()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -501,6 +678,10 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
||||
}.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 {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
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.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import io.narl.protonstream.download.RetainedDownload
|
||||
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".
|
||||
*
|
||||
|
||||
@@ -204,14 +204,7 @@ private fun PartialRow(
|
||||
headlineContent = { Text(download.label, maxLines = 2, overflow = TextOverflow.Ellipsis) },
|
||||
supportingContent = {
|
||||
Column {
|
||||
Text(
|
||||
listOfNotNull(
|
||||
download.status.replaceFirstChar { it.uppercase() },
|
||||
download.total.takeIf { it > 0L }?.let {
|
||||
"${formatBytes(download.downloaded.toULong())} of ${formatBytes(it.toULong())}"
|
||||
},
|
||||
).joinToString(" · "),
|
||||
)
|
||||
Text(downloadStatusLine(download))
|
||||
if (download.total > 0L) {
|
||||
AccentProgress(
|
||||
progress = { (download.downloaded.toFloat() / download.total).coerceIn(0f, 1f) },
|
||||
|
||||
@@ -270,6 +270,18 @@ fun ProtonStreamApp(
|
||||
shareLink,
|
||||
onShareLinkTaken,
|
||||
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(
|
||||
state,
|
||||
@@ -308,7 +320,7 @@ fun ProtonStreamApp(
|
||||
onClose = {
|
||||
playingTitleKey = null
|
||||
playerMinimized = false
|
||||
model.reloadAfterMetadataChange()
|
||||
model.playerClosed()
|
||||
},
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
@@ -338,7 +350,7 @@ fun ProtonStreamApp(
|
||||
onClose = {
|
||||
playingTitleKey = null
|
||||
playerMinimized = false
|
||||
model.reloadAfterMetadataChange()
|
||||
model.playerClosed()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ import androidx.compose.ui.window.SecureFlagPolicy
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.narl.protonstream.ui.theme.AccentButton
|
||||
import io.narl.protonstream.ui.theme.TonalButton
|
||||
import uniffi.pstr_android.AccountState
|
||||
import uniffi.pstr_android.ShareRecord
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
@@ -56,16 +57,40 @@ internal fun SharesScreen(
|
||||
incomingLink: String?,
|
||||
onIncomingLinkTaken: () -> Unit,
|
||||
padding: PaddingValues,
|
||||
account: AccountUiState = AccountUiState(),
|
||||
accountActions: AccountActions? = null,
|
||||
) {
|
||||
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 removing by remember { mutableStateOf<ShareRecord?>(null) }
|
||||
TabPage("Shares", 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.")
|
||||
} else {
|
||||
LazyColumn(contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp)) {
|
||||
if (accountActions != null) {
|
||||
item(key = "account") {
|
||||
AccountCard(
|
||||
account,
|
||||
accountActions,
|
||||
onSignIn = { signingIn = true },
|
||||
onBrowse = { browsing = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
if (shares.isEmpty()) {
|
||||
item(key = "empty") {
|
||||
Text(
|
||||
"No shares yet. Add a public link, or a folder of your Drive once signed in.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
items(shares, key = { it.id }) { share ->
|
||||
ShareRow(
|
||||
share = share,
|
||||
@@ -95,6 +120,14 @@ internal fun SharesScreen(
|
||||
onAdd = onAdd,
|
||||
)
|
||||
}
|
||||
if (accountActions != null && (signingIn || account.state == AccountState.SecondFactor ||
|
||||
account.state == AccountState.MailboxPassword)
|
||||
) {
|
||||
SignInDialog(account, accountActions, onDismiss = { signingIn = false })
|
||||
}
|
||||
if (accountActions != null && browsing) {
|
||||
DriveBrowserDialog(accountActions, onDismiss = { browsing = false })
|
||||
}
|
||||
repairing?.let { share ->
|
||||
RepairShareDialog(
|
||||
share = share,
|
||||
@@ -103,15 +136,15 @@ internal fun SharesScreen(
|
||||
)
|
||||
}
|
||||
removing?.let { share ->
|
||||
// Asked, because it cannot be undone: the link's secret, its offline
|
||||
// files and its watch positions all go (`Catalog::remove_share`).
|
||||
// Asked, because it cannot be undone: the link's secret and its
|
||||
// offline files go. Watch positions stay (`Catalog::remove_share`).
|
||||
AlertDialog(
|
||||
onDismissRequest = { removing = null },
|
||||
title = { Text("Remove ${share.name}?") },
|
||||
text = {
|
||||
Text(
|
||||
"Its titles leave the library, and its offline episodes and watch " +
|
||||
"progress are deleted. Adding the link again starts it fresh.",
|
||||
"Its titles leave the library and its offline episodes are deleted. " +
|
||||
"Watch progress is kept, so adding the link again picks up where you left off.",
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
@@ -139,7 +172,13 @@ private fun ShareRow(
|
||||
ListItem(
|
||||
headlineContent = { Text(share.name, fontWeight = FontWeight.SemiBold) },
|
||||
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 = {
|
||||
Icon(
|
||||
@@ -164,11 +203,13 @@ private fun ShareRow(
|
||||
// Not a remove: a share whose secret has become unreadable
|
||||
// cannot be removed either, since removal deletes a secret
|
||||
// the store can no longer touch.
|
||||
DropdownMenuItem(
|
||||
text = { Text("Re-enter link") },
|
||||
leadingIcon = { Icon(Icons.Default.Key, contentDescription = null) },
|
||||
onClick = { menu = false; onRepair() },
|
||||
)
|
||||
if (!share.fromAccount) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Re-enter link") },
|
||||
leadingIcon = { Icon(Icons.Default.Key, contentDescription = null) },
|
||||
onClick = { menu = false; onRepair() },
|
||||
)
|
||||
}
|
||||
DropdownMenuItem(
|
||||
text = { Text("Remove") },
|
||||
leadingIcon = { Icon(Icons.Default.Delete, contentDescription = null) },
|
||||
|
||||
@@ -636,8 +636,7 @@ private fun EpisodeRow(
|
||||
fill = solid(MaterialTheme.colorScheme.tertiary),
|
||||
)
|
||||
Text(
|
||||
"${active.status.replaceFirstChar { it.uppercase() }} · " +
|
||||
"${formatBytes(active.downloaded.toULong())} of ${formatBytes(active.total.toULong())}",
|
||||
downloadStatusLine(active),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -654,6 +653,8 @@ internal fun ChangeMatchDialog(
|
||||
) {
|
||||
var term by remember(title.key) { mutableStateOf(title.canonicalName ?: title.name) }
|
||||
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()) }
|
||||
val scope = rememberCoroutineScope()
|
||||
AlertDialog(
|
||||
@@ -672,16 +673,26 @@ internal fun ChangeMatchDialog(
|
||||
searching = false
|
||||
}
|
||||
}) { 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)) {
|
||||
items(options, key = { "${it.provider}:${it.remoteId}" }) { option ->
|
||||
EdgedButton(
|
||||
onClick = {
|
||||
choosing = true
|
||||
scope.launch {
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) { NativeRuntime.engine().chooseMatch(title.key, option) }
|
||||
}.onSuccess { onChanged() }.onFailure(onError)
|
||||
choosing = false
|
||||
}
|
||||
},
|
||||
enabled = !choosing,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 6.dp),
|
||||
) {
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
|
||||
@@ -2,6 +2,7 @@ package io.narl.protonstream.ui.theme
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
@@ -9,6 +10,7 @@ import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.LocalMinimumInteractiveComponentSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.NavigationBarItemDefaults
|
||||
import androidx.compose.material3.NavigationDrawerItemDefaults
|
||||
@@ -18,14 +20,18 @@ import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteDefaul
|
||||
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteItemColors
|
||||
import androidx.compose.material3.SliderState
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.minimumInteractiveComponentSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
@@ -68,17 +74,30 @@ internal fun AccentButton(
|
||||
content: @Composable RowScope.() -> Unit,
|
||||
) {
|
||||
val shape = MaterialTheme.shapes.small
|
||||
Button(
|
||||
onClick = onClick,
|
||||
// Only while it is live: a disabled button keeps Material's flat
|
||||
// disabled container, because a greyed control that still wears the
|
||||
// one strong colour in the window reads as pressable.
|
||||
modifier = if (enabled) modifier.background(accentBrush(), shape) else modifier,
|
||||
enabled = enabled,
|
||||
shape = shape,
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color.Transparent),
|
||||
content = content,
|
||||
)
|
||||
// The button pads itself out to the 48dp minimum touch target, and a
|
||||
// background on its modifier fills that whole target — 8dp taller than
|
||||
// the 40dp container every other button draws. So the padding is taken
|
||||
// here, outside the ramp, and the button inside is told not to add its
|
||||
// own. The box hands its size down, so a `fillMaxWidth` still reaches it.
|
||||
Box(
|
||||
modifier.minimumInteractiveComponentSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
propagateMinConstraints = true,
|
||||
) {
|
||||
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) {
|
||||
Button(
|
||||
onClick = onClick,
|
||||
// Only while it is live: a disabled button keeps Material's flat
|
||||
// disabled container, because a greyed control that still wears the
|
||||
// one strong colour in the window reads as pressable.
|
||||
modifier = if (enabled) Modifier.background(accentBrush(), shape) else Modifier,
|
||||
enabled = enabled,
|
||||
shape = shape,
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color.Transparent),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -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("Watch history syncs with 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(
|
||||
"Watch history 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))
|
||||
}
|
||||
}
|
||||
|
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-stream.workspace = true
|
||||
tokio.workspace = true
|
||||
futures.workspace = true
|
||||
parking_lot.workspace = true
|
||||
thiserror.workspace = true
|
||||
uniffi.workspace = true
|
||||
|
||||
@@ -10,6 +10,7 @@ use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
use parking_lot::Mutex;
|
||||
use pstr_core::account::{DriveEntry, PendingSignIn, PlaceKind, SignIn};
|
||||
use pstr_core::appearance::{Accent, Appearance, Flavor, Palette};
|
||||
use pstr_core::browse::Sort;
|
||||
use pstr_core::catalog::{Catalog, OfflineFile, TitleTrackPrefs, WatchState, build_rows};
|
||||
@@ -20,12 +21,17 @@ use pstr_core::metadata::{
|
||||
EpisodeGuide, MetadataConfig, MetadataRecord, ProviderId, TitleMetadata, TitleNames,
|
||||
};
|
||||
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::{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::{
|
||||
BlockSource, DiskCacheConfig, FileBlocks, LibraryOpener, NodeUid, StreamConfig, StreamSource,
|
||||
VideoStream,
|
||||
BlockSource, COPY_BLOCKS_IN_FLIGHT, DiskCacheConfig, FileBlocks, LibraryOpener, NodeUid,
|
||||
StreamConfig, StreamSource, VideoStream,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -131,6 +137,102 @@ pub struct ShareRecord {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
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,
|
||||
}
|
||||
|
||||
#[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)]
|
||||
@@ -811,7 +913,7 @@ pub struct AndroidEngine {
|
||||
/// Held across the open handshakes so concurrent callers share one build.
|
||||
connecting: tokio::sync::Mutex<()>,
|
||||
/// 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.
|
||||
library_cache: Mutex<Option<CachedLibrary>>,
|
||||
/// Serializes offline publication with share removal cleanup.
|
||||
@@ -830,8 +932,30 @@ pub struct AndroidEngine {
|
||||
/// The streaming cache's budget in bytes, for the next connection and,
|
||||
/// through [`AndroidEngine::set_stream_cache_budget`], the open one.
|
||||
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.
|
||||
///
|
||||
/// A refresh costs one round trip. Not refreshing costs an authentication
|
||||
@@ -854,6 +978,17 @@ impl AndroidEngine {
|
||||
Arc::clone(&secret_store),
|
||||
));
|
||||
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()
|
||||
.enable_all()
|
||||
.build()
|
||||
@@ -875,23 +1010,224 @@ impl AndroidEngine {
|
||||
thumbnails: tokio::sync::Semaphore::new(THUMBNAIL_CONCURRENCY),
|
||||
session_refreshed: Mutex::new(HashMap::new()),
|
||||
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).await {
|
||||
Ok(report) => 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),
|
||||
})),
|
||||
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))
|
||||
}
|
||||
|
||||
pub fn shares(&self) -> Result<Vec<ShareRecord>, BridgeError> {
|
||||
self.store
|
||||
.list()
|
||||
.map_err(BridgeError::from_display)
|
||||
.map(|shares| {
|
||||
shares
|
||||
.into_iter()
|
||||
.map(|share| ShareRecord {
|
||||
id: share.id,
|
||||
name: share.name,
|
||||
has_custom_password: share.has_custom_password,
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.map(|shares| shares.into_iter().map(share_record).collect())
|
||||
}
|
||||
|
||||
pub fn add_share(
|
||||
@@ -905,11 +1241,7 @@ impl AndroidEngine {
|
||||
.add(&name, &url, custom_password.as_deref())
|
||||
.map_err(BridgeError::from_display)?;
|
||||
self.invalidate_connection(&share.id);
|
||||
Ok(ShareRecord {
|
||||
id: share.id,
|
||||
name: share.name,
|
||||
has_custom_password: share.has_custom_password,
|
||||
})
|
||||
Ok(share_record(share))
|
||||
}
|
||||
|
||||
/// Re-supply the link behind a share whose stored secret is unreadable.
|
||||
@@ -928,11 +1260,7 @@ impl AndroidEngine {
|
||||
.replace_secrets(&share_id, &url, custom_password.as_deref())
|
||||
.map_err(BridgeError::from_display)?;
|
||||
self.invalidate_connection(&share.id);
|
||||
Ok(ShareRecord {
|
||||
id: share.id,
|
||||
name: share.name,
|
||||
has_custom_password: share.has_custom_password,
|
||||
})
|
||||
Ok(share_record(share))
|
||||
}
|
||||
|
||||
pub fn remove_share(&self, share_id: String) -> Result<(), BridgeError> {
|
||||
@@ -1163,19 +1491,47 @@ impl AndroidEngine {
|
||||
.map_err(BridgeError::from_display)?
|
||||
}
|
||||
|
||||
pub fn choose_match(&self, title_key: String, found: MatchRecord) -> Result<(), BridgeError> {
|
||||
let service = self.metadata_service()?;
|
||||
let metadata = title_metadata(found);
|
||||
if metadata.provider != service.provider() {
|
||||
return Err(BridgeError::Failure {
|
||||
reason: "match came from a different metadata provider".to_owned(),
|
||||
});
|
||||
}
|
||||
self.title(&title_key)?;
|
||||
self.catalog
|
||||
.lock()
|
||||
.set_metadata(&service.chosen(title_key, metadata))
|
||||
.map_err(BridgeError::from_display)
|
||||
/// Pin a title to the entry the viewer picked, then fetch that entry's
|
||||
/// episodes, backdrop and franchise chain — the same enrichment a match run
|
||||
/// does, so the title page shows the new match's episodes as soon as this
|
||||
/// returns rather than after the next run.
|
||||
pub async fn choose_match(
|
||||
self: Arc<Self>,
|
||||
title_key: String,
|
||||
found: MatchRecord,
|
||||
) -> Result<(), BridgeError> {
|
||||
let runtime = Arc::clone(&self.runtime);
|
||||
spawned(&runtime, async move {
|
||||
let service = self.metadata_service()?;
|
||||
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> {
|
||||
@@ -1754,6 +2110,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`.
|
||||
///
|
||||
/// The cached `Connection` is dropped here rather than left to be noticed
|
||||
@@ -1867,9 +2263,11 @@ impl AndroidEngine {
|
||||
let generation = self.share_generation.load(Ordering::Acquire);
|
||||
let reusable = self.reusable_clients.lock().take().unwrap_or_default();
|
||||
|
||||
let (library, failures) = SharedLibrary::open_all_reusing(&self.store, reusable)
|
||||
.await
|
||||
.map_err(BridgeError::from_display)?;
|
||||
let account = self.account.lock().clone();
|
||||
let (library, failures) =
|
||||
SharedLibrary::open_all_reusing(&self.store, reusable, account.as_ref())
|
||||
.await
|
||||
.map_err(BridgeError::from_display)?;
|
||||
let open_failures: Vec<String> = failures
|
||||
.into_iter()
|
||||
.map(|(share, error)| format!("{}: {error}", share.name))
|
||||
@@ -1923,6 +2321,27 @@ impl AndroidEngine {
|
||||
share_id: &str,
|
||||
uid: &NodeUid,
|
||||
) -> 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
|
||||
.session_refreshed
|
||||
.lock()
|
||||
@@ -1931,15 +2350,11 @@ impl AndroidEngine {
|
||||
if stale {
|
||||
self.refresh_session(connection, share_id).await;
|
||||
}
|
||||
match connection.source.open(share_id, uid).await {
|
||||
match open().await {
|
||||
Ok(stream) => Ok(stream),
|
||||
Err(_) if !stale => {
|
||||
self.refresh_session(connection, share_id).await;
|
||||
connection
|
||||
.source
|
||||
.open(share_id, uid)
|
||||
.await
|
||||
.map_err(BridgeError::from_display)
|
||||
open().await.map_err(BridgeError::from_display)
|
||||
}
|
||||
Err(error) => Err(BridgeError::from_display(error)),
|
||||
}
|
||||
@@ -2071,9 +2486,26 @@ impl AndroidEngine {
|
||||
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 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?;
|
||||
let revision_id = stream.revision_id().to_owned();
|
||||
let block_sizes = stream.block_sizes().to_vec();
|
||||
@@ -2125,7 +2557,13 @@ impl AndroidEngine {
|
||||
let mut output = prepare_partial_file(&temporary, offset).await?;
|
||||
report(observer.as_ref(), offset, total);
|
||||
|
||||
use futures::TryStreamExt as _;
|
||||
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() {
|
||||
if cancelled(observer.as_ref()) {
|
||||
output.sync_all().await.map_err(BridgeError::from_display)?;
|
||||
@@ -2134,10 +2572,13 @@ impl AndroidEngine {
|
||||
});
|
||||
}
|
||||
let size = block_sizes[block_index];
|
||||
let bytes = stream
|
||||
.read_range(offset, size)
|
||||
let bytes = fetches
|
||||
.try_next()
|
||||
.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 {
|
||||
return Err(BridgeError::Failure {
|
||||
reason: format!(
|
||||
@@ -3368,11 +3809,12 @@ mod tests {
|
||||
assert!(engine.remove_share(share.id.clone()).is_err());
|
||||
assert_eq!(engine.share_generation.load(Ordering::Acquire), 2);
|
||||
assert!(engine.shares().expect("shares").is_empty());
|
||||
// Kept, as through a recrawl: sync would bring it back regardless.
|
||||
assert!(
|
||||
engine
|
||||
.watch_state(share.id, "episode".to_owned())
|
||||
.expect("watch state")
|
||||
.is_none()
|
||||
.is_some()
|
||||
);
|
||||
|
||||
drop(engine);
|
||||
|
||||
@@ -33,6 +33,11 @@ serde_json.workspace = true
|
||||
directories.workspace = true
|
||||
chrono.workspace = true
|
||||
souvlaki.workspace = true
|
||||
wry.workspace = true
|
||||
tao.workspace = true
|
||||
|
||||
[build-dependencies]
|
||||
embed-resource.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
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 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::playback::{Playback, PlaybackTarget};
|
||||
@@ -126,6 +127,25 @@ pub enum Action {
|
||||
url: 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.
|
||||
RemoveShare(String),
|
||||
ForgetShare(String),
|
||||
@@ -535,6 +555,8 @@ pub struct App {
|
||||
/// library means "not loaded", not "nothing in it".
|
||||
pub loaded: bool,
|
||||
pub form: ShareForm,
|
||||
/// The Proton account section of the shares page.
|
||||
pub account: ui::account::AccountPanel,
|
||||
pub playback: Option<Playback>,
|
||||
/// Whether the player page's controls are showing, and why.
|
||||
pub overlay: ui::player::Overlay,
|
||||
@@ -630,6 +652,7 @@ impl App {
|
||||
view: LibraryView::default(),
|
||||
loaded: false,
|
||||
form: ShareForm::default(),
|
||||
account: ui::account::AccountPanel::default(),
|
||||
playback: None,
|
||||
overlay: ui::player::Overlay::default(),
|
||||
up_next: UpNext::default(),
|
||||
@@ -882,6 +905,9 @@ impl App {
|
||||
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 {
|
||||
@@ -905,6 +931,23 @@ impl App {
|
||||
}
|
||||
}
|
||||
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.
|
||||
@@ -1369,6 +1412,20 @@ impl App {
|
||||
self.form.error = None;
|
||||
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::ForgetShare(id) => {
|
||||
self.confirm_remove_share = None;
|
||||
@@ -1868,6 +1925,7 @@ impl eframe::App for App {
|
||||
view,
|
||||
loaded,
|
||||
form,
|
||||
account,
|
||||
matcher,
|
||||
playback,
|
||||
opening,
|
||||
@@ -1964,7 +2022,9 @@ impl eframe::App for App {
|
||||
},
|
||||
&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 => {
|
||||
ui::settings::show(ui, settings, prefs, api_key, &mut actions)
|
||||
}
|
||||
@@ -2058,6 +2118,9 @@ impl eframe::App for App {
|
||||
self.playback = None;
|
||||
self.engine
|
||||
.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 parking_lot::Mutex;
|
||||
use pstr_core::account::{DriveEntry, DrivePlace, PendingSignIn, SignIn};
|
||||
use pstr_core::appearance::Appearance;
|
||||
use pstr_core::catalog::{
|
||||
Catalog, CatalogNode, OfflineFile, TitleTrackPrefs, WatchState, build_rows,
|
||||
@@ -34,14 +35,17 @@ use pstr_core::library::{Library, Title, TitleKind};
|
||||
use pstr_core::metadata::{EpisodeGuide, MetadataConfig, MetadataRecord, ProviderId};
|
||||
use pstr_core::prefs::PlaybackPrefs;
|
||||
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::{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 crate::desktop_prefs::{DesktopPrefs, DesktopPrefsFile};
|
||||
use pstr_stream::{
|
||||
BlockSource, DiskCacheConfig, FileBlocks, LibraryOpener, StreamConfig, StreamSource,
|
||||
VideoStream,
|
||||
BlockSource, COPY_BLOCKS_IN_FLIGHT, DiskCacheConfig, FileBlocks, LibraryOpener, StreamConfig,
|
||||
StreamSource, VideoStream,
|
||||
};
|
||||
use tokio::runtime::Runtime;
|
||||
|
||||
@@ -269,6 +273,38 @@ pub enum Event {
|
||||
DownloadProgress(Box<DownloadItem>),
|
||||
/// Completed local copies, for badges and online-only actions.
|
||||
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),
|
||||
/// Watch history did not sync, 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.
|
||||
@@ -335,6 +371,17 @@ pub struct Engine {
|
||||
desktop_file: Arc<DesktopPrefsFile>,
|
||||
/// Bumped to stop the crawls in flight; each watches it from its start.
|
||||
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>,
|
||||
ctx: egui::Context,
|
||||
}
|
||||
@@ -345,10 +392,10 @@ const THUMBNAIL_CONCURRENCY: usize = 6;
|
||||
/// How many provider lookups may be in flight at once. See [`Engine::lookups`].
|
||||
const LOOKUP_CONCURRENCY: usize = 2;
|
||||
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
|
||||
/// hide a round trip, few enough that three downloads do not swamp a player.
|
||||
const DOWNLOAD_BLOCKS_IN_FLIGHT: usize = 4;
|
||||
|
||||
/// How often watch history is synced while the app is open, on top of at
|
||||
/// launch and whenever a player stops.
|
||||
const SYNC_INTERVAL: std::time::Duration = std::time::Duration::from_secs(5 * 60);
|
||||
|
||||
/// Enrichment as currently configured.
|
||||
struct Enrichment {
|
||||
@@ -427,6 +474,11 @@ impl Engine {
|
||||
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| {
|
||||
tracing::warn!("read show track preferences: {error}");
|
||||
HashMap::new()
|
||||
@@ -449,6 +501,11 @@ impl Engine {
|
||||
download_permits: Arc::new(tokio::sync::Semaphore::new(DOWNLOAD_CONCURRENCY)),
|
||||
picture_shown: Arc::new(AtomicBool::new(false)),
|
||||
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_file: Arc::new(desktop_file),
|
||||
events,
|
||||
@@ -460,6 +517,8 @@ impl Engine {
|
||||
deferred.install_enrichment(Enrichment::build(config), true);
|
||||
});
|
||||
}
|
||||
engine.load_account();
|
||||
engine.sync_periodically();
|
||||
Ok((engine, receiver))
|
||||
}
|
||||
|
||||
@@ -591,10 +650,281 @@ impl Engine {
|
||||
};
|
||||
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,
|
||||
Err(error) => return engine.emit(Event::ShareRejected(error.to_string())),
|
||||
};
|
||||
engine.emit(Event::ShareAdded(share.name.clone()));
|
||||
engine.load_shares();
|
||||
engine.connection.lock().take();
|
||||
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).await {
|
||||
Ok(report) => {
|
||||
if report.applied > 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);
|
||||
self.runtime.block_on(async move {
|
||||
match tokio::time::timeout(timeout, sync.sync(&account, &catalog)).await {
|
||||
Ok(Ok(_)) => {}
|
||||
Ok(Err(error)) => tracing::warn!("sync watch history on exit: {error}"),
|
||||
Err(_) => tracing::warn!("sync watch history on exit: timed out"),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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.
|
||||
pub fn remove_share(&self, id: String) {
|
||||
let engine = self.clone();
|
||||
@@ -696,7 +1026,11 @@ impl Engine {
|
||||
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,
|
||||
Err(error) => {
|
||||
self.emit(Event::ConnectFailed(error.to_string()));
|
||||
@@ -1555,8 +1889,8 @@ impl Engine {
|
||||
return Ok(DownloadEnd::Cancelled);
|
||||
}
|
||||
let size = block_sizes[block_index];
|
||||
let pending =
|
||||
fetches.get_or_insert_with(|| fetch_in_order(&stream, &block_sizes, block_index));
|
||||
let pending = fetches
|
||||
.get_or_insert_with(|| stream.blocks_in_order(block_index, COPY_BLOCKS_IN_FLIGHT));
|
||||
let Some(bytes) = pending.try_next().await? else {
|
||||
anyhow::bail!("block {block_index} never arrived");
|
||||
};
|
||||
@@ -2043,26 +2377,24 @@ impl Engine {
|
||||
return engine.emit(Event::Error(engine.no_service_reason()));
|
||||
};
|
||||
let name = found.name.clone();
|
||||
let record = service.chosen(title.key.clone(), found.clone());
|
||||
if let Err(error) = engine.catalog.lock().set_metadata(&record) {
|
||||
let outcome = service.choose(&title, found).await;
|
||||
if let Some(record) = &outcome.record
|
||||
&& let Err(error) = engine.catalog.lock().set_metadata(record)
|
||||
{
|
||||
return engine.fail("store the match", error);
|
||||
}
|
||||
|
||||
// A failure leaves the title un-enriched, and the next match run
|
||||
// asks again.
|
||||
match service.enrich(&title, &found).await {
|
||||
Ok(enrichment) => {
|
||||
let stored = engine.catalog.lock().set_enrichment(
|
||||
&title.key,
|
||||
service.provider(),
|
||||
now(),
|
||||
&enrichment,
|
||||
);
|
||||
if let Err(error) = stored {
|
||||
tracing::warn!("store enrichment for {}: {error}", title.key);
|
||||
}
|
||||
if let Some(enrichment) = &outcome.enrichment {
|
||||
let stored = engine.catalog.lock().set_enrichment(
|
||||
&title.key,
|
||||
service.provider(),
|
||||
now(),
|
||||
enrichment,
|
||||
);
|
||||
if let Err(error) = stored {
|
||||
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)));
|
||||
@@ -2223,35 +2555,6 @@ fn resume_position(existing: u64, block_sizes: &[u64]) -> (usize, u64) {
|
||||
(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 {
|
||||
loop {
|
||||
let current = *command.borrow_and_update();
|
||||
|
||||
@@ -30,5 +30,6 @@ pub mod pacing;
|
||||
pub mod playback;
|
||||
pub mod theme;
|
||||
pub mod ui;
|
||||
pub mod verify;
|
||||
pub mod video;
|
||||
pub mod window;
|
||||
@@ -16,6 +16,12 @@ use anyhow::Context;
|
||||
use pstr_core::config::AppDirs;
|
||||
|
||||
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()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
|
||||
@@ -0,0 +1,602 @@
|
||||
//! 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 watch history is kept in your own Drive and every device \
|
||||
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!("Watch history did not sync: {error}"),
|
||||
(None, Some((at, report))) => sync_line(*at, report, unix_now()),
|
||||
(None, None) => "Watch history syncs with 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
|
||||
//! applies them after the frame is drawn.
|
||||
|
||||
pub mod account;
|
||||
pub mod downloads;
|
||||
pub mod history;
|
||||
pub mod library;
|
||||
|
||||
@@ -18,6 +18,7 @@ pub fn show(
|
||||
shares: &[Share],
|
||||
library: &Library,
|
||||
form: &mut ShareForm,
|
||||
account: &mut ui::account::AccountPanel,
|
||||
actions: &mut Vec<Action>,
|
||||
) {
|
||||
egui::ScrollArea::vertical()
|
||||
@@ -40,6 +41,10 @@ pub fn show(
|
||||
|
||||
ui.add_space(theme::space::XXL);
|
||||
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
|
||||
// app. The id is still a hover away for a bug report.
|
||||
let mut detail = ui::library::plural(titles, "title");
|
||||
if share.folder.is_some() {
|
||||
detail.push_str(" · from your Drive");
|
||||
}
|
||||
if share.has_custom_password {
|
||||
detail.push_str(" · password protected");
|
||||
}
|
||||
@@ -211,6 +219,7 @@ mod tests {
|
||||
name: "anime".into(),
|
||||
token: token.into(),
|
||||
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
|
||||
clap.workspace = true
|
||||
rpassword.workspace = true
|
||||
parking_lot.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
@@ -8,9 +8,12 @@
|
||||
use anyhow::{Context, Result};
|
||||
use clap::{Parser, Subcommand};
|
||||
use proton_sdk::ids::{LinkId, NodeUid, VolumeId};
|
||||
use pstr_core::account::{PlaceKind, SignIn};
|
||||
use pstr_core::catalog::{Catalog, build_rows};
|
||||
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_stream::{DiskCacheConfig, LibraryOpener, StreamConfig, StreamSource};
|
||||
|
||||
@@ -69,8 +72,38 @@ enum Command {
|
||||
#[arg(long)]
|
||||
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.
|
||||
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.
|
||||
Remove {
|
||||
/// 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 dirs = AppDirs::ensure().context("resolve app directories")?;
|
||||
let store = ShareStore::new(dirs.clone());
|
||||
let accounts = AccountStore::new(std::sync::Arc::new(KeyringSecretStore));
|
||||
|
||||
match cli.command {
|
||||
Command::Add {
|
||||
@@ -228,6 +262,80 @@ async fn run(runtime: std::sync::Arc<tokio::runtime::Runtime>) -> Result<()> {
|
||||
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).await.context("sync")?;
|
||||
println!(
|
||||
"read {} other device(s), took {} newer position(s), {}",
|
||||
report.devices,
|
||||
report.applied,
|
||||
if report.uploaded {
|
||||
"uploaded this device's history"
|
||||
} else {
|
||||
"this device's history was already up to date"
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
Command::Shares => {
|
||||
let shares = store.list().context("list shares")?;
|
||||
if shares.is_empty() {
|
||||
@@ -239,7 +347,12 @@ async fn run(runtime: std::sync::Arc<tokio::runtime::Runtime>) -> Result<()> {
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,7 +362,8 @@ async fn run(runtime: std::sync::Arc<tokio::runtime::Runtime>) -> Result<()> {
|
||||
}
|
||||
|
||||
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
|
||||
.context("open shares")?;
|
||||
for (share, error) in &failures {
|
||||
@@ -643,7 +757,10 @@ async fn open_target(
|
||||
|
||||
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
|
||||
.context("open shares")?;
|
||||
for (share, error) in &failures {
|
||||
@@ -967,7 +1084,74 @@ async fn play(
|
||||
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 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
|
||||
@@ -20,6 +20,8 @@ tracing.workspace = true
|
||||
directories.workspace = true
|
||||
rusqlite.workspace = true
|
||||
sha2.workspace = true
|
||||
parking_lot.workspace = true
|
||||
getrandom.workspace = true
|
||||
|
||||
# The credential store, per platform. Without the target's feature `keyring`
|
||||
# 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -513,19 +513,17 @@ impl Catalog {
|
||||
Ok(rows.collect::<std::result::Result<_, _>>()?)
|
||||
}
|
||||
|
||||
/// Drop everything a share contributed, watch positions included.
|
||||
/// Drop everything a share contributed except where it was watched.
|
||||
///
|
||||
/// Unlike a recrawl — which replaces rows and deliberately leaves watch
|
||||
/// state alone — this is the viewer saying they are done with the share.
|
||||
/// Keeping positions for files they can no longer reach would only leave
|
||||
/// the "continue watching" row pointing at nothing.
|
||||
/// Watch state stays, as through a recrawl. It is history, and not only
|
||||
/// this device's: with an account signed in, the next sync would bring it
|
||||
/// straight back from the other devices' files, so deleting it here would
|
||||
/// 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.
|
||||
pub fn remove_share(&mut self, share_id: &str) -> Result<()> {
|
||||
let tx = self.conn.transaction()?;
|
||||
tx.execute("DELETE FROM nodes WHERE share_id = ?1", params![share_id])?;
|
||||
tx.execute(
|
||||
"DELETE FROM watch_state WHERE share_id = ?1",
|
||||
params![share_id],
|
||||
)?;
|
||||
tx.execute(
|
||||
"DELETE FROM offline_files WHERE share_id = ?1",
|
||||
params![share_id],
|
||||
@@ -721,6 +719,46 @@ impl Catalog {
|
||||
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.
|
||||
pub fn watch_state(&self, share_id: &str, link_id: &str) -> Result<Option<WatchState>> {
|
||||
let state = self
|
||||
@@ -2390,6 +2428,101 @@ mod tests {
|
||||
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 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]
|
||||
fn an_unwatched_file_has_no_watch_state() {
|
||||
let catalog = Catalog::in_memory().expect("open");
|
||||
|
||||
@@ -88,6 +88,12 @@ impl AppDirs {
|
||||
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.
|
||||
pub fn catalog_db(&self) -> PathBuf {
|
||||
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.
|
||||
#[error("{0}")]
|
||||
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"))]
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
//! how a share is opened and how its contents are modelled.
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod account;
|
||||
pub mod appearance;
|
||||
pub mod browse;
|
||||
pub mod catalog;
|
||||
@@ -18,11 +19,13 @@ pub mod naming;
|
||||
pub mod prefs;
|
||||
pub mod shares;
|
||||
pub mod shelves;
|
||||
pub mod sync;
|
||||
|
||||
pub use account::{Account, AccountStore};
|
||||
pub use error::{Error, Result};
|
||||
#[cfg(not(target_os = "android"))]
|
||||
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_sdk;
|
||||
@@ -14,15 +14,28 @@
|
||||
//! 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
|
||||
//! 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::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::ids::NodeUid;
|
||||
use proton_sdk::ids::{LinkId, NodeUid, VolumeId};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::account::Account;
|
||||
use crate::config::{AppDirs, read_json, write_json};
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
@@ -42,19 +55,26 @@ pub trait SecretStore: Send + Sync {
|
||||
}
|
||||
|
||||
/// The desktop OS credential store.
|
||||
///
|
||||
/// A value too long for one entry is split across several; see
|
||||
/// [`ENTRY_UNITS`].
|
||||
#[cfg(not(target_os = "android"))]
|
||||
#[derive(Debug, Default)]
|
||||
pub struct KeyringSecretStore;
|
||||
|
||||
/// One credential-store entry per key, with no splitting.
|
||||
#[cfg(not(target_os = "android"))]
|
||||
impl KeyringSecretStore {
|
||||
struct KeyringEntries;
|
||||
|
||||
#[cfg(not(target_os = "android"))]
|
||||
impl KeyringEntries {
|
||||
fn entry(key: &str) -> Result<keyring::Entry> {
|
||||
Ok(keyring::Entry::new(KEYRING_SERVICE, key)?)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "android"))]
|
||||
impl SecretStore for KeyringSecretStore {
|
||||
impl SecretStore for KeyringEntries {
|
||||
fn set(&self, key: &str, value: &str) -> Result<()> {
|
||||
Self::entry(key)?.set_password(value)?;
|
||||
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.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Share {
|
||||
@@ -92,6 +311,47 @@ pub struct Share {
|
||||
/// rather than after it fails.
|
||||
#[serde(default)]
|
||||
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.
|
||||
@@ -154,6 +414,31 @@ impl ShareStore {
|
||||
name: name.to_string(),
|
||||
token,
|
||||
has_custom_password: secrets.custom_password.is_some(),
|
||||
folder: None,
|
||||
};
|
||||
shares.push(share.clone());
|
||||
write_json(&self.dirs.shares_file(), &shares)?;
|
||||
Ok(share)
|
||||
}
|
||||
|
||||
/// 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());
|
||||
write_json(&self.dirs.shares_file(), &shares)?;
|
||||
@@ -184,6 +469,11 @@ impl ShareStore {
|
||||
.iter_mut()
|
||||
.find(|share| share.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 {
|
||||
return Err(Error::Config(
|
||||
"that link is for a different share; add it as a new one".to_owned(),
|
||||
@@ -221,8 +511,20 @@ impl ShareStore {
|
||||
self.secrets.delete(id)
|
||||
}
|
||||
|
||||
/// Open a share as a visitor.
|
||||
pub async fn open(&self, share: &Share) -> Result<ProtonDrivePublicLinkClient> {
|
||||
/// Open a share: a link as a visitor, a folder through `account`.
|
||||
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 client = ProtonDrivePublicLinkClient::open(
|
||||
client_configuration(),
|
||||
@@ -234,7 +536,7 @@ impl ShareStore {
|
||||
// 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)
|
||||
Ok(ShareClient::PublicLink(Box::new(client)))
|
||||
}
|
||||
|
||||
fn store_secrets(&self, id: &str, secrets: &ShareSecrets) -> Result<()> {
|
||||
@@ -256,7 +558,96 @@ impl ShareStore {
|
||||
|
||||
/// Several opened shares, presented as one library.
|
||||
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 {
|
||||
@@ -265,8 +656,14 @@ impl SharedLibrary {
|
||||
/// 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
|
||||
/// 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`.
|
||||
@@ -278,7 +675,8 @@ impl SharedLibrary {
|
||||
/// link.
|
||||
pub async fn open_all_reusing(
|
||||
store: &ShareStore,
|
||||
mut reusable: BTreeMap<String, ProtonDrivePublicLinkClient>,
|
||||
mut reusable: BTreeMap<String, ShareClient>,
|
||||
account: Option<&Account>,
|
||||
) -> Result<(Self, Vec<(Share, Error)>)> {
|
||||
let mut clients = BTreeMap::new();
|
||||
let mut failures = Vec::new();
|
||||
@@ -288,7 +686,7 @@ impl SharedLibrary {
|
||||
clients.insert(share.id, client);
|
||||
continue;
|
||||
}
|
||||
match store.open(&share).await {
|
||||
match store.open(&share, account).await {
|
||||
Ok(client) => {
|
||||
clients.insert(share.id.clone(), client);
|
||||
}
|
||||
@@ -305,19 +703,22 @@ impl SharedLibrary {
|
||||
/// The excluded share is the one whose credentials or membership just
|
||||
/// changed; leaving it out is what makes a removed share's live client
|
||||
/// unreachable rather than merely stale.
|
||||
pub fn reusable_clients(
|
||||
&self,
|
||||
dropping: &str,
|
||||
) -> BTreeMap<String, ProtonDrivePublicLinkClient> {
|
||||
///
|
||||
/// Folders of the account are never handed back: they carry the session
|
||||
/// they were opened with, and the account may have signed out or changed
|
||||
/// since.
|
||||
pub fn reusable_clients(&self, dropping: &str) -> BTreeMap<String, ShareClient> {
|
||||
self.clients
|
||||
.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()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
@@ -377,7 +778,7 @@ impl SharedLibrary {
|
||||
.client(share_id)
|
||||
.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 found = vec![root];
|
||||
|
||||
@@ -433,7 +834,7 @@ const APP_VERSION: &str = concat!("external-drive-stream@", env!("CARGO_PKG_VERS
|
||||
const USER_AGENT: &str = concat!("proton-stream/", env!("CARGO_PKG_VERSION"));
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,966 @@
|
||||
//! Watch history kept in the viewer's own Drive, so every device resumes where
|
||||
//! another left off.
|
||||
//!
|
||||
//! ## Layout
|
||||
//!
|
||||
//! The history lives on a Drive *device* of its own, named [`DEVICE_NAME`]: the
|
||||
//! same kind of entry a desktop sync client registers, so it shows up in the
|
||||
//! web app under Computers rather than as a stray folder in My Files. Inside
|
||||
//! it, `watch-history/` holds one JSON file per installation of this app,
|
||||
//! named by an id generated once and kept in [`AppDirs::sync_file`].
|
||||
//!
|
||||
//! ## Why a file per installation
|
||||
//!
|
||||
//! Drive has no merge: two devices writing one file is last writer wins for
|
||||
//! the *whole file*, and the loser's positions are gone. With a file each,
|
||||
//! every installation only ever writes its own and reads everyone else's, so
|
||||
//! nothing is overwritten and there is no write conflict to resolve.
|
||||
//!
|
||||
//! Each file carries the installation's whole watch table, and merging is per
|
||||
//! episode: the newer `updated_at` wins. That is idempotent and order-free, so
|
||||
//! it does not matter how many devices there are, which synced first, or that
|
||||
//! a device writes back entries it learned from another. A device whose clock
|
||||
//! runs ahead wins ties it should not, which is the price of not having a
|
||||
//! server.
|
||||
//!
|
||||
//! The keys travel: `share-{token}` is derived from the link and a folder's
|
||||
//! `drive-…` id from its node, so the same library added on two devices has
|
||||
//! the same `(share_id, link_id)` on both. Nothing secret is in a file — the
|
||||
//! link token decrypts nothing without the fragment — and the file is
|
||||
//! end-to-end encrypted by Drive regardless.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use parking_lot::Mutex;
|
||||
use proton_drive_rs::{DeviceType, NodeKind, ProtonDriveClient};
|
||||
use proton_sdk::ids::NodeUid;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::account::Account;
|
||||
use crate::catalog::{Catalog, WatchState};
|
||||
use crate::config::{AppDirs, read_json, write_json};
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
/// The Drive device watch history is kept on.
|
||||
pub const DEVICE_NAME: &str = "proton-stream";
|
||||
|
||||
/// The folder on that device holding one file per installation.
|
||||
const FOLDER: &str = "watch-history";
|
||||
|
||||
/// Bumped only for a change an older build would misread. A file of a newer
|
||||
/// version is skipped rather than half-understood.
|
||||
const FORMAT_VERSION: u32 = 1;
|
||||
|
||||
/// One installation's watch history, as stored in Drive.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
struct HistoryFile {
|
||||
version: u32,
|
||||
installation: String,
|
||||
entries: Vec<HistoryEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
struct HistoryEntry {
|
||||
share: String,
|
||||
link: String,
|
||||
position: f64,
|
||||
#[serde(default)]
|
||||
duration: Option<f64>,
|
||||
watched: bool,
|
||||
updated_at: i64,
|
||||
}
|
||||
|
||||
impl HistoryEntry {
|
||||
fn from_state((share, link): (String, String), state: WatchState) -> Self {
|
||||
Self {
|
||||
share,
|
||||
link,
|
||||
position: state.position_secs,
|
||||
duration: state.duration_secs,
|
||||
watched: state.watched,
|
||||
updated_at: state.updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
fn state(&self) -> WatchState {
|
||||
WatchState {
|
||||
position_secs: self.position,
|
||||
duration_secs: self.duration,
|
||||
watched: self.watched,
|
||||
updated_at: self.updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this entry could have come from this app. A file is another
|
||||
/// device's to write, so its contents are checked before they reach the
|
||||
/// catalog.
|
||||
fn is_sane(&self) -> bool {
|
||||
self.position.is_finite()
|
||||
&& self.position >= 0.0
|
||||
&& self
|
||||
.duration
|
||||
.is_none_or(|duration| duration.is_finite() && duration >= 0.0)
|
||||
&& !self.share.is_empty()
|
||||
&& !self.link.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// This installation's sync identity, kept beside the share list.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct Identity {
|
||||
installation: String,
|
||||
}
|
||||
|
||||
/// What one [`WatchSync::sync`] did.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub struct SyncReport {
|
||||
/// Episodes whose position another device had newer.
|
||||
pub applied: usize,
|
||||
/// Whether this installation's file was written.
|
||||
pub uploaded: bool,
|
||||
/// Other installations whose history was read.
|
||||
pub devices: usize,
|
||||
/// Files of installations gone quiet that were moved to the trash, their
|
||||
/// history now carried in this installation's own file.
|
||||
pub retired: usize,
|
||||
}
|
||||
|
||||
/// How long an installation's file may go unwritten before another
|
||||
/// installation retires it.
|
||||
///
|
||||
/// Reinstalling — routine on Android, where uninstalling wipes the config —
|
||||
/// starts a new id, and every file ever written would otherwise be read by
|
||||
/// every device forever. Retiring is safe at any age: a file is only trashed
|
||||
/// after its entries were merged and this installation's file, which then
|
||||
/// holds them too, is written. An installation that was merely idle finds its
|
||||
/// file gone on its next sync and writes it again.
|
||||
const RETIRE_AFTER_SECS: i64 = 90 * 24 * 60 * 60;
|
||||
|
||||
/// One history file in Drive, as listed.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct RemoteFile {
|
||||
pub uid: NodeUid,
|
||||
pub name: String,
|
||||
/// The active revision, which moves with every write.
|
||||
pub revision: Option<String>,
|
||||
/// Last modification, epoch seconds.
|
||||
pub modified: i64,
|
||||
}
|
||||
|
||||
/// The few Drive operations sync needs: the account's Drive in the app, an
|
||||
/// in-memory one in the tests.
|
||||
pub(crate) trait HistoryDrive: Sync {
|
||||
/// The history folders, creating the device and folder the first time.
|
||||
/// See [`WatchSync::folders`].
|
||||
fn history_folders(&self) -> impl Future<Output = Result<Vec<NodeUid>>> + Send;
|
||||
/// The untrashed `.json` files directly in `folder`.
|
||||
fn list(&self, folder: &NodeUid) -> impl Future<Output = Result<Vec<RemoteFile>>> + Send;
|
||||
fn download(&self, uid: &NodeUid) -> impl Future<Output = Result<Vec<u8>>> + Send;
|
||||
fn create(
|
||||
&self,
|
||||
folder: &NodeUid,
|
||||
name: &str,
|
||||
bytes: &[u8],
|
||||
) -> impl Future<Output = Result<()>> + Send;
|
||||
fn replace(&self, uid: &NodeUid, bytes: &[u8]) -> impl Future<Output = Result<()>> + Send;
|
||||
fn trash(&self, uids: &[NodeUid]) -> impl Future<Output = Result<()>> + Send;
|
||||
}
|
||||
|
||||
impl HistoryDrive for ProtonDriveClient {
|
||||
/// Two installations signing in at the same moment can each create the
|
||||
/// device before seeing the other's. Every device of that name is read
|
||||
/// from so neither's history is lost; the oldest is the one written to,
|
||||
/// which is the same one on every installation.
|
||||
async fn history_folders(&self) -> Result<Vec<NodeUid>> {
|
||||
let mut devices: Vec<_> = self
|
||||
.enumerate_devices()
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter(|device| device.name.as_deref().ok() == Some(DEVICE_NAME))
|
||||
.collect();
|
||||
devices.sort_by_key(|device| device.creation_time);
|
||||
if devices.is_empty() {
|
||||
devices.push(self.create_device(DEVICE_NAME, device_type()).await?);
|
||||
}
|
||||
let mut folders = Vec::with_capacity(devices.len());
|
||||
for device in &devices {
|
||||
folders.push(
|
||||
self.create_folder_path(&device.root_folder_uid, FOLDER)
|
||||
.await?,
|
||||
);
|
||||
}
|
||||
Ok(folders)
|
||||
}
|
||||
|
||||
async fn list(&self, folder: &NodeUid) -> Result<Vec<RemoteFile>> {
|
||||
let uids = self.enumerate_folder_children_node_uids(folder).await?;
|
||||
if uids.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
Ok(self
|
||||
.enumerate_nodes(&uids)
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter_map(|node| {
|
||||
let NodeKind::File {
|
||||
active_revision_id, ..
|
||||
} = node.kind
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
(!node.trashed && node.name.ends_with(".json")).then_some(RemoteFile {
|
||||
uid: node.uid,
|
||||
name: node.name,
|
||||
revision: active_revision_id,
|
||||
modified: node.modification_time,
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn download(&self, uid: &NodeUid) -> Result<Vec<u8>> {
|
||||
Ok(self.download_file(uid).await?)
|
||||
}
|
||||
|
||||
async fn create(&self, folder: &NodeUid, name: &str, bytes: &[u8]) -> Result<()> {
|
||||
self.upload_file(folder, name, "application/json", bytes)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn replace(&self, uid: &NodeUid, bytes: &[u8]) -> Result<()> {
|
||||
Ok(self.upload_new_revision(uid, bytes).await?)
|
||||
}
|
||||
|
||||
async fn trash(&self, uids: &[NodeUid]) -> Result<()> {
|
||||
for (uid, outcome) in self.trash_nodes(uids).await? {
|
||||
if let Err(error) = outcome {
|
||||
tracing::warn!(%uid, %error, "a retired watch-history file was not trashed");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// A history file as last read, by the revision it was read at. `None`
|
||||
/// entries for one this build could not read.
|
||||
struct Seen {
|
||||
revision: Option<String>,
|
||||
entries: Option<Vec<HistoryEntry>>,
|
||||
}
|
||||
|
||||
/// Syncs the catalog's watch table with the account's Drive.
|
||||
pub struct WatchSync {
|
||||
installation: String,
|
||||
/// Remote files by link id. A file whose active revision has not moved
|
||||
/// since it was read is not downloaded again.
|
||||
seen: Mutex<HashMap<String, Seen>>,
|
||||
/// What this installation's own file holds, once known: what was read
|
||||
/// back at the first sync, then what was last written. Compared against
|
||||
/// the catalog so an unchanged history is not uploaded as a new revision.
|
||||
own: Mutex<Option<Vec<HistoryEntry>>>,
|
||||
/// The history folders, resolved once per account. Usually one; see
|
||||
/// [`HistoryDrive::history_folders`].
|
||||
folders: Mutex<Option<Vec<NodeUid>>>,
|
||||
/// One sync at a time: two would race on creating the device and the
|
||||
/// installation's file.
|
||||
running: tokio::sync::Mutex<()>,
|
||||
}
|
||||
|
||||
impl WatchSync {
|
||||
/// This installation's sync, with its id read from — or, the first time,
|
||||
/// written to — [`AppDirs::sync_file`].
|
||||
pub fn open(dirs: &AppDirs) -> Result<Self> {
|
||||
let path = dirs.sync_file();
|
||||
let installation = match read_json::<Identity>(&path)? {
|
||||
Some(identity) => identity.installation,
|
||||
None => {
|
||||
let identity = Identity {
|
||||
installation: new_installation_id()?,
|
||||
};
|
||||
write_json(&path, &identity)?;
|
||||
identity.installation
|
||||
}
|
||||
};
|
||||
Ok(Self::with_installation(installation))
|
||||
}
|
||||
|
||||
fn with_installation(installation: String) -> Self {
|
||||
Self {
|
||||
installation,
|
||||
seen: Mutex::new(HashMap::new()),
|
||||
own: Mutex::new(None),
|
||||
folders: Mutex::new(None),
|
||||
running: tokio::sync::Mutex::new(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Forget everything learned about the account, after signing out or in
|
||||
/// as someone else.
|
||||
pub fn reset(&self) {
|
||||
self.seen.lock().clear();
|
||||
*self.own.lock() = None;
|
||||
*self.folders.lock() = None;
|
||||
}
|
||||
|
||||
/// Pull every other installation's history into `catalog`, write this
|
||||
/// one's if it changed, then retire the files of installations gone quiet.
|
||||
///
|
||||
/// The catalog is locked only around its own reads and writes, never
|
||||
/// across a request, so playback can keep saving its position while this
|
||||
/// runs.
|
||||
pub async fn sync(&self, account: &Account, catalog: &Mutex<Catalog>) -> Result<SyncReport> {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |since| since.as_secs() as i64);
|
||||
self.sync_with(account.drive(), catalog, now).await
|
||||
}
|
||||
|
||||
pub(crate) async fn sync_with<D: HistoryDrive>(
|
||||
&self,
|
||||
drive: &D,
|
||||
catalog: &Mutex<Catalog>,
|
||||
now: i64,
|
||||
) -> Result<SyncReport> {
|
||||
let _running = self.running.lock().await;
|
||||
let folders = self.folders(drive).await?;
|
||||
let own_name = format!("{}.json", self.installation);
|
||||
|
||||
let mut own_file = None;
|
||||
let mut others = Vec::new();
|
||||
for folder in &folders {
|
||||
for file in drive.list(folder).await? {
|
||||
if file.name == own_name {
|
||||
own_file.get_or_insert(file);
|
||||
} else {
|
||||
others.push(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut remote = Vec::new();
|
||||
// Files whose every entry has been read, and so can be retired once
|
||||
// this installation's file carries them.
|
||||
let mut readable = Vec::new();
|
||||
for file in &others {
|
||||
if let Some(entries) = self.read(drive, file).await {
|
||||
remote.extend(entries);
|
||||
readable.push(file);
|
||||
}
|
||||
}
|
||||
|
||||
// What this installation wrote before is history too: after a
|
||||
// reinstall that kept the config, or a catalog that was deleted. One
|
||||
// that cannot be read now is left alone rather than overwritten with
|
||||
// less than it holds, and read again next time.
|
||||
let mut own_unreadable = false;
|
||||
if self.own.lock().is_none() {
|
||||
let entries = match &own_file {
|
||||
Some(file) => self.read(drive, file).await,
|
||||
None => Some(Vec::new()),
|
||||
};
|
||||
match entries {
|
||||
Some(entries) => {
|
||||
remote.extend(entries.iter().cloned());
|
||||
*self.own.lock() = Some(entries);
|
||||
}
|
||||
None => own_unreadable = true,
|
||||
}
|
||||
}
|
||||
|
||||
let incoming: Vec<(String, String, WatchState)> = remote
|
||||
.iter()
|
||||
.filter(|entry| entry.is_sane())
|
||||
.map(|entry| (entry.share.clone(), entry.link.clone(), entry.state()))
|
||||
.collect();
|
||||
let (applied, local) = {
|
||||
let mut catalog = catalog.lock();
|
||||
let applied = catalog.merge_watch_states(&incoming)?;
|
||||
(applied, catalog.all_watch_states()?)
|
||||
};
|
||||
|
||||
let mut entries: Vec<HistoryEntry> = local
|
||||
.into_iter()
|
||||
.map(|(key, state)| HistoryEntry::from_state(key, state))
|
||||
.collect();
|
||||
entries.sort_by(|a, b| (&a.share, &a.link).cmp(&(&b.share, &b.link)));
|
||||
|
||||
// A file that is missing is written even when what it would hold has
|
||||
// not changed: another installation retired it, or it was deleted by
|
||||
// hand, and either way the history it carried lives only here now.
|
||||
let unchanged = own_file.is_some() && self.own.lock().as_ref() == Some(&entries);
|
||||
let uploaded = if own_unreadable || unchanged || (entries.is_empty() && own_file.is_none())
|
||||
{
|
||||
false
|
||||
} else {
|
||||
let file = HistoryFile {
|
||||
version: FORMAT_VERSION,
|
||||
installation: self.installation.clone(),
|
||||
entries: entries.clone(),
|
||||
};
|
||||
let bytes = serde_json::to_vec(&file)
|
||||
.map_err(|e| Error::Config(format!("serialize watch history: {e}")))?;
|
||||
match &own_file {
|
||||
Some(file) => drive.replace(&file.uid, &bytes).await?,
|
||||
None => drive.create(&folders[0], &own_name, &bytes).await?,
|
||||
}
|
||||
*self.own.lock() = Some(entries);
|
||||
true
|
||||
};
|
||||
|
||||
// Only now: every entry of a retired file was merged above, and this
|
||||
// installation's file — just written, or already up to date — holds
|
||||
// the merged table.
|
||||
let retired: Vec<NodeUid> = readable
|
||||
.into_iter()
|
||||
.filter(|file| !own_unreadable && now - file.modified >= RETIRE_AFTER_SECS)
|
||||
.map(|file| file.uid.clone())
|
||||
.collect();
|
||||
if !retired.is_empty() {
|
||||
match drive.trash(&retired).await {
|
||||
Ok(()) => {
|
||||
let mut seen = self.seen.lock();
|
||||
for uid in &retired {
|
||||
seen.remove(uid.link_id.as_str());
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "retiring old watch-history files failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(SyncReport {
|
||||
applied,
|
||||
uploaded,
|
||||
devices: others.len(),
|
||||
retired: retired.len(),
|
||||
})
|
||||
}
|
||||
|
||||
/// The entries of one history file, from the cache when its revision has
|
||||
/// not moved, or `None` when it cannot be read.
|
||||
///
|
||||
/// A file that will not download or parse is logged and skipped: it is
|
||||
/// another device's, and one bad file must not stop the rest syncing.
|
||||
async fn read<D: HistoryDrive>(
|
||||
&self,
|
||||
drive: &D,
|
||||
file: &RemoteFile,
|
||||
) -> Option<Vec<HistoryEntry>> {
|
||||
let key = file.uid.link_id.as_str().to_owned();
|
||||
if let Some(seen) = self.seen.lock().get(&key)
|
||||
&& file.revision.is_some()
|
||||
&& seen.revision == file.revision
|
||||
{
|
||||
return seen.entries.clone();
|
||||
}
|
||||
let uid = &file.uid;
|
||||
let entries = match drive.download(uid).await {
|
||||
Ok(bytes) => {
|
||||
let entries = parse(&bytes);
|
||||
if entries.is_none() {
|
||||
tracing::warn!(%uid, "skipping a watch-history file this build cannot read");
|
||||
}
|
||||
entries
|
||||
}
|
||||
Err(error) => {
|
||||
// Not cached: a download that failed is tried again next time.
|
||||
tracing::warn!(%uid, %error, "a watch-history file could not be downloaded");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
self.seen.lock().insert(
|
||||
key,
|
||||
Seen {
|
||||
revision: file.revision.clone(),
|
||||
entries: entries.clone(),
|
||||
},
|
||||
);
|
||||
entries
|
||||
}
|
||||
|
||||
/// The history folders, resolved once per account.
|
||||
async fn folders<D: HistoryDrive>(&self, drive: &D) -> Result<Vec<NodeUid>> {
|
||||
if let Some(folders) = self.folders.lock().clone() {
|
||||
return Ok(folders);
|
||||
}
|
||||
let folders = drive.history_folders().await?;
|
||||
*self.folders.lock() = Some(folders.clone());
|
||||
Ok(folders)
|
||||
}
|
||||
}
|
||||
|
||||
/// A history file's entries, or `None` for one this build cannot read.
|
||||
fn parse(bytes: &[u8]) -> Option<Vec<HistoryEntry>> {
|
||||
let file: HistoryFile = serde_json::from_slice(bytes).ok()?;
|
||||
(file.version <= FORMAT_VERSION).then_some(file.entries)
|
||||
}
|
||||
|
||||
/// The kind of device to register, which is only the icon the web app draws.
|
||||
fn device_type() -> DeviceType {
|
||||
if cfg!(target_os = "windows") {
|
||||
DeviceType::Windows
|
||||
} else if cfg!(target_os = "macos") {
|
||||
DeviceType::MacOs
|
||||
} else {
|
||||
DeviceType::Linux
|
||||
}
|
||||
}
|
||||
|
||||
fn new_installation_id() -> Result<String> {
|
||||
let mut bytes = [0_u8; 16];
|
||||
getrandom::fill(&mut bytes)
|
||||
.map_err(|e| Error::Config(format!("no randomness for an installation id: {e}")))?;
|
||||
Ok(bytes.iter().map(|byte| format!("{byte:02x}")).collect())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn entry(link: &str, position: f64, updated_at: i64) -> HistoryEntry {
|
||||
HistoryEntry {
|
||||
share: "share-a".to_owned(),
|
||||
link: link.to_owned(),
|
||||
position,
|
||||
duration: Some(1440.0),
|
||||
watched: false,
|
||||
updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
/// One history folder in memory, standing in for the account's Drive.
|
||||
#[derive(Default)]
|
||||
struct FakeDrive {
|
||||
files: Mutex<Vec<FakeFile>>,
|
||||
/// What the next write is stamped with, epoch seconds.
|
||||
clock: std::sync::atomic::AtomicI64,
|
||||
/// Downloads fail while set, as on a dropped connection.
|
||||
offline: std::sync::atomic::AtomicBool,
|
||||
uploads: std::sync::atomic::AtomicUsize,
|
||||
}
|
||||
|
||||
struct FakeFile {
|
||||
uid: NodeUid,
|
||||
name: String,
|
||||
bytes: Vec<u8>,
|
||||
revision: u32,
|
||||
modified: i64,
|
||||
trashed: bool,
|
||||
}
|
||||
|
||||
fn uid(link: &str) -> NodeUid {
|
||||
NodeUid::new("volume".to_owned().into(), link.to_owned().into())
|
||||
}
|
||||
|
||||
impl FakeDrive {
|
||||
fn at(&self, now: i64) {
|
||||
self.clock.store(now, std::sync::atomic::Ordering::SeqCst);
|
||||
}
|
||||
|
||||
fn names(&self) -> Vec<String> {
|
||||
let mut names: Vec<_> = self
|
||||
.files
|
||||
.lock()
|
||||
.iter()
|
||||
.filter(|file| !file.trashed)
|
||||
.map(|file| file.name.clone())
|
||||
.collect();
|
||||
names.sort();
|
||||
names
|
||||
}
|
||||
|
||||
fn uploads(&self) -> usize {
|
||||
self.uploads.load(std::sync::atomic::Ordering::SeqCst)
|
||||
}
|
||||
|
||||
fn put(&self, name: &str, bytes: Vec<u8>) {
|
||||
let modified = self.clock.load(std::sync::atomic::Ordering::SeqCst);
|
||||
let mut files = self.files.lock();
|
||||
let link = format!("file-{}", files.len());
|
||||
files.push(FakeFile {
|
||||
uid: uid(&link),
|
||||
name: name.to_owned(),
|
||||
bytes,
|
||||
revision: 1,
|
||||
modified,
|
||||
trashed: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl HistoryDrive for FakeDrive {
|
||||
async fn history_folders(&self) -> Result<Vec<NodeUid>> {
|
||||
Ok(vec![uid("folder")])
|
||||
}
|
||||
|
||||
async fn list(&self, _folder: &NodeUid) -> Result<Vec<RemoteFile>> {
|
||||
Ok(self
|
||||
.files
|
||||
.lock()
|
||||
.iter()
|
||||
.filter(|file| !file.trashed)
|
||||
.map(|file| RemoteFile {
|
||||
uid: file.uid.clone(),
|
||||
name: file.name.clone(),
|
||||
revision: Some(file.revision.to_string()),
|
||||
modified: file.modified,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn download(&self, uid: &NodeUid) -> Result<Vec<u8>> {
|
||||
if self.offline.load(std::sync::atomic::Ordering::SeqCst) {
|
||||
return Err(Error::NotFound("offline".to_owned()));
|
||||
}
|
||||
self.files
|
||||
.lock()
|
||||
.iter()
|
||||
.find(|file| &file.uid == uid)
|
||||
.map(|file| file.bytes.clone())
|
||||
.ok_or_else(|| Error::NotFound(uid.to_string()))
|
||||
}
|
||||
|
||||
async fn create(&self, _folder: &NodeUid, name: &str, bytes: &[u8]) -> Result<()> {
|
||||
self.uploads
|
||||
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
self.put(name, bytes.to_vec());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn replace(&self, uid: &NodeUid, bytes: &[u8]) -> Result<()> {
|
||||
self.uploads
|
||||
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
let modified = self.clock.load(std::sync::atomic::Ordering::SeqCst);
|
||||
let mut files = self.files.lock();
|
||||
let file = files
|
||||
.iter_mut()
|
||||
.find(|file| &file.uid == uid)
|
||||
.ok_or_else(|| Error::NotFound(uid.to_string()))?;
|
||||
file.bytes = bytes.to_vec();
|
||||
file.revision += 1;
|
||||
file.modified = modified;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn trash(&self, uids: &[NodeUid]) -> Result<()> {
|
||||
for file in self.files.lock().iter_mut() {
|
||||
if uids.contains(&file.uid) {
|
||||
file.trashed = true;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// An installation: its sync and its catalog.
|
||||
fn device(id: &str) -> (WatchSync, Mutex<Catalog>) {
|
||||
(
|
||||
WatchSync::with_installation(id.to_owned()),
|
||||
Mutex::new(Catalog::in_memory().unwrap()),
|
||||
)
|
||||
}
|
||||
|
||||
fn watched_at(position_secs: f64, updated_at: i64) -> WatchState {
|
||||
WatchState {
|
||||
position_secs,
|
||||
duration_secs: Some(1440.0),
|
||||
watched: false,
|
||||
updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
const DAY: i64 = 24 * 60 * 60;
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_position_saved_on_one_device_resumes_on_another() {
|
||||
let drive = FakeDrive::default();
|
||||
let (laptop, laptop_catalog) = device("laptop");
|
||||
let (phone, phone_catalog) = device("phone");
|
||||
laptop_catalog
|
||||
.lock()
|
||||
.set_watch_state("share-a", "ep1", &watched_at(600.0, 100))
|
||||
.unwrap();
|
||||
|
||||
let first = laptop
|
||||
.sync_with(&drive, &laptop_catalog, 100)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(first.uploaded);
|
||||
let second = phone.sync_with(&drive, &phone_catalog, 110).await.unwrap();
|
||||
|
||||
assert_eq!(second.applied, 1);
|
||||
assert_eq!(second.devices, 1);
|
||||
assert_eq!(
|
||||
phone_catalog.lock().watch_state("share-a", "ep1").unwrap(),
|
||||
Some(watched_at(600.0, 100))
|
||||
);
|
||||
assert_eq!(drive.names(), ["laptop.json", "phone.json"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_newer_position_wins_whichever_device_syncs_first() {
|
||||
let drive = FakeDrive::default();
|
||||
let (laptop, laptop_catalog) = device("laptop");
|
||||
let (phone, phone_catalog) = device("phone");
|
||||
laptop_catalog
|
||||
.lock()
|
||||
.set_watch_state("share-a", "ep1", &watched_at(300.0, 100))
|
||||
.unwrap();
|
||||
phone_catalog
|
||||
.lock()
|
||||
.set_watch_state("share-a", "ep1", &watched_at(900.0, 200))
|
||||
.unwrap();
|
||||
|
||||
laptop
|
||||
.sync_with(&drive, &laptop_catalog, 300)
|
||||
.await
|
||||
.unwrap();
|
||||
phone.sync_with(&drive, &phone_catalog, 300).await.unwrap();
|
||||
laptop
|
||||
.sync_with(&drive, &laptop_catalog, 300)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
for catalog in [&laptop_catalog, &phone_catalog] {
|
||||
assert_eq!(
|
||||
catalog.lock().watch_state("share-a", "ep1").unwrap(),
|
||||
Some(watched_at(900.0, 200))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_unchanged_history_is_not_uploaded_again() {
|
||||
let drive = FakeDrive::default();
|
||||
let (laptop, catalog) = device("laptop");
|
||||
catalog
|
||||
.lock()
|
||||
.set_watch_state("share-a", "ep1", &watched_at(60.0, 100))
|
||||
.unwrap();
|
||||
|
||||
laptop.sync_with(&drive, &catalog, 100).await.unwrap();
|
||||
let again = laptop.sync_with(&drive, &catalog, 200).await.unwrap();
|
||||
|
||||
assert!(!again.uploaded);
|
||||
assert_eq!(drive.uploads(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn nothing_is_uploaded_before_anything_was_watched() {
|
||||
let drive = FakeDrive::default();
|
||||
let (laptop, catalog) = device("laptop");
|
||||
let report = laptop.sync_with(&drive, &catalog, 100).await.unwrap();
|
||||
assert!(!report.uploaded);
|
||||
assert!(drive.names().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_file_that_went_missing_is_written_again() {
|
||||
let drive = FakeDrive::default();
|
||||
let (laptop, catalog) = device("laptop");
|
||||
catalog
|
||||
.lock()
|
||||
.set_watch_state("share-a", "ep1", &watched_at(60.0, 100))
|
||||
.unwrap();
|
||||
laptop.sync_with(&drive, &catalog, 100).await.unwrap();
|
||||
|
||||
let own: Vec<NodeUid> = drive.files.lock().iter().map(|f| f.uid.clone()).collect();
|
||||
drive.trash(&own).await.unwrap();
|
||||
let report = laptop.sync_with(&drive, &catalog, 200).await.unwrap();
|
||||
|
||||
assert!(report.uploaded);
|
||||
assert_eq!(drive.names(), ["laptop.json"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn history_this_installation_wrote_before_comes_back_into_an_empty_catalog() {
|
||||
let drive = FakeDrive::default();
|
||||
let (laptop, catalog) = device("laptop");
|
||||
catalog
|
||||
.lock()
|
||||
.set_watch_state("share-a", "ep1", &watched_at(60.0, 100))
|
||||
.unwrap();
|
||||
laptop.sync_with(&drive, &catalog, 100).await.unwrap();
|
||||
|
||||
// The catalog was deleted; the config, and with it the id, was not.
|
||||
let (relaunched, empty) = device("laptop");
|
||||
let report = relaunched.sync_with(&drive, &empty, 200).await.unwrap();
|
||||
|
||||
assert_eq!(report.applied, 1);
|
||||
assert!(!report.uploaded);
|
||||
assert_eq!(
|
||||
empty.lock().watch_state("share-a", "ep1").unwrap(),
|
||||
Some(watched_at(60.0, 100))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_own_file_that_cannot_be_read_is_not_overwritten() {
|
||||
let drive = FakeDrive::default();
|
||||
let (laptop, catalog) = device("laptop");
|
||||
catalog
|
||||
.lock()
|
||||
.set_watch_state("share-a", "ep1", &watched_at(60.0, 100))
|
||||
.unwrap();
|
||||
laptop.sync_with(&drive, &catalog, 100).await.unwrap();
|
||||
|
||||
let (relaunched, other) = device("laptop");
|
||||
other
|
||||
.lock()
|
||||
.set_watch_state("share-a", "ep2", &watched_at(5.0, 150))
|
||||
.unwrap();
|
||||
drive
|
||||
.offline
|
||||
.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
let report = relaunched.sync_with(&drive, &other, 200).await.unwrap();
|
||||
assert!(!report.uploaded);
|
||||
|
||||
drive
|
||||
.offline
|
||||
.store(false, std::sync::atomic::Ordering::SeqCst);
|
||||
relaunched.sync_with(&drive, &other, 300).await.unwrap();
|
||||
let state = other.lock().all_watch_states().unwrap();
|
||||
assert_eq!(state.len(), 2, "both episodes survive: {state:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_file_from_a_newer_build_is_neither_merged_nor_retired() {
|
||||
let drive = FakeDrive::default();
|
||||
let future = HistoryFile {
|
||||
version: FORMAT_VERSION + 1,
|
||||
installation: "future".to_owned(),
|
||||
entries: vec![entry("ep1", 60.0, 100)],
|
||||
};
|
||||
drive.put("future.json", serde_json::to_vec(&future).unwrap());
|
||||
let (laptop, catalog) = device("laptop");
|
||||
catalog
|
||||
.lock()
|
||||
.set_watch_state("share-a", "ep2", &watched_at(5.0, 100))
|
||||
.unwrap();
|
||||
|
||||
let report = laptop
|
||||
.sync_with(&drive, &catalog, RETIRE_AFTER_SECS + DAY)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(report.applied, 0);
|
||||
assert_eq!(report.retired, 0);
|
||||
assert_eq!(drive.names(), ["future.json", "laptop.json"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_quiet_installations_file_is_retired_once_its_history_is_carried() {
|
||||
let drive = FakeDrive::default();
|
||||
let (old_phone, old_catalog) = device("old-phone");
|
||||
old_catalog
|
||||
.lock()
|
||||
.set_watch_state("share-a", "ep1", &watched_at(600.0, 100))
|
||||
.unwrap();
|
||||
drive.at(100);
|
||||
old_phone
|
||||
.sync_with(&drive, &old_catalog, 100)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (phone, catalog) = device("phone");
|
||||
let later = 100 + RETIRE_AFTER_SECS;
|
||||
drive.at(later);
|
||||
let report = phone.sync_with(&drive, &catalog, later).await.unwrap();
|
||||
|
||||
assert_eq!(report.retired, 1);
|
||||
assert_eq!(drive.names(), ["phone.json"]);
|
||||
// Nothing was lost: the newcomer's file carries it now.
|
||||
let (fresh, fresh_catalog) = device("fresh");
|
||||
fresh
|
||||
.sync_with(&drive, &fresh_catalog, later)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
fresh_catalog.lock().watch_state("share-a", "ep1").unwrap(),
|
||||
Some(watched_at(600.0, 100))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_recent_installations_file_is_kept() {
|
||||
let drive = FakeDrive::default();
|
||||
let (laptop, laptop_catalog) = device("laptop");
|
||||
laptop_catalog
|
||||
.lock()
|
||||
.set_watch_state("share-a", "ep1", &watched_at(600.0, 100))
|
||||
.unwrap();
|
||||
drive.at(100);
|
||||
laptop
|
||||
.sync_with(&drive, &laptop_catalog, 100)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (phone, catalog) = device("phone");
|
||||
let report = phone
|
||||
.sync_with(&drive, &catalog, 100 + RETIRE_AFTER_SECS - DAY)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(report.retired, 0);
|
||||
assert_eq!(drive.names(), ["laptop.json", "phone.json"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_installation_keeps_the_id_it_was_given() {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("pstr-sync-{}", new_installation_id().unwrap()));
|
||||
let dirs = AppDirs::from_paths(root.join("c"), root.join("d"), root.join("k")).unwrap();
|
||||
let first = WatchSync::open(&dirs).unwrap().installation;
|
||||
let second = WatchSync::open(&dirs).unwrap().installation;
|
||||
assert_eq!(first, second);
|
||||
assert_eq!(first.len(), 32);
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_history_file_round_trips() {
|
||||
let file = HistoryFile {
|
||||
version: FORMAT_VERSION,
|
||||
installation: "abc".to_owned(),
|
||||
entries: vec![entry("a", 12.5, 100)],
|
||||
};
|
||||
let bytes = serde_json::to_vec(&file).unwrap();
|
||||
assert_eq!(parse(&bytes), Some(file.entries));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_history_file_from_a_newer_format_is_not_read() {
|
||||
let file = HistoryFile {
|
||||
version: FORMAT_VERSION + 1,
|
||||
installation: "abc".to_owned(),
|
||||
entries: vec![entry("a", 12.5, 100)],
|
||||
};
|
||||
assert_eq!(parse(&serde_json::to_vec(&file).unwrap()), None);
|
||||
assert_eq!(parse(b"not json"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entries_another_device_could_not_have_written_are_refused() {
|
||||
assert!(entry("a", 1.0, 1).is_sane());
|
||||
assert!(!entry("a", f64::NAN, 1).is_sane());
|
||||
assert!(!entry("a", -1.0, 1).is_sane());
|
||||
assert!(!entry("", 1.0, 1).is_sane());
|
||||
let mut infinite = entry("a", 1.0, 1);
|
||||
infinite.duration = Some(f64::INFINITY);
|
||||
assert!(!infinite.is_sane());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_entry_carries_its_watch_state_unchanged() {
|
||||
let state = WatchState {
|
||||
position_secs: 61.0,
|
||||
duration_secs: None,
|
||||
watched: true,
|
||||
updated_at: 42,
|
||||
};
|
||||
let entry = HistoryEntry::from_state(("s".to_owned(), "l".to_owned()), state);
|
||||
assert_eq!(entry.state(), state);
|
||||
}
|
||||
}
|
||||
@@ -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> {
|
||||
self.enrich(title, found)
|
||||
.await
|
||||
|
||||
@@ -42,4 +42,4 @@ pub use proton_sdk::ids::NodeUid;
|
||||
pub use reader::{FileBlocks, LibraryOpener, RevisionBlocks};
|
||||
pub use ring::{DEFAULT_RING_BYTES, RingStats};
|
||||
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 async_trait::async_trait;
|
||||
use proton_drive_rs::ProtonDrivePublicLinkClient;
|
||||
use proton_sdk::error::ProtonError;
|
||||
use proton_sdk::ids::NodeUid;
|
||||
|
||||
@@ -159,7 +158,7 @@ impl LibraryOpener {
|
||||
Self { library }
|
||||
}
|
||||
|
||||
fn client(&self, share_id: &str) -> Result<&ProtonDrivePublicLinkClient> {
|
||||
fn client(&self, share_id: &str) -> Result<&pstr_core::ShareClient> {
|
||||
self.library
|
||||
.client(share_id)
|
||||
.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.
|
||||
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
|
||||
/// a continuation rather than a seek.
|
||||
///
|
||||
@@ -299,6 +305,42 @@ impl VideoStream {
|
||||
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 {
|
||||
StreamStats {
|
||||
ring: self.inner.ring.stats(),
|
||||
|
||||
@@ -104,6 +104,24 @@ an edge case.
|
||||
holds only the id, display name and token.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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
|
||||
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
|
||||
@@ -118,7 +136,10 @@ an edge case.
|
||||
| Share list | config dir, `shares.json` | **No** — atomic writes, never overwritten when unparseable |
|
||||
| Share secrets | OS credential store | **No** |
|
||||
| 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 |
|
||||
| Watch history in Drive | device `proton-stream`, `watch-history/<id>.json` | One file per installation; merged per episode, newer wins. 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 |
|
||||
| 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 |
|
||||
|
||||
@@ -146,6 +146,80 @@ and [B44](#b44--r8-broke-the-app-under-instrumentation-in-five-places).
|
||||
|
||||
## 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
|
||||
|
||||
**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
|
||||
|
||||
- **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
|
||||
subtitles with hardware decode, for free.
|
||||
- **No transcoding.** mpv demuxes and decodes natively; there is no remux or
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
# 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.
|
||||
- **Watch history syncs 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.
|
||||
- **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.
|
||||
@@ -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,656 @@
|
||||
import Foundation
|
||||
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
|
||||
/// asks it questions off the main thread and keeps the answers for SwiftUI.
|
||||
/// Rust owns the Proton sessions, SQLite and every decrypted byte; this asks it
|
||||
/// questions off the main thread and keeps the answers for SwiftUI.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class AppModel {
|
||||
let engine: AndroidEngine?
|
||||
private(set) var startupError: String?
|
||||
|
||||
var loading = true
|
||||
var refreshing = false
|
||||
private(set) var query = ""
|
||||
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 search = ""
|
||||
var crawling = false
|
||||
var error: String?
|
||||
var offline: [OfflineRecord] = []
|
||||
var metadataSettings = MetadataSettingsRecord(enabled: false, provider: .aniList, language: "en", ready: true)
|
||||
var storage = StorageUsageRecord(offlineBytes: 0, offlineCount: 0, partialBytes: 0, cacheBytes: 0)
|
||||
/// What the snackbar says.
|
||||
var message: String?
|
||||
/// What Undo on `message` puts back, when the message offers one.
|
||||
var undo: [WatchSnapshot]?
|
||||
var account = AccountUiState()
|
||||
|
||||
init() {
|
||||
do {
|
||||
engine = try AndroidEngine(paths: Self.paths(), secrets: KeychainSecretStore())
|
||||
} catch {
|
||||
engine = nil
|
||||
startupError = String(describing: error)
|
||||
@ObservationIgnored private var searchTask: Task<Void, Never>?
|
||||
@ObservationIgnored private var started = false
|
||||
|
||||
/// 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()
|
||||
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
|
||||
/// 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 {
|
||||
guard let engine else { throw NoEngine() }
|
||||
return try await Task.detached(priority: .userInitiated) { try work(engine) }.value
|
||||
nonisolated func run<T: Sendable>(_ work: @escaping @Sendable (AndroidEngine) throws -> T) async throws -> T {
|
||||
try await Task.detached(priority: .userInitiated) {
|
||||
try work(try NativeRuntime.blockingEngine())
|
||||
}.value
|
||||
}
|
||||
|
||||
func report(_ error: Error) {
|
||||
self.error = String(describing: error)
|
||||
// MARK: - Library
|
||||
|
||||
/// 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 {
|
||||
let needle = search.trimmingCharacters(in: .whitespaces)
|
||||
func search(_ query: String) {
|
||||
self.query = query
|
||||
searchTask?.cancel()
|
||||
searchTask = Task {
|
||||
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 {
|
||||
refreshing = true
|
||||
message = nil
|
||||
do {
|
||||
let (titles, shares) = try await run { engine in
|
||||
(try engine.library(search: needle.isEmpty ? nil : needle), try engine.shares())
|
||||
try await NativeRuntime.engine().crawl(shareId: shareId)
|
||||
} catch {
|
||||
message = errorMessage(error)
|
||||
}
|
||||
refreshing = false
|
||||
reload()
|
||||
}
|
||||
|
||||
/// Run enrichment again. With `force` every title is looked up afresh,
|
||||
/// including ones that already matched.
|
||||
func matchTitles(force: Bool) {
|
||||
Task {
|
||||
refreshing = true
|
||||
message = nil
|
||||
do {
|
||||
let summary = try await NativeRuntime.engine().matchTitles(force: force)
|
||||
message = Self.describe(summary)
|
||||
} catch {
|
||||
message = errorMessage(error)
|
||||
}
|
||||
refreshing = false
|
||||
reload()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Shares
|
||||
|
||||
func addShare(name: String, url: String, password: String?) {
|
||||
Task {
|
||||
do {
|
||||
let password = password.flatMap { $0.trimmingCharacters(in: .whitespaces).isEmpty ? nil : $0 }
|
||||
_ = try await run { try $0.addShare(name: name, url: url, customPassword: password) }
|
||||
try await NativeRuntime.engine().crawl(shareId: nil)
|
||||
} catch {
|
||||
message = errorMessage(error)
|
||||
}
|
||||
reload()
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-supply the link behind a share whose stored secret cannot be read.
|
||||
/// Re-entering the link rewrites the secret and leaves the catalog and the
|
||||
/// offline files alone.
|
||||
func repairShare(id: String, url: String, password: String?) {
|
||||
Task {
|
||||
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)
|
||||
}
|
||||
reload()
|
||||
}
|
||||
}
|
||||
|
||||
func removeShare(_ id: String) {
|
||||
Task {
|
||||
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)
|
||||
}
|
||||
reload()
|
||||
}
|
||||
}
|
||||
|
||||
func dismissMessage() {
|
||||
message = nil
|
||||
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 {
|
||||
do {
|
||||
try await NativeRuntime.engine().signOut()
|
||||
} catch {
|
||||
reportError(error)
|
||||
}
|
||||
account = AccountUiState(state: .signedOut)
|
||||
reload()
|
||||
}
|
||||
}
|
||||
|
||||
/// Sync now, from a tap: says so when it is done.
|
||||
func syncNow() {
|
||||
Task {
|
||||
guard await syncWatchHistory() else { return }
|
||||
let applied = account.applied
|
||||
message = applied > 0 ? "Watch history synced, \(applied) from other devices" : "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.
|
||||
@discardableResult
|
||||
func syncWatchHistory() async -> Bool {
|
||||
guard case .signedIn = account.state else { return false }
|
||||
do {
|
||||
guard let report = try await NativeRuntime.engine().syncWatchHistory() else { return false }
|
||||
account.syncedAt = Date()
|
||||
account.applied = Int(report.applied)
|
||||
account.syncError = nil
|
||||
if report.applied > 0 { reload() }
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
refreshing = true
|
||||
do {
|
||||
let share = try await run { try $0.addAccountFolder(name: name, volumeId: volumeId, linkId: linkId) }
|
||||
try await NativeRuntime.engine().crawl(shareId: share.id)
|
||||
} catch {
|
||||
reportError(error)
|
||||
}
|
||||
refreshing = false
|
||||
reload()
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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
|
||||
|
||||
func saveMetadataSettings(enabled: Bool, provider: MetadataProvider, language: String, apiKey: String) {
|
||||
Task {
|
||||
do {
|
||||
let engine = try await NativeRuntime.engine()
|
||||
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))
|
||||
}
|
||||
if enabled {
|
||||
message = Self.describe(try await engine.matchTitles(force: false))
|
||||
}
|
||||
} catch {
|
||||
reportError(error)
|
||||
}
|
||||
reload()
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
do {
|
||||
try await NativeRuntime.engine().removeOfflineEpisode(shareId: file.shareId, linkId: file.linkId)
|
||||
} catch {
|
||||
message = errorMessage(error)
|
||||
}
|
||||
reload()
|
||||
}
|
||||
}
|
||||
|
||||
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 DownloadCoordinator.shared.cancelAll()
|
||||
do {
|
||||
try await NativeRuntime.engine().removeAllOffline()
|
||||
DownloadStateStore.shared.clear()
|
||||
} catch {
|
||||
reportError(error)
|
||||
}
|
||||
reload()
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Reload
|
||||
|
||||
func reload() {
|
||||
let query = query.trimmingCharacters(in: .whitespaces).isEmpty ? nil : self.query
|
||||
let sort = sort
|
||||
let grouped = grouped
|
||||
Task {
|
||||
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 {
|
||||
loading = false
|
||||
message = errorMessage(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func reloadLibrary(_ query: String) async {
|
||||
let search = query.trimmingCharacters(in: .whitespaces).isEmpty ? nil : query
|
||||
let sort = sort
|
||||
let grouped = grouped
|
||||
do {
|
||||
let (titles, arrangement) = try await run { engine in
|
||||
(try engine.library(search: search), try engine.arrangement(search: search, sort: sort, grouped: grouped))
|
||||
}
|
||||
self.titles = titles
|
||||
self.shares = shares
|
||||
self.arrangement = arrangement
|
||||
} catch {
|
||||
report(error)
|
||||
message = errorMessage(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func crawl(shareId: String? = nil) async {
|
||||
guard let engine, !crawling else { return }
|
||||
crawling = true
|
||||
defer { crawling = false }
|
||||
do {
|
||||
try await engine.crawl(shareId: shareId)
|
||||
} catch {
|
||||
report(error)
|
||||
}
|
||||
await reload()
|
||||
}
|
||||
|
||||
func addShare(name: String, url: String, password: String?) async {
|
||||
do {
|
||||
let share = try await run { try $0.addShare(name: name, url: url, customPassword: password) }
|
||||
await crawl(shareId: share.id)
|
||||
} catch {
|
||||
report(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
|
||||
|
||||
func repairShare(id: String, url: String, password: String?) async {
|
||||
do {
|
||||
_ = try await run { try $0.repairShare(shareId: id, url: url, customPassword: password) }
|
||||
await crawl(shareId: id)
|
||||
} catch {
|
||||
report(error)
|
||||
init(name: String) {
|
||||
identifier = UIApplication.shared.beginBackgroundTask(withName: name) { [weak self] in
|
||||
MainActor.assumeIsolated { self?.end() }
|
||||
}
|
||||
}
|
||||
|
||||
func removeShare(id: String) async {
|
||||
do {
|
||||
try await run { try $0.removeShare(shareId: id) }
|
||||
} catch {
|
||||
report(error)
|
||||
}
|
||||
await reload()
|
||||
func end() {
|
||||
guard identifier != .invalid else { return }
|
||||
UIApplication.shared.endBackgroundTask(identifier)
|
||||
identifier = .invalid
|
||||
}
|
||||
}
|
||||
|
||||
/// Marking seen puts the position at the duration where one is known, and
|
||||
/// unwatching rewinds, as on desktop and Android.
|
||||
func setWatched(_ episode: EpisodeRecord, _ watched: Bool) async {
|
||||
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 {
|
||||
report(error)
|
||||
}
|
||||
await reload()
|
||||
}
|
||||
|
||||
/// Episodes started but not finished, most recent first.
|
||||
var continueWatching: [(title: TitleRecord, episode: EpisodeRecord)] {
|
||||
titles
|
||||
.flatMap { title in
|
||||
title.seasons.flatMap(\.episodes)
|
||||
.filter { $0.resumeAt != nil && !$0.watched }
|
||||
.map { (title: title, episode: $0) }
|
||||
}
|
||||
.sorted { $0.episode.lastPlayed > $1.episode.lastPlayed }
|
||||
}
|
||||
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,338 @@
|
||||
import AVFoundation
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
@main
|
||||
struct ProtonStreamApp: App {
|
||||
@UIApplicationDelegateAdaptor(AppDelegate.self) private var delegate
|
||||
@State private var model = AppModel()
|
||||
@State private var appearance = AppearanceState.shared
|
||||
|
||||
init() {
|
||||
// `.playback` keeps the audio going with the screen locked or the app
|
||||
// in the background, which `UIBackgroundModes: audio` then permits.
|
||||
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 {
|
||||
WindowGroup {
|
||||
let scheme = appearance.scheme
|
||||
RootView()
|
||||
.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 {
|
||||
@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 {
|
||||
if let failure = model.startupError {
|
||||
ContentUnavailableView("Could not start", systemImage: "exclamationmark.triangle", description: Text(failure))
|
||||
} else {
|
||||
TabView {
|
||||
NavigationStack { LibraryView() }
|
||||
.tabItem { Label("Library", systemImage: "play.rectangle.on.rectangle") }
|
||||
NavigationStack { SharesView() }
|
||||
.tabItem { Label("Shares", systemImage: "link") }
|
||||
NavigationStack { SettingsView() }
|
||||
.tabItem { Label("Settings", systemImage: "gearshape") }
|
||||
GeometryReader { geometry in
|
||||
let wide = geometry.size.width >= railWidth
|
||||
ZStack(alignment: .bottom) {
|
||||
HStack(spacing: 0) {
|
||||
if wide { NavigationRail(destination: $destination, onSelect: select) }
|
||||
page(width: geometry.size.width)
|
||||
.safeAreaInset(edge: .bottom, spacing: 0) {
|
||||
VStack(spacing: 8) {
|
||||
if host.isOpen && host.minimized {
|
||||
MiniTransport(host: host)
|
||||
}
|
||||
if !wide { NavigationBar(destination: $destination, onSelect: select) }
|
||||
}
|
||||
}
|
||||
}
|
||||
Snackbar()
|
||||
.padding(.bottom, (wide ? 16 : 72) + (host.isOpen && host.minimized ? miniTransportInset : 0))
|
||||
}
|
||||
.task { await model.reload() }
|
||||
.alert("Something went wrong", isPresented: .init(
|
||||
get: { model.error != nil },
|
||||
set: { if !$0 { model.error = nil } }
|
||||
)) {
|
||||
Button("OK", role: .cancel) {}
|
||||
} message: {
|
||||
Text(model.error ?? "")
|
||||
.background(scheme.background.ignoresSafeArea())
|
||||
}
|
||||
.overlay {
|
||||
// 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.
|
||||
if host.isOpen && !host.minimized {
|
||||
Group {
|
||||
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()
|
||||
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(.plain)
|
||||
.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(.plain)
|
||||
.accessibilityLabel(item.label)
|
||||
.accessibilityAddTraits(selected ? .isSelected : [])
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(.top, 24)
|
||||
.background(scheme.surface.ignoresSafeArea())
|
||||
}
|
||||
}
|
||||
|
||||
/// 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(.plain)
|
||||
.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 {
|
||||
let id: Int64
|
||||
let type: String
|
||||
@@ -25,51 +35,90 @@ struct MpvTrack: Identifiable, Hashable {
|
||||
let selected: Bool
|
||||
|
||||
var label: String {
|
||||
let parts = [title, language.uppercased()].filter { !$0.isEmpty }
|
||||
return parts.isEmpty ? "Track \(id)" : parts.joined(separator: " · ")
|
||||
let parts = [language, title].filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty }
|
||||
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
|
||||
/// the main thread; mpv's events are read on a thread of their own and applied
|
||||
/// back on the main thread.
|
||||
/// One libmpv core rendering into a Metal layer, for the life of the app.
|
||||
///
|
||||
/// 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
|
||||
@Observable
|
||||
final class MpvPlayer {
|
||||
enum EndReason { case eof, stopped, failed }
|
||||
|
||||
private(set) var position: Double = 0
|
||||
private(set) var duration: Double = 0
|
||||
private(set) var volume: Double = 100
|
||||
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
|
||||
/// 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 chapters: [ChapterRecord] = []
|
||||
private(set) var failure: String?
|
||||
/// Between `loadfile` and mpv's `FILE_LOADED`. Everything mpv reports in
|
||||
/// that window still describes the *outgoing* file, including the
|
||||
/// `END_FILE(stop)` stopping it emits.
|
||||
private(set) var loading = false
|
||||
/// Which episode the readings above describe, or nil between `loadfile`
|
||||
/// and mpv's `START_FILE`. Everything that *writes* per-episode state —
|
||||
/// the resume position above all — must check it: one core serves every
|
||||
/// episode, and a reading attributed to the wrong one resumes the next
|
||||
/// 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.
|
||||
@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 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.backgroundColor = UIColor.black.cgColor
|
||||
guard let mpv = mpv_create() else {
|
||||
failure = "libmpv did not start"
|
||||
return
|
||||
}
|
||||
var wid = layer
|
||||
guard let mpv = mpv_create() else { return }
|
||||
// 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)
|
||||
for (name, value) in [
|
||||
("config", "no"),
|
||||
("vo", "gpu-next"),
|
||||
("gpu-api", "vulkan"),
|
||||
("gpu-context", "moltenvk"),
|
||||
// The default; `load` sets it per file from the viewer's setting.
|
||||
("hwdec", "videotoolbox"),
|
||||
("video-rotate", "no"),
|
||||
("keep-open", "no"),
|
||||
@@ -78,20 +127,21 @@ final class MpvPlayer {
|
||||
("demuxer-readahead-secs", "30"),
|
||||
("demuxer-max-bytes", "50331648"),
|
||||
("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)
|
||||
}
|
||||
guard registerStreamProtocol(mpv), mpv_initialize(mpv) >= 0 else {
|
||||
mpv_terminate_destroy(mpv)
|
||||
failure = "libmpv did not initialize"
|
||||
return
|
||||
}
|
||||
mpv_observe_property(mpv, 1, "time-pos", MPV_FORMAT_DOUBLE)
|
||||
mpv_observe_property(mpv, 2, "duration", MPV_FORMAT_DOUBLE)
|
||||
mpv_observe_property(mpv, 3, "pause", MPV_FORMAT_FLAG)
|
||||
mpv_observe_property(mpv, 4, "paused-for-cache", MPV_FORMAT_FLAG)
|
||||
mpv_observe_property(mpv, 5, "track-list/count", MPV_FORMAT_INT64)
|
||||
mpv_observe_property(mpv, 6, "chapter-list/count", MPV_FORMAT_INT64)
|
||||
for (id, name, format) in Self.observed {
|
||||
mpv_observe_property(mpv, id, name, format)
|
||||
}
|
||||
// Errors mpv reports about the file itself, rather than about the
|
||||
// request that opened it. Without these a failed demux is silent.
|
||||
mpv_request_log_messages(mpv, "error")
|
||||
self.mpv = mpv
|
||||
|
||||
@@ -100,52 +150,106 @@ final class MpvPlayer {
|
||||
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
|
||||
|
||||
/// Load `pstr://<handle>`. On `false` nothing was issued, and the caller
|
||||
/// still owns the handle's release.
|
||||
func load(handle: UInt64, start: Double, audio: String?, subtitle: String?, subtitles: Bool) -> Bool {
|
||||
/// Load `pstr://<handle>` as `media`. On `false` nothing was issued, and
|
||||
/// the caller still owns the handle's release.
|
||||
func load(
|
||||
media: String,
|
||||
handle: UInt64,
|
||||
start: Double,
|
||||
audio: String?,
|
||||
subtitle: String?,
|
||||
subtitles: Bool,
|
||||
hardwareDecoding: Bool
|
||||
) -> Bool {
|
||||
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("alang", audio ?? "")
|
||||
setString("slang", subtitle ?? "")
|
||||
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
|
||||
endReason = nil
|
||||
buffering = false
|
||||
cachePercent = 0
|
||||
seeking = false
|
||||
cachedUntil = 0
|
||||
videoWidth = 0
|
||||
videoHeight = 0
|
||||
tracks = []
|
||||
chapters = []
|
||||
failure = nil
|
||||
problem = nil
|
||||
self.media = nil
|
||||
generation += 1
|
||||
pendingMedia = media
|
||||
loading = true
|
||||
if command(["loadfile", "pstr://\(handle)", "replace"]) < 0 {
|
||||
// Nothing was issued, so no START_FILE will arrive to clear this.
|
||||
loading = false
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func setPaused(_ value: Bool) { setFlag("pause", value) }
|
||||
func seek(to seconds: Double) { setDouble("time-pos", max(0, seconds)) }
|
||||
func seek(by seconds: Double) { _ = command(["seek", String(seconds), "relative"]) }
|
||||
/// Written through rather than left to the observer: a tap that only shows
|
||||
/// its effect on mpv's next property event reads as a button that missed.
|
||||
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 setVolume(_ value: Double) { setDouble("volume", min(max(value, 0), 100)) }
|
||||
func setMuted(_ value: Bool) { setFlag("mute", value) }
|
||||
|
||||
func select(audio: Bool, track: MpvTrack?) {
|
||||
let property = audio ? "aid" : "sid"
|
||||
if let track { setString(property, String(track.id)) } else { setString(property, "no") }
|
||||
/// A track, or none: `nil` turns subtitles off.
|
||||
func select(type: String, track: MpvTrack?) {
|
||||
setString(type == "audio" ? "aid" : "sid", track.map { String($0.id) } ?? "no")
|
||||
refreshTracks()
|
||||
}
|
||||
|
||||
/// Showing no picture while backgrounded keeps the audio playing and avoids
|
||||
/// the black frame MoltenVK otherwise leaves on return.
|
||||
/// Showing no picture while backgrounded keeps the audio playing: iOS
|
||||
/// refuses GPU work from a background app, and would end the process for it.
|
||||
func setVideoEnabled(_ enabled: Bool) { setString("vid", enabled ? "auto" : "no") }
|
||||
|
||||
/// Stop and tear the core down. `cancel_fn` interrupts a read parked in a
|
||||
/// block fetch, and the event thread destroys the core once it is down.
|
||||
func shutdown() {
|
||||
guard let mpv else { return }
|
||||
self.mpv = nil
|
||||
mpv_command_string(mpv, "quit")
|
||||
/// Stop the open file. The stream behind it is released by mpv closing it,
|
||||
/// which this causes; releasing it from here would race that close.
|
||||
func stop() {
|
||||
_ = command(["stop"])
|
||||
media = nil
|
||||
pendingMedia = nil
|
||||
chapters = []
|
||||
tracks = []
|
||||
}
|
||||
|
||||
// MARK: Events
|
||||
@@ -153,10 +257,13 @@ final class MpvPlayer {
|
||||
private enum Event {
|
||||
case double(UInt64, Double)
|
||||
case flag(UInt64, Bool)
|
||||
case count(UInt64)
|
||||
case int(UInt64, Int64)
|
||||
case unavailable(UInt64)
|
||||
case started
|
||||
case loaded
|
||||
case ended(Int32)
|
||||
case log(String)
|
||||
case ended(Int64)
|
||||
case restarted
|
||||
case log(prefix: String, text: String)
|
||||
}
|
||||
|
||||
/// Runs until the core shuts down, then destroys it. Nothing else calls
|
||||
@@ -170,24 +277,34 @@ final class MpvPlayer {
|
||||
mpv_terminate_destroy(mpv)
|
||||
return
|
||||
case MPV_EVENT_PROPERTY_CHANGE:
|
||||
guard let property = event.data?.assumingMemoryBound(to: mpv_event_property.self).pointee,
|
||||
let data = property.data else { break }
|
||||
guard let property = event.data?.assumingMemoryBound(to: mpv_event_property.self).pointee else { break }
|
||||
guard let data = property.data else {
|
||||
decoded = .unavailable(event.reply_userdata)
|
||||
break
|
||||
}
|
||||
switch property.format {
|
||||
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_INT64: decoded = .count(event.reply_userdata)
|
||||
default: break
|
||||
case MPV_FORMAT_INT64: decoded = .int(event.reply_userdata, data.load(as: Int64.self))
|
||||
default: decoded = .unavailable(event.reply_userdata)
|
||||
}
|
||||
case MPV_EVENT_START_FILE:
|
||||
decoded = .started
|
||||
case MPV_EVENT_FILE_LOADED:
|
||||
decoded = .loaded
|
||||
case MPV_EVENT_PLAYBACK_RESTART:
|
||||
decoded = .restarted
|
||||
case MPV_EVENT_END_FILE:
|
||||
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:
|
||||
if let message = event.data?.assumingMemoryBound(to: mpv_event_log_message.self).pointee,
|
||||
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:
|
||||
break
|
||||
@@ -202,28 +319,71 @@ final class MpvPlayer {
|
||||
switch event {
|
||||
case let .double(id, value):
|
||||
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):
|
||||
if id == 3 { paused = value } else if id == 4 { buffering = value }
|
||||
case let .count(id):
|
||||
switch 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 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:
|
||||
loading = false
|
||||
refreshTracks()
|
||||
refreshChapters()
|
||||
case .restarted:
|
||||
seeking = false
|
||||
case let .ended(reason):
|
||||
// The outgoing file's END_FILE arrives after the next load began.
|
||||
if loading { return }
|
||||
switch reason {
|
||||
case MPV_END_FILE_REASON_EOF.rawValue: onEnded?(.eof)
|
||||
case MPV_END_FILE_REASON_ERROR.rawValue:
|
||||
failure = failure ?? "The file could not be played"
|
||||
onEnded?(.failed)
|
||||
default: onEnded?(.stopped)
|
||||
let ended: EndReason = switch reason {
|
||||
case Int64(MPV_END_FILE_REASON_EOF.rawValue): .eof
|
||||
case Int64(MPV_END_FILE_REASON_STOP.rawValue): .stopped
|
||||
case Int64(MPV_END_FILE_REASON_QUIT.rawValue): .quit
|
||||
case Int64(MPV_END_FILE_REASON_ERROR.rawValue): .failed
|
||||
default: .other
|
||||
}
|
||||
case let .log(text):
|
||||
if !text.isEmpty { failure = text }
|
||||
endReason = ended
|
||||
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,553 @@
|
||||
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?
|
||||
/// 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 }
|
||||
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
|
||||
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)
|
||||
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") }
|
||||
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
|
||||
}
|
||||
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 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 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,614 @@
|
||||
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.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 player.stalled && host.playbackError == nil {
|
||||
VStack(spacing: 12) {
|
||||
ProgressView().controlSize(.large).tint(scheme.primary)
|
||||
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(.plain)
|
||||
.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(.plain)
|
||||
.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(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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: CALayer
|
||||
|
||||
init(layer video: CALayer) {
|
||||
self.video = video
|
||||
super.init(frame: .zero)
|
||||
backgroundColor = .black
|
||||
video.removeFromSuperlayer()
|
||||
layer.addSublayer(video)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder _: NSCoder) { nil }
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
video.frame = bounds
|
||||
video.contentsScale = window?.screen.scale ?? UIScreen.main.scale
|
||||
CATransaction.commit()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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, watch history is kept in your own Drive so every device 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 "Watch history did not sync: \(error)" }
|
||||
guard let at = account.syncedAt else { return "Watch history syncs with 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(.plain)
|
||||
.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(.plain)
|
||||
.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(.plain)
|
||||
.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(.plain)
|
||||
.accessibilityAddTraits(selected ? .isSelected : [])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,531 @@
|
||||
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?
|
||||
// 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) { 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") {
|
||||
Task { @MainActor in
|
||||
try? await Task.sleep(for: .milliseconds(350))
|
||||
matching = 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)
|
||||
.onTapGesture { onResume(entry.title, entry.index) }
|
||||
.onLongPressGesture { 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)
|
||||
.onTapGesture { onTitle(title) }
|
||||
.onLongPressGesture { 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)
|
||||
.onTapGesture { onTitle(tile.title) }
|
||||
.onLongPressGesture { 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(.plain)
|
||||
.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,317 @@
|
||||
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 }
|
||||
var bytes = Data()
|
||||
do {
|
||||
for try await byte in stream {
|
||||
bytes.append(byte)
|
||||
if bytes.count > Self.maxBytes { return nil }
|
||||
}
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
return 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,466 @@
|
||||
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
|
||||
@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(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.task {
|
||||
prefs = try? await model.run { try $0.playbackPrefs() }
|
||||
}
|
||||
.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
|
||||
model.saveMetadataSettings(enabled: enabled, provider: provider, language: language, apiKey: key)
|
||||
showMetadata = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
// Back by an edge swipe, as from a title page.
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 20)
|
||||
.onEnded { drag in
|
||||
if drag.startLocation.x < 24, drag.translation.width > 80 { self.page = nil }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@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) } })
|
||||
}
|
||||
|
||||
private func update(_ change: (inout PlaybackPrefsRecord) -> Void) {
|
||||
guard var next = prefs else { return }
|
||||
change(&next)
|
||||
prefs = next
|
||||
let stored = next
|
||||
Task.detached { try? NativeRuntime.blockingEngine().setPlaybackPrefs(prefs: stored) }
|
||||
}
|
||||
}
|
||||
|
||||
/// `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(.plain)
|
||||
.disabled(!enabled)
|
||||
}
|
||||
}
|
||||
|
||||
private struct MetadataSettingsSheet: View {
|
||||
let current: MetadataSettingsRecord
|
||||
let onSave: (Bool, MetadataProvider, String, String) -> Void
|
||||
@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 = ""
|
||||
|
||||
init(current: MetadataSettingsRecord, onSave: @escaping (Bool, MetadataProvider, String, String) -> Void) {
|
||||
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)
|
||||
}
|
||||
} buttons: {
|
||||
Button("Cancel") { dismiss() }.buttonStyle(.tonal)
|
||||
Button("Save") {
|
||||
onSave(enabled, provider, language.trimmingCharacters(in: .whitespaces).isEmpty ? "en" : language, apiKey)
|
||||
}
|
||||
.buttonStyle(.accent)
|
||||
.disabled(!(!enabled || provider != .tmdb || current.ready || !apiKey.trimmingCharacters(in: .whitespaces).isEmpty))
|
||||
}
|
||||
.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func change(namesChanged: Bool = false, _ edit: (inout AppearanceRecord) -> Void) {
|
||||
guard var next = choice else { return }
|
||||
edit(&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
|
||||
guard let (palette, row) = 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)
|
||||
}) else { return }
|
||||
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,209 @@
|
||||
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(.plain)
|
||||
.padding(16)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: Binding(get: { showAdd || incomingLink != nil }, set: { if !$0 { showAdd = false; incomingLink = nil } })) {
|
||||
AddShareSheet(initialUrl: incomingLink ?? "") { name, url, password in
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
private struct AddShareSheet: View {
|
||||
let onAdd: (String, String, String?) -> Void
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var name = ""
|
||||
@State private var url: String
|
||||
@State private var password = ""
|
||||
|
||||
init(initialUrl: String, onAdd: @escaping (String, String, String?) -> Void) {
|
||||
self.onAdd = onAdd
|
||||
_url = State(initialValue: initialUrl)
|
||||
}
|
||||
|
||||
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)
|
||||
} buttons: {
|
||||
Button("Cancel") { dismiss() }.buttonStyle(.tonal)
|
||||
Button("Add") {
|
||||
onAdd(name.trimmingCharacters(in: .whitespaces), url.trimmingCharacters(in: .whitespaces), password)
|
||||
dismiss()
|
||||
}
|
||||
.buttonStyle(.accent)
|
||||
.disabled(name.trimmingCharacters(in: .whitespaces).isEmpty || url.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
}
|
||||
.secureContent()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
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) }
|
||||
}
|
||||
|
||||
/// 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,194 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,546 @@
|
||||
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 upNext = title.playlist.indices.contains(nextUpIndex(title.playlist)) ? title.playlist[nextUpIndex(title.playlist)] : 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)
|
||||
.onTapGesture { 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(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
.onTapGesture(perform: 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] = []
|
||||
|
||||
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 {
|
||||
Text("Fetching its episodes…").textStyle(.bodySmall).padding(.top, 8)
|
||||
}
|
||||
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("Forget match") {
|
||||
Task {
|
||||
do {
|
||||
try await model.run { try $0.forgetMatch(titleKey: title.key) }
|
||||
changed()
|
||||
} catch {
|
||||
model.reportError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.quiet)
|
||||
Button("Close", action: onDone).buttonStyle(.tonal)
|
||||
}
|
||||
}
|
||||
|
||||
private func search() {
|
||||
let term = term
|
||||
guard !term.trimmingCharacters(in: .whitespaces).isEmpty, !searching else { return }
|
||||
searching = true
|
||||
Task {
|
||||
do {
|
||||
options = try await NativeRuntime.engine().searchMatches(titleKey: title.key, term: term)
|
||||
} catch {
|
||||
model.reportError(error)
|
||||
}
|
||||
searching = false
|
||||
}
|
||||
}
|
||||
|
||||
private func choose(_ option: MatchRecord) {
|
||||
choosing = true
|
||||
Task {
|
||||
do {
|
||||
try await NativeRuntime.engine().chooseMatch(titleKey: title.key, found: option)
|
||||
changed()
|
||||
} catch {
|
||||
model.reportError(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), "Watch history syncs with 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),
|
||||
"Watch history 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
|
||||
sources:
|
||||
- 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`.
|
||||
- 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:
|
||||
# The GPL build is the one with libass and every demuxer; the app is
|
||||
# sideloaded, never distributed through the App Store.
|
||||
@@ -26,6 +42,9 @@ targets:
|
||||
product: MPVKit-GPL
|
||||
- sdk: Security.framework
|
||||
- sdk: SystemConfiguration.framework
|
||||
scheme:
|
||||
testTargets:
|
||||
- ProtonStreamTests
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: io.narl.protonstream
|
||||
@@ -33,9 +52,11 @@ targets:
|
||||
CURRENT_PROJECT_VERSION: ${PSTR_BUILD}
|
||||
SWIFT_VERSION: "5.0"
|
||||
TARGETED_DEVICE_FAMILY: "1,2"
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||
SWIFT_OBJC_BRIDGING_HEADER: ProtonStream/Bridge/ProtonStream-Bridging-Header.h
|
||||
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
|
||||
info:
|
||||
path: ProtonStream/Info.plist
|
||||
@@ -44,8 +65,18 @@ targets:
|
||||
LSRequiresIPhoneOS: true
|
||||
ITSAppUsesNonExemptEncryption: false
|
||||
UIBackgroundModes: [audio]
|
||||
UILaunchScreen: {}
|
||||
# The Proton palette's page colour, so a dark app does not open on white.
|
||||
UILaunchScreen:
|
||||
UIColorName: LaunchBackground
|
||||
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:
|
||||
- UIInterfaceOrientationPortrait
|
||||
- UIInterfaceOrientationLandscapeLeft
|
||||
@@ -55,3 +86,20 @@ targets:
|
||||
- UIInterfaceOrientationPortraitUpsideDown
|
||||
- UIInterfaceOrientationLandscapeLeft
|
||||
- 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
|
||||
@@ -14,7 +14,7 @@
|
||||
# builds from the registry alone.
|
||||
|
||||
pkgname=proton-stream
|
||||
pkgver=1.1.1
|
||||
pkgver=1.3.0
|
||||
pkgrel=1
|
||||
pkgdesc="Netflix-style desktop client for Proton Drive public links: browse and stream a share"
|
||||
arch=('x86_64')
|
||||
@@ -23,7 +23,7 @@ license=('MIT')
|
||||
# mpv carries libmpv.so plus its headers on Arch, so it is both the runtime and
|
||||
# the build dependency. libsecret is the Secret Service side of the keyring —
|
||||
# the share fragment and custom password never touch shares.json.
|
||||
depends=('mpv' 'libsecret' 'hicolor-icon-theme')
|
||||
depends=('mpv' 'libsecret' 'webkit2gtk-4.1' 'hicolor-icon-theme')
|
||||
makedepends=('cargo')
|
||||
optdepends=('gnome-keyring: credential storage via Secret Service'
|
||||
'kwallet: credential storage via Secret Service on KDE')
|
||||
|
||||
@@ -40,6 +40,7 @@ are not Arch.
|
||||
|---|---|
|
||||
| **libmpv ≥ 0.34** | The player. `pstr-player` links it directly and drives it through `stream_cb`; nothing here shells out to `mpv(1)`, so packages depend on the library, not the player binary. |
|
||||
| **libsecret / Secret Service** | The share URL fragment and the custom password are secrets and live in the OS credential store, never in `shares.json`. |
|
||||
| **WebKitGTK 4.1** | The window Proton's CAPTCHA is shown in when it gates a sign-in (`pstr-app::verify`). The page reports success by posting a message to its host, so the system browser cannot stand in for it. |
|
||||
| **libGL, libxkbcommon, libX11, libwayland-client** | eframe on glow, with both display backends compiled in. |
|
||||
|
||||
Not dependencies, deliberately: **SQLite** (rusqlite is `bundled`) and
|
||||
@@ -47,11 +48,13 @@ Not dependencies, deliberately: **SQLite** (rusqlite is `bundled`) and
|
||||
|
||||
Per packager that is:
|
||||
|
||||
- Debian — `libmpv2 | libmpv1` (trixie | bookworm), `libsecret-1-0`, `libgl1`,
|
||||
`libxkbcommon0`, `libx11-6`, `libwayland-client0`.
|
||||
- RPM — `mpv-libs`, `libsecret`, `mesa-libGL`, `libxkbcommon`, `libX11`,
|
||||
`libwayland-client`.
|
||||
- Arch — `mpv` (which carries both `libmpv.so` and its headers), `libsecret`.
|
||||
- Debian — `libmpv2 | libmpv1` (trixie | bookworm), `libsecret-1-0`,
|
||||
`libwebkit2gtk-4.1-0`, `libgl1`, `libxkbcommon0`, `libx11-6`,
|
||||
`libwayland-client0`.
|
||||
- RPM — `mpv-libs`, `libsecret`, `webkit2gtk4.1`, `mesa-libGL`, `libxkbcommon`,
|
||||
`libX11`, `libwayland-client`.
|
||||
- Arch — `mpv` (which carries both `libmpv.so` and its headers), `libsecret`,
|
||||
`webkit2gtk-4.1`.
|
||||
|
||||
The `.deb` and `.rpm` are built from binaries compiled on the packaging host, so
|
||||
they inherit its glibc. A package built on Arch installs on Fedora rawhide but
|
||||
@@ -88,6 +91,13 @@ There is no static libmpv worth shipping, so **"standalone exe" means the exe
|
||||
plus `libmpv-2.dll` beside it** — that pair, plus `pstr.exe` and the README, is
|
||||
what the portable `.zip` contains and what the `.msi` installs.
|
||||
|
||||
The cross build adds `WebView2Loader.dll` to that set. The CAPTCHA window runs
|
||||
on WebView2, and under `-gnu` `webview2-com-sys` links its loader as a DLL —
|
||||
without it beside the exe, the app does not start at all. `build-windows.sh`
|
||||
takes it from that crate's build output. An `-msvc` build links the loader
|
||||
statically and needs no DLL. The WebView2 *runtime* is part of Windows 10 and
|
||||
11 and is not shipped.
|
||||
|
||||
The MSI needs the WiX toolset; the script installs it (`dotnet tool install
|
||||
--global wix`) and adds the UI extension if they are missing. It installs to
|
||||
`Program Files\Proton Stream`, puts a Start Menu shortcut on the GUI, and adds
|
||||
@@ -124,7 +134,7 @@ The `.icns` is rasterized from `io.narl.proton-stream.svg` when `rsvg-convert` o
|
||||
| `io.narl.proton-stream.desktop` / `.svg` | Freedesktop entry and icon |
|
||||
| `macos/Info.plist.in` | `.app` bundle plist; `@VERSION@`/`@BIN@`/`@APP_ID@` substituted at build time |
|
||||
| `windows/proton-stream.wxs` | WiX v4+ installer definition |
|
||||
| `windows/proton-stream.ico` | MSI and shortcut icon, rasterized from the SVG |
|
||||
| `windows/proton-stream.ico` | Exe and Add/Remove Programs icon, rasterized from the SVG; `crates/pstr-app/build.rs` compiles it into `proton-stream.exe` |
|
||||
| `windows/license.rtf` | Fallback for the installer's license dialog; the repo's own `LICENSE` is preferred when it exists |
|
||||
|
||||
The Debian `control` file has no template — `scripts/build.sh` writes it, because
|
||||
|
||||
@@ -31,6 +31,7 @@ ExclusiveArch: x86_64
|
||||
# `bundled`) and TLS is rustls, so neither shows up here.
|
||||
Requires: mpv-libs >= 0.34
|
||||
Requires: libsecret
|
||||
Requires: webkit2gtk4.1
|
||||
Requires: mesa-libGL
|
||||
Requires: libxkbcommon
|
||||
Requires: libX11
|
||||
|
||||
@@ -78,6 +78,15 @@
|
||||
<RemoveExistingProducts After="InstallInitialize" />
|
||||
</InstallExecuteSequence>
|
||||
|
||||
<!-- Copy every file on an upgrade, whatever is on disk. Costing runs
|
||||
before RemoveExistingProducts, so it sees the old libmpv-2.dll — the
|
||||
same version, since mpv is pinned — and decides not to copy it. The
|
||||
removal of the old product then deletes it, and the upgrade ends with
|
||||
no DLL: "mpv missing" until a second install, which finds nothing on
|
||||
disk and copies it. Every file here is private to INSTALLDIR, so
|
||||
overwriting unconditionally downgrades nothing shared. -->
|
||||
<Property Id="REINSTALLMODE" Value="amus" />
|
||||
|
||||
<Media Id="1" Cabinet="proton_stream.cab" EmbedCab="yes" />
|
||||
|
||||
<Icon Id="proton-stream.ico" SourceFile="$(var.IconFile)" />
|
||||
@@ -98,14 +107,19 @@
|
||||
and writes the advertised form anyway, with the feature name in
|
||||
Shortcut/Target. What selects the non-advertised form is being
|
||||
a child of the Component rather than of the File, plus an
|
||||
explicit formatted Target. Keep it out of the <File>. -->
|
||||
explicit formatted Target. Keep it out of the <File>.
|
||||
|
||||
No Icon either. A shortcut's Icon is copied to
|
||||
C:\Windows\Installer\{ProductCode}, and every upgrade has a
|
||||
new ProductCode and deletes the old folder — taking the icon
|
||||
of every taskbar pin made from the shortcut with it. Without
|
||||
one, the shortcut shows the exe's own icon resource. -->
|
||||
<Shortcut Id="StartMenuShortcut"
|
||||
Directory="ProgramMenuFolder"
|
||||
Name="Proton Stream"
|
||||
Description="Browse and stream a Proton Drive public link"
|
||||
Target="[INSTALLDIR]proton-stream.exe"
|
||||
WorkingDirectory="INSTALLDIR"
|
||||
Icon="proton-stream.ico" />
|
||||
WorkingDirectory="INSTALLDIR" />
|
||||
</Component>
|
||||
|
||||
<!-- The CLI, and the PATH entry that makes it worth installing. Tied
|
||||
@@ -135,6 +149,13 @@
|
||||
<File Id="MpvDll" Source="$(var.MpvDll)" Name="libmpv-2.dll" KeyPath="yes" />
|
||||
</Component>
|
||||
|
||||
<!-- Loads WebView2 for the CAPTCHA window Proton sometimes puts
|
||||
in front of a sign-in. A -gnu build links it as a DLL; the
|
||||
WebView2 runtime itself comes with Windows. -->
|
||||
<Component Id="WebViewLoader" Guid="3e8a89cf-cf3e-4cc7-b1c6-9d272b3210c3" Win64="yes">
|
||||
<File Id="WebViewLoader" Source="$(var.WebViewLoader)" Name="WebView2Loader.dll" KeyPath="yes" />
|
||||
</Component>
|
||||
|
||||
<Component Id="ReadMe" Guid="5bb65b44-f5df-423b-8dcc-a753d7aef565" Win64="yes">
|
||||
<File Id="ReadMe" Source="$(var.ReadMe)" Name="README.md" KeyPath="yes" />
|
||||
</Component>
|
||||
@@ -148,6 +169,7 @@
|
||||
<ComponentRef Id="AppExe" />
|
||||
<ComponentRef Id="CliExe" />
|
||||
<ComponentRef Id="MpvDll" />
|
||||
<ComponentRef Id="WebViewLoader" />
|
||||
<ComponentRef Id="ReadMe" />
|
||||
</Feature>
|
||||
|
||||
|
||||
@@ -26,6 +26,15 @@
|
||||
DowngradeErrorMessage="A newer version of Proton Stream is already installed." />
|
||||
<MediaTemplate EmbedCab="yes" />
|
||||
|
||||
<!-- Copy every file on an upgrade, whatever is on disk. Costing runs
|
||||
before RemoveExistingProducts, so it sees the old libmpv-2.dll — the
|
||||
same version, since mpv is pinned — and decides not to copy it. The
|
||||
removal of the old product then deletes it, and the upgrade ends with
|
||||
no DLL: "mpv missing" until a second install, which finds nothing on
|
||||
disk and copies it. Every file here is private to INSTALLFOLDER, so
|
||||
overwriting unconditionally downgrades nothing shared. -->
|
||||
<Property Id="REINSTALLMODE" Value="amus" />
|
||||
|
||||
<Icon Id="AppIcon" SourceFile="$(var.IconFile)" />
|
||||
<Property Id="ARPPRODUCTICON" Value="AppIcon" />
|
||||
<Property Id="ARPURLINFOABOUT" Value="https://github.com/narrrl/proton-stream" />
|
||||
@@ -45,12 +54,16 @@
|
||||
<ComponentGroup Id="AppComponents" Directory="INSTALLFOLDER">
|
||||
<Component Guid="623c8cb8-b826-4d57-a054-e5be56f24c19">
|
||||
<File Id="AppExe" Source="$(var.BinDir)\proton-stream.exe" KeyPath="yes" />
|
||||
<!-- Not advertised, and no Icon: either one puts the shortcut's icon
|
||||
in C:\Windows\Installer\{ProductCode}, which the next upgrade
|
||||
deletes along with the icon of every taskbar pin made from the
|
||||
shortcut. This way it shows the exe's own icon resource. -->
|
||||
<Shortcut Id="StartMenuShortcut"
|
||||
Directory="ProgramMenuFolder"
|
||||
Name="Proton Stream"
|
||||
Description="Browse and stream a Proton Drive public link"
|
||||
Icon="AppIcon"
|
||||
Advertise="yes" />
|
||||
Target="[#AppExe]"
|
||||
WorkingDirectory="INSTALLFOLDER" />
|
||||
</Component>
|
||||
|
||||
<!-- The CLI, and the PATH entry that makes it worth installing. Tied to
|
||||
|
||||
@@ -11,14 +11,16 @@ repo_root="$(cd -- "$script_dir/.." && pwd)"
|
||||
ios_dir="$repo_root/ios"
|
||||
generated="$ios_dir/build/generated"
|
||||
target=aarch64-apple-ios
|
||||
simulator=aarch64-apple-ios-sim
|
||||
|
||||
usage() {
|
||||
echo "usage: $0 {rust|bindings|ipa|all}"
|
||||
echo "usage: $0 {rust|bindings|ipa|all|test}"
|
||||
echo
|
||||
echo " rust static libpstr_android.a for $target"
|
||||
echo " bindings Swift bindings into ios/build/generated"
|
||||
echo " ipa xcodegen + xcodebuild, then dist/ProtonStream-<version>.ipa"
|
||||
echo " ipa xcodegen + xcodebuild, then dist/proton-stream-<version>-ios.ipa"
|
||||
echo " all all three, in that order"
|
||||
echo " test the unit tests on a simulator ($simulator)"
|
||||
}
|
||||
|
||||
die() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
|
||||
@@ -36,11 +38,19 @@ version="$(awk '/^\[workspace.package\]/{found=1; next} found && /^\[/{exit} fou
|
||||
[[ -n "$version" ]] || die "no [workspace.package] version in Cargo.toml"
|
||||
|
||||
build_rust() {
|
||||
log "cargo rustc -p pstr-android --target $target (staticlib)"
|
||||
local triple="${1:-$target}"
|
||||
log "cargo rustc -p pstr-android --target $triple (staticlib)"
|
||||
# The crate stays `cdylib, rlib` for Android; the archive iOS links is asked
|
||||
# for here instead of being built on every other platform too.
|
||||
( cd "$repo_root" && IPHONEOS_DEPLOYMENT_TARGET=17.0 cargo rustc -p pstr-android --lib \
|
||||
--release --locked --target "$target" --crate-type staticlib )
|
||||
--release --locked --target "$triple" --crate-type staticlib )
|
||||
}
|
||||
|
||||
generate_project() {
|
||||
command -v xcodegen >/dev/null || die "xcodegen is required (brew install xcodegen)"
|
||||
[[ -f "$generated/PstrBridge.swift" ]] || die "run '$0 bindings' first"
|
||||
log "xcodegen ($version)"
|
||||
( cd "$ios_dir" && PSTR_VERSION="$version" PSTR_BUILD="${PSTR_BUILD:-1}" xcodegen generate --quiet )
|
||||
}
|
||||
|
||||
build_bindings() {
|
||||
@@ -56,12 +66,8 @@ build_bindings() {
|
||||
}
|
||||
|
||||
build_ipa() {
|
||||
command -v xcodegen >/dev/null || die "xcodegen is required (brew install xcodegen)"
|
||||
[[ -f "$repo_root/target/$target/release/libpstr_android.a" ]] || die "run '$0 rust' first"
|
||||
[[ -f "$generated/PstrBridge.swift" ]] || die "run '$0 bindings' first"
|
||||
|
||||
log "xcodegen ($version)"
|
||||
( cd "$ios_dir" && PSTR_VERSION="$version" PSTR_BUILD="${PSTR_BUILD:-1}" xcodegen generate --quiet )
|
||||
generate_project
|
||||
|
||||
log "xcodebuild (unsigned)"
|
||||
xcodebuild -project "$ios_dir/ProtonStream.xcodeproj" -scheme ProtonStream \
|
||||
@@ -76,16 +82,34 @@ build_ipa() {
|
||||
rm -rf "$stage"
|
||||
mkdir -p "$stage/Payload" "$repo_root/dist"
|
||||
cp -R "$app" "$stage/Payload/"
|
||||
local ipa="$repo_root/dist/ProtonStream-$version.ipa"
|
||||
local ipa="$repo_root/dist/proton-stream-$version-ios.ipa"
|
||||
rm -f "$ipa"
|
||||
( cd "$stage" && zip -qry "$ipa" Payload )
|
||||
log "wrote $ipa"
|
||||
}
|
||||
|
||||
run_tests() {
|
||||
build_rust "$simulator"
|
||||
[[ -f "$generated/PstrBridge.swift" ]] || build_bindings
|
||||
generate_project
|
||||
# Whichever iPhone this Xcode ships a simulator for: the names change with
|
||||
# every release, and the tests do not care which one.
|
||||
local device
|
||||
device="$(xcrun simctl list devices available | grep -m1 -oE 'iPhone [^(]+' | sed 's/ *$//')"
|
||||
[[ -n "$device" ]] || die "no iPhone simulator is installed"
|
||||
log "xcodebuild test ($device)"
|
||||
xcodebuild -project "$ios_dir/ProtonStream.xcodeproj" -scheme ProtonStream \
|
||||
-destination "platform=iOS Simulator,name=$device" \
|
||||
-derivedDataPath "$ios_dir/build/DerivedData" \
|
||||
CODE_SIGNING_ALLOWED=NO \
|
||||
test
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
rust) build_rust ;;
|
||||
bindings) build_bindings ;;
|
||||
ipa) build_ipa ;;
|
||||
all) build_rust; build_bindings; build_ipa ;;
|
||||
test) run_tests ;;
|
||||
*) usage >&2; exit 2 ;;
|
||||
esac
|
||||
@@ -232,9 +232,16 @@ stage() {
|
||||
[[ -f "$rel/$f" ]] || die "$f missing from $rel — drop --skip-build"
|
||||
done
|
||||
|
||||
# The CAPTCHA window's WebView2 loader. Under -msvc it links statically;
|
||||
# under -gnu, webview2-com-sys links the DLL, which it ships and copies into
|
||||
# its build output, so the exe does not start without it beside it.
|
||||
local loader
|
||||
loader="$(ls -t "$rel"/build/webview2-com-sys-*/out/x64/WebView2Loader.dll 2>/dev/null | head -n1)"
|
||||
[[ -n "$loader" ]] || die "WebView2Loader.dll missing from $rel/build — drop --skip-build"
|
||||
|
||||
rm -rf "$OUTDIR"
|
||||
mkdir -p "$OUTDIR"
|
||||
install -m755 "$rel/$GUI_BIN" "$rel/$CLI_BIN" "$MPV_DLL" "$OUTDIR/"
|
||||
install -m755 "$rel/$GUI_BIN" "$rel/$CLI_BIN" "$MPV_DLL" "$loader" "$OUTDIR/"
|
||||
install -m644 "$ROOT/README.md" "$OUTDIR/"
|
||||
|
||||
if [[ -n "$SIGN_CERT" ]]; then
|
||||
@@ -295,6 +302,7 @@ make_msi() {
|
||||
-D "Version=$VERSION" \
|
||||
-D "BinDir=$OUTDIR" \
|
||||
-D "MpvDll=$OUTDIR/libmpv-2.dll" \
|
||||
-D "WebViewLoader=$OUTDIR/WebView2Loader.dll" \
|
||||
-D "IconFile=$PKG/proton-stream.ico" \
|
||||
-D "ReadMe=$OUTDIR/README.md" \
|
||||
-o "$out" "$PKG/proton-stream.wixl.wxs"
|
||||
|
||||
@@ -229,7 +229,7 @@ Priority: optional
|
||||
Architecture: $arch
|
||||
Maintainer: Nils Pukropp <contact@narl.io>
|
||||
Installed-Size: $size
|
||||
Depends: libc6, libmpv2 | libmpv1, libsecret-1-0, libgl1, libxkbcommon0, libx11-6, libwayland-client0
|
||||
Depends: libc6, libmpv2 | libmpv1, libsecret-1-0, libwebkit2gtk-4.1-0, libgl1, libxkbcommon0, libx11-6, libwayland-client0
|
||||
Recommends: gnome-keyring
|
||||
Homepage: https://github.com/narrrl/proton-stream
|
||||
Description: Netflix-style desktop client for Proton Drive public links
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
# Render ios/ProtonStream/Resources/AppIcon.svg into the asset catalog's one
|
||||
# 1024 px icon. iOS wants it opaque, so the alpha channel is dropped.
|
||||
set -euo pipefail
|
||||
|
||||
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
resources="$script_dir/../ios/ProtonStream/Resources"
|
||||
out="$resources/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png"
|
||||
|
||||
command -v rsvg-convert >/dev/null || { echo "rsvg-convert is required (librsvg)" >&2; exit 1; }
|
||||
command -v magick >/dev/null || { echo "magick is required (ImageMagick)" >&2; exit 1; }
|
||||
rsvg-convert -w 1024 -h 1024 "$resources/AppIcon.svg" | magick - -alpha off -strip "$out"
|
||||
echo "wrote $out"
|
||||