mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +01:00
feat(ios): port the Android client to SwiftUI
The iOS client is now a screen-for-screen port of the Android app over the same pstr-android UniFFI surface, with no Rust changes: Library, title page, History, Shares (public links and Drive folders), Downloads and Settings, with the same strings, undo snackbars and two-pane layout on wide screens. - Theme from the engine's palette (Material role names, Inter, the same type ramp and corners), repainted live from the appearance settings. - Account sign-in with two-factor code, mailbox password and the Proton CAPTCHA in a non-persistent WKWebView, the Drive folder browser, and watch-history sync every five minutes, after sign-in, on player close and when the app leaves the screen. - Player over libmpv: resume, progress saved every 5 s, autoplay with the up-next card, skip and auto-skip from the chapter plan, track, speed, volume and episode pickers, landscape lock, mini transport, and lock-screen controls with interruption and route-change handling. - Offline downloads with the Android record format, free-space floor, retries, transfer rate and the Wi-Fi-only policy; interrupted transfers resume at next launch. - Config, data and download state are excluded from backup, as Android's allowBackup=false: a restore would bring shares back without their Keychain secrets and give two devices one sync identity. Picture-in-picture, the share sheet and background downloads are not possible the same way on iOS; docs/IOS.md lists why and what replaces them. Adds XCTest ports of the Android unit tests, a `test` command to scripts/build-ios.sh, the app icon and its render script, and an iOS workflow on macos-15 that runs the tests and builds the unsigned .ipa.
This commit is contained in:
1 parent
da16ab0575
commit
ed05a1e723
38 files changed
+7546
-208
No files matched your search
@@ -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.
|
||||
+623
-107
@@ -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,44 +35,81 @@ 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
|
||||
}
|
||||
guard let mpv = mpv_create() else { return }
|
||||
var wid = layer
|
||||
mpv_set_option(mpv, "wid", MPV_FORMAT_INT64, &wid)
|
||||
for (name, value) in [
|
||||
@@ -70,6 +117,7 @@ final class MpvPlayer {
|
||||
("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 +126,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 +149,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 +256,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 +276,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 +318,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 |
Binary file not shown.
|
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 = try 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] = try 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]
|
||||
)
|
||||
}
|
||||
}
|
||||
+47
-2
@@ -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,17 @@ 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
|
||||
Reference in new issue
Block a user