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:
nvrl committed 2026-10-07 00:32:03 +02:00
1 parent da16ab0575
commit ed05a1e723
38 files changed
+7546 -208

No files matched your search

+623 -107
View File
@@ -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
}
+73
View File
@@ -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
}
+309 -19
View File
@@ -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() }
}
}
}
+55
View File
@@ -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(&current)
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)
}
}
+225 -66
View File
@@ -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
}
}
+553
View File
@@ -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)
}
}
}
}
+614
View File
@@ -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))
}
}
+37
View File
@@ -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.
+389
View File
@@ -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)
}
}
+341
View File
@@ -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)
}
}
+162
View File
@@ -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)
}
}
+141
View File
@@ -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))" } ?? "")
}
}
+120
View File
@@ -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 : [])
}
}
+531
View File
@@ -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 }
}
+317
View File
@@ -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
}
}
+466
View File
@@ -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"
}
}
}
+209
View File
@@ -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()
}
}
+174
View File
@@ -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)
}
+194
View File
@@ -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)
}
}
+546
View File
@@ -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()
}
}