From ed05a1e723ec191a16a8006416c5a736894631b5 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Wed, 7 Oct 2026 00:32:03 +0200 Subject: [PATCH] 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. --- .github/workflows/ios.yml | 71 ++ README.md | 16 +- docs/IOS.md | 85 ++ ios/LICENSE.md | 20 + ios/ProtonStream/App/AppModel.swift | 730 +++++++++++++++--- ios/ProtonStream/App/IncomingShareLink.swift | 40 + ios/ProtonStream/App/NativeRuntime.swift | 73 ++ ios/ProtonStream/App/ProtonStreamApp.swift | 328 +++++++- ios/ProtonStream/App/SettingsStore.swift | 55 ++ .../Download/DownloadCoordinator.swift | 343 ++++++++ .../Download/DownloadStateStore.swift | 196 +++++ ios/ProtonStream/Player/MpvPlayer.swift | 291 +++++-- ios/ProtonStream/Player/PlayerHost.swift | 553 +++++++++++++ ios/ProtonStream/Player/PlayerView.swift | 614 +++++++++++++++ ios/ProtonStream/Resources/AppIcon.svg | 37 + .../AppIcon.appiconset/AppIcon-1024.png | Bin 0 -> 52742 bytes .../AppIcon.appiconset/Contents.json | 14 + .../Resources/Assets.xcassets/Contents.json | 6 + .../LaunchBackground.colorset/Contents.json | 20 + .../Resources/THIRD_PARTY_NOTICES.md | 22 + ios/ProtonStream/UI/AccountUI.swift | 389 ++++++++++ ios/ProtonStream/UI/Common.swift | 341 ++++++++ ios/ProtonStream/UI/DownloadsView.swift | 162 ++++ ios/ProtonStream/UI/HistoryView.swift | 141 ++++ ios/ProtonStream/UI/LanguagePicker.swift | 120 +++ ios/ProtonStream/UI/LibraryView.swift | 531 +++++++++++++ ios/ProtonStream/UI/RemoteArtwork.swift | 317 ++++++++ ios/ProtonStream/UI/SettingsView.swift | 466 +++++++++++ ios/ProtonStream/UI/SharesView.swift | 209 +++++ ios/ProtonStream/UI/Theme/Accent.swift | 174 +++++ ios/ProtonStream/UI/Theme/Theme.swift | 194 +++++ ios/ProtonStream/UI/Theme/Typography.swift | 88 +++ ios/ProtonStream/UI/TitleView.swift | 546 +++++++++++++ ios/ProtonStreamTests/DownloadTests.swift | 187 +++++ ios/ProtonStreamTests/UiLogicTests.swift | 269 +++++++ ios/project.yml | 49 +- scripts/build-ios.sh | 44 +- scripts/render-ios-icon.sh | 13 + 38 files changed, 7546 insertions(+), 208 deletions(-) create mode 100644 .github/workflows/ios.yml create mode 100644 docs/IOS.md create mode 100644 ios/LICENSE.md create mode 100644 ios/ProtonStream/App/IncomingShareLink.swift create mode 100644 ios/ProtonStream/App/NativeRuntime.swift create mode 100644 ios/ProtonStream/App/SettingsStore.swift create mode 100644 ios/ProtonStream/Download/DownloadCoordinator.swift create mode 100644 ios/ProtonStream/Download/DownloadStateStore.swift create mode 100644 ios/ProtonStream/Player/PlayerHost.swift create mode 100644 ios/ProtonStream/Player/PlayerView.swift create mode 100644 ios/ProtonStream/Resources/AppIcon.svg create mode 100644 ios/ProtonStream/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png create mode 100644 ios/ProtonStream/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 ios/ProtonStream/Resources/Assets.xcassets/Contents.json create mode 100644 ios/ProtonStream/Resources/Assets.xcassets/LaunchBackground.colorset/Contents.json create mode 100644 ios/ProtonStream/Resources/THIRD_PARTY_NOTICES.md create mode 100644 ios/ProtonStream/UI/AccountUI.swift create mode 100644 ios/ProtonStream/UI/Common.swift create mode 100644 ios/ProtonStream/UI/DownloadsView.swift create mode 100644 ios/ProtonStream/UI/HistoryView.swift create mode 100644 ios/ProtonStream/UI/LanguagePicker.swift create mode 100644 ios/ProtonStream/UI/LibraryView.swift create mode 100644 ios/ProtonStream/UI/RemoteArtwork.swift create mode 100644 ios/ProtonStream/UI/SettingsView.swift create mode 100644 ios/ProtonStream/UI/SharesView.swift create mode 100644 ios/ProtonStream/UI/Theme/Accent.swift create mode 100644 ios/ProtonStream/UI/Theme/Theme.swift create mode 100644 ios/ProtonStream/UI/Theme/Typography.swift create mode 100644 ios/ProtonStream/UI/TitleView.swift create mode 100644 ios/ProtonStreamTests/DownloadTests.swift create mode 100644 ios/ProtonStreamTests/UiLogicTests.swift create mode 100755 scripts/render-ios-icon.sh diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml new file mode 100644 index 0000000..e43eb8c --- /dev/null +++ b/.github/workflows/ios.yml @@ -0,0 +1,71 @@ +name: iOS + +on: + push: + branches: [main] + workflow_call: + pull_request: + paths: + - 'ios/**' + - 'android/app/src/main/res/font/**' + - 'android/app/src/main/assets/licenses/**' + - 'crates/pstr-android/**' + - 'crates/pstr-core/**' + - 'crates/pstr-meta/**' + - 'crates/pstr-stream/**' + - 'Cargo.toml' + - 'Cargo.lock' + - 'scripts/build-ios.sh' + - '.github/workflows/ios.yml' + workflow_dispatch: + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + +jobs: + build: + name: Unit tests and unsigned .ipa + runs-on: macos-15 + steps: + - uses: actions/checkout@v7 + + - id: toolchain + uses: dtolnay/rust-toolchain@stable + with: + toolchain: '1.96' + targets: aarch64-apple-ios,aarch64-apple-ios-sim + + - name: Restore the Rust target tree + uses: actions/cache@v6 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: rust-target-ios-${{ steps.toolchain.outputs.cachekey }}-${{ hashFiles('Cargo.lock') }} + restore-keys: rust-target-ios-${{ steps.toolchain.outputs.cachekey }}- + + - name: Install XcodeGen + run: brew install xcodegen + + - name: Bindings + run: scripts/build-ios.sh bindings + + - name: Unit tests on the simulator + run: scripts/build-ios.sh test + + # SideStore and AltStore re-sign whatever they install with the viewer's + # own Apple ID, so the release artifact is unsigned. + - name: Unsigned .ipa + run: | + scripts/build-ios.sh rust + scripts/build-ios.sh ipa + + - uses: actions/upload-artifact@v7 + with: + name: ios + path: dist/*.ipa + if-no-files-found: error diff --git a/README.md b/README.md index e608d09..1e8b509 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,10 @@ streaming implementation through UniFFI. It features a modern Material 3 interfa an immersive fullscreen player with auto-hiding controls, and offline downloads. Its toolchain, emulator, signing, and release instructions are in [`docs/ANDROID.md`](docs/ANDROID.md). +An iOS 17+ client under `ios/` is a SwiftUI port of the Android one over the same +Rust engine, built as an unsigned `.ipa` for SideStore or AltStore; see +[`docs/IOS.md`](docs/IOS.md). + ```bash cargo run --release -p pstr-app # the app proton-stream 'https://drive.proton.me/urls/TOKEN#fragment' # open the Add form with a link @@ -73,7 +77,7 @@ against the SDK's own range download. | UI | egui/eframe on glow; mpv renders into the same GL context. | | Themes | The shipped near-black palette, Catppuccin Latte, Frappé, Macchiato and Mocha, and a Persona 5 one — black, white and one loud red, which is what the Red accent is there for — each with a choice of nine accents, drawn as a gradient unless you turn that off. Picked on the Settings page, applied without a restart. | | Metadata | Filename parsing, with optional AniList/TMDB enrichment — **off by default**, because enabling it sends your library's titles to a third party. It brings posters, synopses, fanart and per-episode titles and synopses (for AniList, from ani.zip, which is sent the matched ids); a title the scorer refuses to guess about can be pinned by hand, and a pinned match is never overwritten. The library sorts by name, watch, add or air date, rating and popularity, folds a franchise into one tile, and shelves titles by director, studio and genre. See [docs/METADATA.md](docs/METADATA.md). | -| Platforms | Linux and Windows; macOS packaging is written but has not been run on a Mac. A native Android 12+ client is available featuring a Material 3 UI and immersive fullscreen playback. | +| Platforms | Linux and Windows; macOS packaging is written but has not been run on a Mac. A native Android 12+ client is available featuring a Material 3 UI and immersive fullscreen playback, and an iOS 17+ port of it for sideloading. | ## Building @@ -99,6 +103,9 @@ Android builds use JDK 17, Android SDK 36/NDK 28, Gradle 8.13, `cargo-ndk`, and UniFFI. Run `bash scripts/build-android.sh debug` after installing that toolchain; see [`docs/ANDROID.md`](docs/ANDROID.md) for exact setup and release signing. +iOS builds need macOS with Xcode, XcodeGen and the `aarch64-apple-ios` Rust +target: `scripts/build-ios.sh all` writes `dist/proton-stream--ios.ipa`. + ## Privacy - The share URL fragment **is** the decryption password. It and any custom @@ -121,9 +128,10 @@ see [`docs/ANDROID.md`](docs/ANDROID.md) for exact setup and release signing. ## License -The shared Rust crates and desktop application are MIT licensed. The Android -application and its combined native distribution are GPL-3.0-or-later; see -[`android/LICENSE.md`](android/LICENSE.md) and the bundled-component notices. +The shared Rust crates and desktop application are MIT licensed. The Android and +iOS applications and their combined native distributions are GPL-3.0-or-later; see +[`android/LICENSE.md`](android/LICENSE.md), [`ios/LICENSE.md`](ios/LICENSE.md) and +the bundled-component notices. The desktop application embeds the Inter typeface, under the SIL Open Font License 1.1 ([`crates/pstr-app/assets/fonts/Inter-OFL.txt`](crates/pstr-app/assets/fonts/Inter-OFL.txt)), and the Phosphor icon font, under MIT. diff --git a/docs/IOS.md b/docs/IOS.md new file mode 100644 index 0000000..f002e13 --- /dev/null +++ b/docs/IOS.md @@ -0,0 +1,85 @@ +# iOS Development + +The iOS client lives in `ios/`. It is a SwiftUI port of the Android client, screen +for screen and string for string, over the same Rust engine: `crates/pstr-android` +builds unchanged for `aarch64-apple-ios` as a static library, and UniFFI writes its +Swift bindings (module `PstrBridge`). It targets iOS 17 and newer on iPhone and +iPad. Keep reusable behaviour in the Rust crates; when Android changes, the iOS +file of the same name is the one to change with it. + +| Android | iOS | +|---|---| +| `ui/AppViewModel.kt` | `App/AppModel.swift` | +| `ui/ProtonStreamApp.kt` | `App/ProtonStreamApp.swift` (`RootView`) | +| `native/NativeRuntime.kt`, `native/KeystoreSecretStore.kt` | `App/NativeRuntime.swift`, `App/KeychainSecretStore.swift` | +| `settings/SettingsStore.kt` | `App/SettingsStore.swift` (same keys, in `UserDefaults`) | +| `ui/*Screen.kt`, `ui/AccountUi.kt`, `ui/LanguagePicker.kt` | `UI/*View.swift`, `UI/AccountUI.swift`, `UI/LanguagePicker.swift` | +| `ui/RemoteArtwork.kt`, `ui/theme/*` | `UI/RemoteArtwork.swift`, `UI/Theme/*` | +| `download/*` | `Download/*` | +| `playback/PlayerScreen.kt`, `NativeMpvHost.kt`, `PlaybackService.kt`, `StreamSession.kt` | `Player/PlayerView.swift`, `Player/PlayerHost.swift` | +| `cpp/pstr_mpv.cpp` | `Player/MpvPlayer.swift`, `Player/StreamProtocol.swift` | + +Playback is libmpv from [MPVKit](https://github.com/mpvkit/MPVKit)'s GPL build, +rendering through MoltenVK into a `CAMetalLayer`, with the `pstr://` stream protocol +reading decrypted blocks straight out of Rust as on Android. + +## Toolchain + +macOS with Xcode 16 or newer, XcodeGen, and Rust 1.96 with the iOS targets: + +```bash +brew install xcodegen +rustup target add aarch64-apple-ios aarch64-apple-ios-sim +``` + +The Xcode project and `Info.plist` are generated from `ios/project.yml` and are not +committed, nor are the bindings under `ios/build/generated`. + +```bash +scripts/build-ios.sh test # simulator static library, bindings, unit tests +scripts/build-ios.sh all # device static library, bindings, dist/proton-stream--ios.ipa +``` + +`all` is `rust`, `bindings` and `ipa` in order; each can be run on its own. After +`bindings` and an `xcodegen generate` in `ios/`, the project opens in Xcode as usual, +where a run on a device needs a development team set on the target. + +The `.ipa` is unsigned. SideStore and AltStore re-sign what they install with the +viewer's own Apple ID, which is also why the app has no extensions: a free Apple ID +gets few app IDs, and each extension costs one. + +`.github/workflows/ios.yml` runs the tests and builds the `.ipa` on `macos-15` for +every push to `main` and every pull request that touches the iOS sources or the Rust +crates under them. It is not yet part of `release.yml`: add it there, and the `ios` +artifact to `publish`, once it has passed on `main`. + +`scripts/render-ios-icon.sh` renders `ios/ProtonStream/Resources/AppIcon.svg`, which +is Android's adaptive launcher icon with both layers on one canvas, into the asset +catalog. + +## Where iOS differs + +Each of these is the platform's doing, not a choice to diverge. + +- **No Picture-in-Picture.** libmpv draws into a Metal layer, and AVKit's + Picture-in-Picture only takes frames from `AVPlayerLayer` or + `AVSampleBufferDisplayLayer`. Background audio and the lock-screen transport work + as on Android. +- **No share sheet.** A share extension would cost a second app ID. A link reaches + the app through its URL scheme — `protonstream://add?url=`, or the link with + `https` swapped for `protonstream` — or through **Paste link** in the Add share + form, which finds a link anywhere in the clipboard's text. +- **Downloads run while the app does.** A transfer is a Rust task, not a + `URLSession` task, so iOS has nothing to hand it to in the background. A download + keeps going for the grace iOS gives a backgrounded app, then stops at a block + boundary and is queued again; it resumes from its `.part` file when the app next + comes forward. "Download on Wi-Fi only" stops transfers on a cellular or + otherwise expensive network rather than holding them back. +- **Watch history syncs when the app leaves the screen**, inside the same grace, in + place of Android's `WatchSyncWorker`, and every five minutes while it is open. +- **Background audio off pauses** the episode when the app leaves the screen, where + Android stops it. +- **Nothing is backed up**, as on Android (`allowBackup="false"`). The share + secrets are this-device-only Keychain items, so a restored share list would + arrive without them, and a restored `sync.json` would give two devices one sync + identity. diff --git a/ios/LICENSE.md b/ios/LICENSE.md new file mode 100644 index 0000000..fa7cc53 --- /dev/null +++ b/ios/LICENSE.md @@ -0,0 +1,20 @@ +# proton-stream for iOS license + +SPDX-License-Identifier: GPL-3.0-or-later + +Copyright (c) proton-stream contributors. + +The iOS application under this directory is free software: you may +redistribute it and/or modify it under the terms of the GNU General Public +License as published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, but WITHOUT ANY +WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A +PARTICULAR PURPOSE. See the [GNU GPL version 3](https://www.gnu.org/licenses/gpl-3.0.html) +for the complete license text. + +This licensing choice applies to the iOS application and its distribution, +including the combined native player. Rust crates outside `ios/` retain the +licenses declared in their manifests; the repository root `LICENSE` remains the +MIT license for those components. diff --git a/ios/ProtonStream/App/AppModel.swift b/ios/ProtonStream/App/AppModel.swift index b20388f..a0dcf36 100644 --- a/ios/ProtonStream/App/AppModel.swift +++ b/ios/ProtonStream/App/AppModel.swift @@ -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? + @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(_ 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(_ 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 } diff --git a/ios/ProtonStream/App/IncomingShareLink.swift b/ios/ProtonStream/App/IncomingShareLink.swift new file mode 100644 index 0000000..d18d208 --- /dev/null +++ b/ios/ProtonStream/App/IncomingShareLink.swift @@ -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=`, 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 +} diff --git a/ios/ProtonStream/App/NativeRuntime.swift b/ios/ProtonStream/App/NativeRuntime.swift new file mode 100644 index 0000000..6875db8 --- /dev/null +++ b/ios/ProtonStream/App/NativeRuntime.swift @@ -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 +} diff --git a/ios/ProtonStream/App/ProtonStreamApp.swift b/ios/ProtonStream/App/ProtonStreamApp.swift index 3d4038c..56265fe 100644 --- a/ios/ProtonStream/App/ProtonStreamApp.swift +++ b/ios/ProtonStream/App/ProtonStreamApp.swift @@ -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() } } } } diff --git a/ios/ProtonStream/App/SettingsStore.swift b/ios/ProtonStream/App/SettingsStore.swift new file mode 100644 index 0000000..26ca77d --- /dev/null +++ b/ios/ProtonStream/App/SettingsStore.swift @@ -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" + } +} diff --git a/ios/ProtonStream/Download/DownloadCoordinator.swift b/ios/ProtonStream/Download/DownloadCoordinator.swift new file mode 100644 index 0000000..a919d68 --- /dev/null +++ b/ios/ProtonStream/Download/DownloadCoordinator.swift @@ -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! + + 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 } +} diff --git a/ios/ProtonStream/Download/DownloadStateStore.swift b/ios/ProtonStream/Download/DownloadStateStore.swift new file mode 100644 index 0000000..d3800ba --- /dev/null +++ b/ios/ProtonStream/Download/DownloadStateStore.swift @@ -0,0 +1,196 @@ +import Foundation + +/// Durable user-facing state for one offline download, kept until it finishes +/// or the viewer deletes it. The fields and their JSON names are Android's +/// `RetainedDownload`. +struct RetainedDownload: Equatable, Hashable, Identifiable, Sendable { + var shareId: String + var volumeId: String + var linkId: String + var label: String + var downloaded: Int64 = 0 + var total: Int64 = 0 + var status: String = RetainedDownload.statusQueued + var error: String? + /// Recent transfer rate while running; 0 when unknown or not running. + var bytesPerSecond: Int64 = 0 + + var key: String { downloadKey(shareId, linkId) } + var id: String { key } + + static let statusQueued = "queued" + static let statusRunning = "running" + static let statusPaused = "paused" + static let statusFailed = "failed" + static let statusCancelled = "cancelled" +} + +func downloadKey(_ shareId: String, _ linkId: String) -> String { "\(shareId)\u{1f}\(linkId)" } + +/// The retained downloads, as one JSON file of `key → record` beside the +/// catalog — Android keeps the same records in SharedPreferences. +/// +/// Every write goes through one lock: the transfer's progress reports and the +/// viewer's pause are concurrent writers to the same record, and a whole-record +/// `put` from a screen's stale copy would both lose that progress and race the +/// transfer's own read-modify-write. +final class DownloadStateStore: @unchecked Sendable { + static let shared = DownloadStateStore(file: DownloadStateStore.defaultFile) + + /// Called after every write, on whichever thread wrote. + var onChange: (() -> Void)? + + private let file: URL + private let lock = NSLock() + private var entries: [String: String]? + + init(file: URL) { + self.file = file + } + + func records() -> [RetainedDownload] { + lock.withLock { loaded().values.compactMap(Self.decode) } + .sorted { $0.label.lowercased() < $1.label.lowercased() } + } + + func get(_ shareId: String, _ linkId: String) -> RetainedDownload? { + lock.withLock { loaded()[downloadKey(shareId, linkId)].flatMap(Self.decode) } + } + + func put(_ record: RetainedDownload) { + edit { $0[record.key] = Self.encode(record) } + } + + /// Read-modify-write one record, excluding every other writer. Returning + /// nil from `change` leaves the record alone. + func update(_ shareId: String, _ linkId: String, _ change: (RetainedDownload?) -> RetainedDownload?) { + edit { entries in + let key = downloadKey(shareId, linkId) + if let changed = change(entries[key].flatMap(Self.decode)) { + entries[changed.key] = Self.encode(changed) + } + } + } + + /// Write a whole queue in one go: queueing a season is one write, not one + /// per episode. + func putAll(_ records: [RetainedDownload]) { + edit { entries in + for record in records { entries[record.key] = Self.encode(record) } + } + } + + func remove(_ shareId: String, _ linkId: String) { + edit { $0[downloadKey(shareId, linkId)] = nil } + } + + /// Forget every retained download. Callers stop the transfers first. + func clear() { + edit { $0.removeAll() } + } + + func removeShare(_ shareId: String) { + edit { entries in + entries = entries.filter { Self.decode($0.value)?.shareId != shareId } + } + } + + private func edit(_ change: (inout [String: String]) -> Void) { + lock.withLock { + var current = loaded() + change(¤t) + entries = current + // Atomic, so a kill mid-write leaves the last good file. + if let data = try? JSONEncoder().encode(current) { + try? data.write(to: file, options: [.atomic]) + } + } + onChange?() + } + + /// Under `lock`. + private func loaded() -> [String: String] { + if let entries { return entries } + let read = (try? Data(contentsOf: file)).flatMap { try? JSONDecoder().decode([String: String].self, from: $0) } ?? [:] + entries = read + return read + } + + static func encode(_ record: RetainedDownload) -> String { + var json: [String: Any] = [ + "share": record.shareId, + "volume": record.volumeId, + "link": record.linkId, + "label": record.label, + "downloaded": record.downloaded, + "total": record.total, + "status": record.status, + "rate": record.bytesPerSecond, + ] + if let error = record.error { json["error"] = error } + let data = (try? JSONSerialization.data(withJSONObject: json, options: [.sortedKeys])) ?? Data() + return String(decoding: data, as: UTF8.self) + } + + /// A row that does not decode is skipped rather than failing the list. + static func decode(_ encoded: String) -> RetainedDownload? { + guard let object = try? JSONSerialization.jsonObject(with: Data(encoded.utf8)), + let json = object as? [String: Any], + let share = json["share"] as? String, + let volume = json["volume"] as? String, + let link = json["link"] as? String + else { return nil } + let error = (json["error"] as? String).flatMap { $0.trimmingCharacters(in: .whitespaces).isEmpty ? nil : $0 } + return RetainedDownload( + shareId: share, + volumeId: volume, + linkId: link, + label: json["label"] as? String ?? link, + downloaded: (json["downloaded"] as? NSNumber)?.int64Value ?? 0, + total: (json["total"] as? NSNumber)?.int64Value ?? 0, + status: json["status"] as? String ?? RetainedDownload.statusQueued, + error: error, + bytesPerSecond: (json["rate"] as? NSNumber)?.int64Value ?? 0 + ) + } + + /// In a directory of its own, excluded from backup like the catalog: an + /// atomic write replaces the file, so the flag has to sit on its parent. + private static var defaultFile: URL { + let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + var directory = support.appendingPathComponent("state", isDirectory: true) + try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + var excluded = URLResourceValues() + excluded.isExcludedFromBackup = true + try? directory.setResourceValues(excluded) + return directory.appendingPathComponent("offline-downloads.json") + } +} + +/// Bytes per second over roughly the last few seconds. +/// +/// Progress arrives once per 4 MiB block, so on a slow link the bar moves in +/// steps seconds apart; a rate beside it is what tells a slow download from a +/// stuck one. Smoothed, because one block that took a little longer than the +/// last would otherwise halve the figure for a moment. +struct TransferRate { + private var lastBytes: Int64 = -1 + private var lastAt: Int64 = 0 + private var smoothed = 0.0 + + /// Record `bytes` transferred at `now` (milliseconds) and return the rate. + mutating func sample(_ bytes: Int64, now: Int64) -> Int64 { + if lastBytes < 0 || bytes < lastBytes { + lastBytes = bytes + lastAt = now + return Int64(smoothed) + } + let elapsed = now - lastAt + if elapsed <= 0 { return Int64(smoothed) } + let instant = Double(bytes - lastBytes) * 1000 / Double(elapsed) + smoothed = smoothed > 0 ? 0.7 * smoothed + 0.3 * instant : instant + lastBytes = bytes + lastAt = now + return Int64(smoothed) + } +} diff --git a/ios/ProtonStream/Player/MpvPlayer.swift b/ios/ProtonStream/Player/MpvPlayer.swift index 6eb1a1a..463f9dd 100644 --- a/ios/ProtonStream/Player/MpvPlayer.swift +++ b/ios/ProtonStream/Player/MpvPlayer.swift @@ -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://`. 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://` 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 } } diff --git a/ios/ProtonStream/Player/PlayerHost.swift b/ios/ProtonStream/Player/PlayerHost.swift new file mode 100644 index 0000000..26428f6 --- /dev/null +++ b/ios/ProtonStream/Player/PlayerHost.swift @@ -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? + @ObservationIgnored private var saveTask: Task? + @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() + @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) + } + } + } +} diff --git a/ios/ProtonStream/Player/PlayerView.swift b/ios/ProtonStream/Player/PlayerView.swift new file mode 100644 index 0000000..ddc4734 --- /dev/null +++ b/ios/ProtonStream/Player/PlayerView.swift @@ -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? + @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)) + } +} diff --git a/ios/ProtonStream/Resources/AppIcon.svg b/ios/ProtonStream/Resources/AppIcon.svg new file mode 100644 index 0000000..ebbfb93 --- /dev/null +++ b/ios/ProtonStream/Resources/AppIcon.svg @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/ProtonStream/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png b/ios/ProtonStream/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png new file mode 100644 index 0000000000000000000000000000000000000000..06702904e79ff00d17f6fe233aec56fcabc85017 GIT binary patch literal 52742 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 literal 0 HcmV?d00001 diff --git a/ios/ProtonStream/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/ProtonStream/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..f22e10c --- /dev/null +++ b/ios/ProtonStream/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "AppIcon-1024.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/ProtonStream/Resources/Assets.xcassets/Contents.json b/ios/ProtonStream/Resources/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/ios/ProtonStream/Resources/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/ProtonStream/Resources/Assets.xcassets/LaunchBackground.colorset/Contents.json b/ios/ProtonStream/Resources/Assets.xcassets/LaunchBackground.colorset/Contents.json new file mode 100644 index 0000000..c0e21c0 --- /dev/null +++ b/ios/ProtonStream/Resources/Assets.xcassets/LaunchBackground.colorset/Contents.json @@ -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 + } +} diff --git a/ios/ProtonStream/Resources/THIRD_PARTY_NOTICES.md b/ios/ProtonStream/Resources/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..49fcef8 --- /dev/null +++ b/ios/ProtonStream/Resources/THIRD_PARTY_NOTICES.md @@ -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. diff --git a/ios/ProtonStream/UI/AccountUI.swift b/ios/ProtonStream/UI/AccountUI.swift new file mode 100644 index 0000000..982b970 --- /dev/null +++ b/ios/ProtonStream/UI/AccountUI.swift @@ -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) + } +} diff --git a/ios/ProtonStream/UI/Common.swift b/ios/ProtonStream/UI/Common.swift new file mode 100644 index 0000000..29d74e0 --- /dev/null +++ b/ios/ProtonStream/UI/Common.swift @@ -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: 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: 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: 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) + } +} diff --git a/ios/ProtonStream/UI/DownloadsView.swift b/ios/ProtonStream/UI/DownloadsView.swift new file mode 100644 index 0000000..22bb341 --- /dev/null +++ b/ios/ProtonStream/UI/DownloadsView.swift @@ -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) + } +} diff --git a/ios/ProtonStream/UI/HistoryView.swift b/ios/ProtonStream/UI/HistoryView.swift new file mode 100644 index 0000000..90854f3 --- /dev/null +++ b/ios/ProtonStream/UI/HistoryView.swift @@ -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))" } ?? "") + } +} diff --git a/ios/ProtonStream/UI/LanguagePicker.swift b/ios/ProtonStream/UI/LanguagePicker.swift new file mode 100644 index 0000000..9f2bd1b --- /dev/null +++ b/ios/ProtonStream/UI/LanguagePicker.swift @@ -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 : []) + } +} diff --git a/ios/ProtonStream/UI/LibraryView.swift b/ios/ProtonStream/UI/LibraryView.swift new file mode 100644 index 0000000..204ffb5 --- /dev/null +++ b/ios/ProtonStream/UI/LibraryView.swift @@ -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: 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 } +} diff --git a/ios/ProtonStream/UI/RemoteArtwork.swift b/ios/ProtonStream/UI/RemoteArtwork.swift new file mode 100644 index 0000000..deba0bd --- /dev/null +++ b/ios/ProtonStream/UI/RemoteArtwork.swift @@ -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 = { + let cache = NSCache() + 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() + 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 + } +} diff --git a/ios/ProtonStream/UI/SettingsView.swift b/ios/ProtonStream/UI/SettingsView.swift new file mode 100644 index 0000000..67e1b49 --- /dev/null +++ b/ios/ProtonStream/UI/SettingsView.swift @@ -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 { + 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" + } + } +} diff --git a/ios/ProtonStream/UI/SharesView.swift b/ios/ProtonStream/UI/SharesView.swift new file mode 100644 index 0000000..5c88e76 --- /dev/null +++ b/ios/ProtonStream/UI/SharesView.swift @@ -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() + } +} diff --git a/ios/ProtonStream/UI/Theme/Accent.swift b/ios/ProtonStream/UI/Theme/Accent.swift new file mode 100644 index 0000000..ed0d56b --- /dev/null +++ b/ios/ProtonStream/UI/Theme/Accent.swift @@ -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 + 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) +} diff --git a/ios/ProtonStream/UI/Theme/Theme.swift b/ios/ProtonStream/UI/Theme/Theme.swift new file mode 100644 index 0000000..298ad79 --- /dev/null +++ b/ios/ProtonStream/UI/Theme/Theme.swift @@ -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 +} diff --git a/ios/ProtonStream/UI/Theme/Typography.swift b/ios/ProtonStream/UI/Theme/Typography.swift new file mode 100644 index 0000000..8af26f6 --- /dev/null +++ b/ios/ProtonStream/UI/Theme/Typography.swift @@ -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) + } +} diff --git a/ios/ProtonStream/UI/TitleView.swift b/ios/ProtonStream/UI/TitleView.swift new file mode 100644 index 0000000..a857bce --- /dev/null +++ b/ios/ProtonStream/UI/TitleView.swift @@ -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() + } +} diff --git a/ios/ProtonStreamTests/DownloadTests.swift b/ios/ProtonStreamTests/DownloadTests.swift new file mode 100644 index 0000000..8bd2a49 --- /dev/null +++ b/ios/ProtonStreamTests/DownloadTests.swift @@ -0,0 +1,187 @@ +@testable import ProtonStream +import XCTest + +final class DownloadStateStoreTests: XCTestCase { + private var file: URL! + private var store: DownloadStateStore! + + override func setUp() { + file = FileManager.default.temporaryDirectory.appendingPathComponent("downloads-\(UUID().uuidString).json") + store = DownloadStateStore(file: file) + } + + override func tearDown() { + try? FileManager.default.removeItem(at: file) + } + + private func record( + shareId: String = "share-1", + linkId: String = "link-1", + label: String = "S01E01.mkv", + status: String = RetainedDownload.statusRunning, + error: String? = nil + ) -> RetainedDownload { + RetainedDownload( + shareId: shareId, + volumeId: "volume-1", + linkId: linkId, + label: label, + downloaded: 4 * 1024 * 1024, + total: 761 * 1024 * 1024, + status: status, + error: error + ) + } + + private func baseJson() -> [String: Any] { + [ + "share": "share-1", + "volume": "volume-1", + "link": "link-1", + "label": "S01E01.mkv", + "downloaded": 0, + "total": 0, + "status": RetainedDownload.statusQueued, + ] + } + + /// A row as an older or another writer left it, read by a fresh store. + private func writeRaw(_ rows: [String: String]) throws { + try JSONEncoder().encode(rows).write(to: file) + store = DownloadStateStore(file: file) + } + + private func encoded(_ json: [String: Any]) throws -> String { + String(decoding: try JSONSerialization.data(withJSONObject: json), as: UTF8.self) + } + + func testARecordSurvivesARoundTripUnchanged() { + let original = record() + store.put(original) + XCTAssertEqual(store.get("share-1", "link-1"), original) + } + + func testARecordSurvivesARestartUnchanged() { + let original = record() + store.put(original) + XCTAssertEqual(DownloadStateStore(file: file).get("share-1", "link-1"), original) + } + + func testAnAbsentRecordReadsAsNilRatherThanADefault() { + XCTAssertNil(store.get("share-1", "link-1")) + } + + func testAnErrorMessageRoundTripsAndANilOneStaysNil() { + store.put(record(error: "no space left on device")) + XCTAssertEqual(store.get("share-1", "link-1")?.error, "no space left on device") + store.put(record(error: nil)) + XCTAssertNil(store.get("share-1", "link-1")?.error) + } + + func testABlankErrorIsNotReadAsAFailureMessage() throws { + var json = baseJson() + json["error"] = "" + try writeRaw([downloadKey("share-1", "link-1"): encoded(json)]) + XCTAssertNil(store.get("share-1", "link-1")?.error) + } + + func testARecordWithNoLabelIsCaptionedWithItsLinkId() throws { + var json = baseJson() + json["label"] = nil + try writeRaw([downloadKey("share-1", "link-1"): encoded(json)]) + XCTAssertEqual(store.get("share-1", "link-1")?.label, "link-1") + } + + func testACorruptRowIsSkippedRatherThanFailingTheWholeList() throws { + try writeRaw([ + downloadKey("share-1", "link-1"): DownloadStateStore.encode(record(label: "good.mkv")), + downloadKey("share-1", "link-broken"): "{ not json", + ]) + XCTAssertEqual(store.records().map(\.label), ["good.mkv"]) + } + + func testRecordsAreOrderedByLabelWithoutRegardToCase() { + store.put(record(linkId: "a", label: "beta.mkv")) + store.put(record(linkId: "b", label: "Alpha.mkv")) + XCTAssertEqual(store.records().map(\.label), ["Alpha.mkv", "beta.mkv"]) + } + + func testRemovingOneShareLeavesTheOthersAlone() { + store.put(record(shareId: "share-1", linkId: "a")) + store.put(record(shareId: "share-2", linkId: "b")) + store.removeShare("share-1") + XCTAssertEqual(store.records().map(\.shareId), ["share-2"]) + } + + func testClearForgetsEverything() { + store.put(record(linkId: "a")) + store.put(record(linkId: "b")) + store.clear() + XCTAssertTrue(store.records().isEmpty) + } + + func testTheKeySeparatesShareFromLinkSoIdsCannotRunTogether() { + store.put(record(shareId: "a", linkId: "b-c")) + store.put(record(shareId: "a-b", linkId: "c")) + XCTAssertEqual(store.records().count, 2) + } + + func testAnUpdateThatReturnsNilLeavesTheRecordAlone() { + store.put(record(status: RetainedDownload.statusPaused)) + store.update("share-1", "link-1") { _ in nil } + XCTAssertEqual(store.get("share-1", "link-1")?.status, RetainedDownload.statusPaused) + } +} + +final class TransferRateTests: XCTestCase { + func testTheFirstSampleHasNoRateToReport() { + var rate = TransferRate() + XCTAssertEqual(rate.sample(4 * 1024 * 1024, now: 1000), 0) + } + + func testASteadyTransferReportsItsRate() { + var rate = TransferRate() + _ = rate.sample(0, now: 0) + XCTAssertEqual(rate.sample(1_000_000, now: 1000), 1_000_000) + XCTAssertEqual(rate.sample(2_000_000, now: 2000), 1_000_000) + } + + func testOneSlowBlockMovesTheRateWithoutHalvingIt() { + var rate = TransferRate() + _ = rate.sample(0, now: 0) + _ = rate.sample(1_000_000, now: 1000) + let after = rate.sample(2_000_000, now: 3000) + XCTAssertTrue((600_000 ... 999_999).contains(after)) + } + + func testAResumedDownloadStartingBelowTheLastSampleStartsOver() { + var rate = TransferRate() + _ = rate.sample(0, now: 0) + _ = rate.sample(1_000_000, now: 1000) + XCTAssertEqual(rate.sample(0, now: 2000), 1_000_000) + } +} + +final class DownloadStatusLineTests: XCTestCase { + func testARunningDownloadSaysHowFarAndHowFast() { + let download = RetainedDownload( + shareId: "s", volumeId: "v", linkId: "l", label: "E01", + downloaded: 120 * 1024 * 1024, total: 1288 * 1024 * 1024, + status: RetainedDownload.statusRunning, bytesPerSecond: 2_516_582 + ) + XCTAssertEqual(downloadStatusLine(download), "Running · 120.0 MiB of 1.3 GiB · 2.4 MiB/s") + } + + func testAQueuedDownloadSaysOnlyThat() { + let download = RetainedDownload(shareId: "s", volumeId: "v", linkId: "l", label: "E01") + XCTAssertEqual(downloadStatusLine(download), "Queued") + } + + func testAPausedDownloadGivesNoRate() { + let download = RetainedDownload( + shareId: "s", volumeId: "v", linkId: "l", label: "E01", + downloaded: 1024, total: 2048, status: RetainedDownload.statusPaused, bytesPerSecond: 500 + ) + XCTAssertEqual(downloadStatusLine(download), "Paused · 1.0 KiB of 2.0 KiB") + } +} diff --git a/ios/ProtonStreamTests/UiLogicTests.swift b/ios/ProtonStreamTests/UiLogicTests.swift new file mode 100644 index 0000000..fb3cb94 --- /dev/null +++ b/ios/ProtonStreamTests/UiLogicTests.swift @@ -0,0 +1,269 @@ +@testable import ProtonStream +import SwiftUI +import XCTest + +private func episode(_ link: String, watched: Bool = false, progress: Double? = nil, lastPlayed: Int64 = 0, number: UInt32? = nil) -> EpisodeRecord { + EpisodeRecord( + shareId: "s", volumeId: "v", linkId: link, name: "\(link).mkv", label: link, detail: link, + season: 1, number: number, size: nil, progress: progress, resumeAt: progress.map { $0 * 1000 }, + watched: watched, offline: false, providerName: nil, providerOverview: nil, stillUrl: nil, + airDate: nil, lastPlayed: lastPlayed + ) +} + +private func title(_ key: String, _ episodes: [EpisodeRecord]) -> TitleRecord { + TitleRecord( + key: key, name: key, year: nil, kind: .series, watchedCount: 0, + episodeCount: UInt64(episodes.count), canonicalName: nil, originalName: nil, overview: nil, + metadataProvider: nil, metadataId: nil, metadataYear: nil, metadataKind: nil, posterUrl: nil, + backdropUrl: nil, rating: nil, genres: [], providerEpisodeCount: nil, externalUrl: nil, + manualMatch: false, seasons: [SeasonRecord(number: 1, label: "Season 1", episodes: episodes)], + displayName: key, wideUrl: nil, formatLabel: nil, seasonLabel: nil, studios: [], + directors: [], tags: [], airing: false, nextEpisode: nil, nextAiringAt: nil, + franchise: [] + ) +} + +final class IncomingShareLinkTests: XCTestCase { + private let link = "https://drive.proton.me/urls/ABC123#s3cr3t" + + func testTheSchemeSwappedLinkKeepsItsFragment() { + XCTAssertEqual(shareLink(from: URL(string: "protonstream://drive.proton.me/urls/ABC123#s3cr3t")!), link) + } + + func testTheAddFormCarriesTheWholeLink() { + let encoded = link.addingPercentEncoding(withAllowedCharacters: .alphanumerics)! + XCTAssertEqual(shareLink(from: URL(string: "protonstream://add?url=\(encoded)")!), link) + } + + func testAnythingButAShareLinkIsIgnored() { + XCTAssertNil(shareLink(from: URL(string: "https://drive.proton.me/urls/ABC123#s3cr3t")!)) + XCTAssertNil(shareLink(from: URL(string: "protonstream://example.com/urls/ABC")!)) + XCTAssertNil(shareLink(from: URL(string: "protonstream://add?url=http%3A%2F%2Fdrive.proton.me%2Furls%2FA")!)) + } + + func testALinkIsFoundInsideTextAndStopsAtItsQuotes() { + XCTAssertEqual(shareLink(in: "Watch this: \"\(link)\" — enjoy"), link) + XCTAssertNil(shareLink(in: "no link here")) + } +} + +final class AccountUiTests: XCTestCase { + func testTheSyncLineSaysHowLongAgoAndWhatCameIn() { + let zero = Date(timeIntervalSince1970: 0) + XCTAssertEqual(syncLine(AccountUiState(), now: zero), "Watch history syncs with your Drive") + XCTAssertEqual(syncLine(AccountUiState(syncedAt: zero), now: zero.addingTimeInterval(30)), "Synced just now") + XCTAssertEqual(syncLine(AccountUiState(syncedAt: zero), now: zero.addingTimeInterval(300)), "Synced 5 min ago") + XCTAssertEqual( + syncLine(AccountUiState(syncedAt: zero, applied: 3), now: zero.addingTimeInterval(7200)), + "Synced 2 h ago · 3 from other devices" + ) + } + + func testAFailedSyncIsSaidInPlaceOfWhenItLastWorked() { + let zero = Date(timeIntervalSince1970: 0) + XCTAssertEqual( + syncLine(AccountUiState(syncedAt: zero, syncError: "offline"), now: zero), + "Watch history did not sync: offline" + ) + } + + func testTheVerificationPageMayOnlyNavigateWithinProtonOverHttps() { + XCTAssertTrue(isProtonPage(scheme: "https", host: "verify.proton.me")) + XCTAssertTrue(isProtonPage(scheme: "https", host: "proton.me")) + XCTAssertFalse(isProtonPage(scheme: "http", host: "verify.proton.me")) + XCTAssertFalse(isProtonPage(scheme: "https", host: "proton.me.example.com")) + XCTAssertFalse(isProtonPage(scheme: "https", host: "notproton.me")) + XCTAssertFalse(isProtonPage(scheme: "https", host: nil)) + } +} + +final class ArtworkTests: XCTestCase { + func testInitialsSkipTheLowerCaseParticlesOfARomanisedName() { + XCTAssertEqual(initials("Oshi no Ko"), "OK") + XCTAssertEqual(initials("Ano Hi Mita Hana no Namae o Bokutachi wa Mada Shiranai."), "AH") + } + + func testAOneWordNameHasOneInitial() { + XCTAssertEqual(initials("Akira"), "A") + } + + func testANameWithNoCapitalsFallsBackToItsFirstTwoWords() { + XCTAssertEqual(initials("blue world order"), "BW") + } + + func testPunctuationAndSeparatorsAreNotWords() { + XCTAssertEqual(initials("Steins;Gate - 2"), "S2") + XCTAssertEqual(initials(" - "), "") + } + + /// The placeholder's colour comes from the name's hash, so a title is the + /// same colour here as on Android only if the hash is the JVM's. + func testTheNameHashIsTheJvmStringHash() { + XCTAssertEqual(javaHash(""), 0) + XCTAssertEqual(javaHash("Akira"), 63_321_038) + XCTAssertEqual(javaHash("Oshi no Ko"), 539_783_784) + } +} + +final class HistoryTests: XCTestCase { + func testHistoryIsEveryStartedOrFinishedEpisodeNewestFirstAcrossTitles() { + let titles = [ + title("a", [ + episode("a1", watched: true, progress: 1, lastPlayed: 30), + // Marked unwatched again: a row at zero, which is not a play. + episode("a2", progress: 0, lastPlayed: 40), + episode("a3"), + ]), + title("b", [episode("b1", progress: 0.4, lastPlayed: 50)]), + ] + let played = history(titles) + XCTAssertEqual(played.map(\.episode.linkId), ["b1", "a1"]) + XCTAssertEqual(played.last?.index, 0) + } + + func testDaysAreHeadedRelativeToTodayThenByDate() { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "UTC")! + let english = Locale(identifier: "en_GB") + func day(_ year: Int, _ month: Int, _ day: Int) -> Date { + calendar.date(from: DateComponents(year: year, month: month, day: day))! + } + let today = day(2026, 10, 3) // a Saturday + XCTAssertEqual(dayHeading(today, today: today, calendar: calendar, locale: english), "Today") + XCTAssertEqual(dayHeading(day(2026, 10, 2), today: today, calendar: calendar, locale: english), "Yesterday") + XCTAssertEqual(dayHeading(day(2026, 9, 28), today: today, calendar: calendar, locale: english), "Monday") + XCTAssertEqual(dayHeading(day(2026, 9, 26), today: today, calendar: calendar, locale: english), "26 September") + XCTAssertEqual(dayHeading(day(2025, 10, 3), today: today, calendar: calendar, locale: english), "3 October 2025") + } +} + +final class CommonTests: XCTestCase { + func testPlayLandsOnTheLastPartWatchedEpisodeThenTheFirstUnwatched() { + XCTAssertEqual(nextUpIndex([episode("1", watched: true), episode("2", progress: 0.5, lastPlayed: 10), episode("3", progress: 0.2, lastPlayed: 20)]), 2) + XCTAssertEqual(nextUpIndex([episode("1", watched: true), episode("2"), episode("3")]), 1) + XCTAssertEqual(nextUpIndex([episode("1", watched: true)]), 0) + XCTAssertEqual(nextUpIndex([]), 0) + } + + func testBytesAreWrittenInBinaryUnitsToOnePlace() { + XCTAssertEqual(formatBytes(512), "512 bytes") + XCTAssertEqual(formatBytes(1536), "1.5 KiB") + XCTAssertEqual(formatBytes(4 * 1024 * 1024), "4.0 MiB") + XCTAssertEqual(formatBytes(3 * 1024 * 1024 * 1024 / 2), "1.5 GiB") + } + + func testAnEpisodeIsHeadedByItsNameAndNumberBeforeItsFilename() { + var named = episode("e", number: 3) + named.providerName = "Mother and Children" + XCTAssertEqual(named.heading(0), "3. Mother and Children") + XCTAssertEqual(episode("e", number: 7).heading(0), "7. e") + var bare = episode("e") + bare.detail = bare.name + XCTAssertEqual(bare.heading(0), "e") + bare.number = nil + bare.name = ".mkv" + bare.detail = bare.name + XCTAssertEqual(bare.heading(2), "Episode 3") + bare.name = "e.mkv" + bare.detail = bare.name + bare.number = 4 + XCTAssertEqual(bare.heading(0), "Episode 4") + } + + func testEpisodesAreNumberedBySeasonThenByPlaceInTheList() { + var numbered = episode("e", number: 38) + numbered.season = 3 + XCTAssertEqual(numbered.numbering(nil, 0), "S03E38") + numbered.season = nil + XCTAssertEqual(numbered.numbering(nil, 0), "E38") + XCTAssertEqual(numbered.numbering(2, 0), "S02E38") + numbered.number = nil + XCTAssertEqual(numbered.numbering(nil, 3), "#4") + } + + func testClockReadsMinutesThenHours() { + XCTAssertEqual(clock(0), "0:00") + XCTAssertEqual(clock(724), "12:04") + XCTAssertEqual(clock(3725), "1:02:05") + } +} + +final class LanguagePickerTests: XCTestCase { + func testAStoredTagReadsAsItsLanguageInEitherIsoForm() { + XCTAssertEqual(languageLabel("jpn"), "Japanese") + XCTAssertEqual(languageLabel("ja"), "Japanese") + XCTAssertEqual(languageLabel("deu"), "German") + XCTAssertEqual(languageLabel("ger"), "German") + } + + func testARegionSuffixAndCapitalsDoNotHideTheLanguage() { + XCTAssertEqual(languageLabel("PT-br"), "Portuguese") + } + + func testATagNotOnTheListIsShownAsTypedAndBlankIsNoPreference() { + XCTAssertEqual(languageLabel("tgl"), "tgl") + XCTAssertEqual(languageLabel(nil), "No preference") + XCTAssertEqual(languageLabel(" "), "No preference") + } + + func testEveryListedLanguageHasATwoLetterForm() { + for language in languages { + XCTAssertEqual(languageLabel(twoLetter[language.code]), language.name, language.code) + } + } +} + +final class ThemeTests: XCTestCase { + /// The same mapping as Android's `schemeOf`: a role that drifted to a + /// different palette colour on one client is the two clients disagreeing. + func testEveryRoleIsTheSamePaletteColourAndroidUses() { + let palette = protonFallback + let scheme = Scheme(palette) + XCTAssertEqual(scheme.primary, argb(palette.accent)) + XCTAssertEqual(scheme.onPrimary, argb(palette.onAccent)) + XCTAssertEqual(scheme.secondary, argb(palette.accentAlt)) + XCTAssertEqual(scheme.secondaryContainer, argb(palette.accentDim)) + XCTAssertEqual(scheme.tertiary, argb(palette.accentAlt)) + XCTAssertEqual(scheme.background, argb(palette.background)) + XCTAssertEqual(scheme.surface, argb(palette.surface)) + XCTAssertEqual(scheme.onSurface, argb(palette.text)) + XCTAssertEqual(scheme.onSurfaceVariant, argb(palette.muted)) + XCTAssertEqual(scheme.inverseSurface, argb(palette.text)) + XCTAssertEqual(scheme.inverseOnSurface, argb(palette.background)) + XCTAssertEqual(scheme.error, argb(palette.danger)) + XCTAssertEqual(scheme.errorContainer, argb(palette.dangerDim)) + XCTAssertEqual(scheme.outline, argb(palette.muted)) + XCTAssertEqual(scheme.outlineVariant, argb(palette.border)) + XCTAssertEqual(scheme.surfaceContainerLowest, argb(palette.sunken)) + XCTAssertEqual(scheme.surfaceContainer, argb(palette.card)) + XCTAssertEqual(scheme.surfaceContainerHigh, argb(palette.cardHover)) + XCTAssertEqual(scheme.surfaceContainerHighest, argb(palette.elevated)) + XCTAssertFalse(scheme.light) + } + + func testArgbUnpacksEveryChannel() { + let colour = UIColor(argb(0x80FF_4000)) + var red: CGFloat = 0, green: CGFloat = 0, blue: CGFloat = 0, alpha: CGFloat = 0 + colour.getRed(&red, green: &green, blue: &blue, alpha: &alpha) + XCTAssertEqual(red, 1, accuracy: 0.001) + XCTAssertEqual(green, 64 / 255, accuracy: 0.001) + XCTAssertEqual(blue, 0, accuracy: 0.001) + XCTAssertEqual(alpha, 128 / 255, accuracy: 0.001) + } + + /// Every style on one of the nine rungs, every rung used, and leading + /// derived from the size — Android's `TypeRampTest`. + func testEveryStyleIsOnTheDesktopRamp() { + let rungs: Set = [26, 20, 18, 17, 15, 14, 13, 12, 11] + let used = Set(TypeRole.allCases.map(\.size)) + XCTAssertEqual(used, rungs) + } + + func testOnlyHeadingsAreSemibold() { + XCTAssertEqual( + Set(TypeRole.allCases.filter(\.semibold)), + [.displayLarge, .displayMedium, .displaySmall, .headlineLarge, .headlineMedium, .headlineSmall, .titleLarge] + ) + } +} diff --git a/ios/project.yml b/ios/project.yml index b50f969..c786177 100644 --- a/ios/project.yml +++ b/ios/project.yml @@ -17,8 +17,24 @@ targets: platform: iOS sources: - path: ProtonStream + excludes: + # The icon's source; the asset catalog holds what it renders to. + - Resources/AppIcon.svg + - Resources/THIRD_PARTY_NOTICES.md + - path: ProtonStream/Resources/THIRD_PARTY_NOTICES.md + buildPhase: resources # UniFFI output, written by `scripts/build-ios.sh bindings`. - path: build/generated/PstrBridge.swift + # Shared with the Android client rather than copied: Inter, and the + # licence texts the About page shows. + - path: ../android/app/src/main/res/font/inter_regular.ttf + buildPhase: resources + - path: ../android/app/src/main/res/font/inter_semibold.ttf + buildPhase: resources + - path: ../android/app/src/main/assets/licenses/GPL-3.0.txt + buildPhase: resources + - path: ../android/app/src/main/assets/licenses/Inter-OFL.txt + buildPhase: resources dependencies: # The GPL build is the one with libass and every demuxer; the app is # sideloaded, never distributed through the App Store. @@ -26,6 +42,9 @@ targets: product: MPVKit-GPL - sdk: Security.framework - sdk: SystemConfiguration.framework + scheme: + testTargets: + - ProtonStreamTests settings: base: PRODUCT_BUNDLE_IDENTIFIER: io.narl.protonstream @@ -33,9 +52,11 @@ targets: CURRENT_PROJECT_VERSION: ${PSTR_BUILD} SWIFT_VERSION: "5.0" TARGETED_DEVICE_FAMILY: "1,2" + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon SWIFT_OBJC_BRIDGING_HEADER: ProtonStream/Bridge/ProtonStream-Bridging-Header.h HEADER_SEARCH_PATHS: $(PROJECT_DIR)/build/generated - LIBRARY_SEARCH_PATHS: $(PROJECT_DIR)/../target/aarch64-apple-ios/release + "LIBRARY_SEARCH_PATHS[sdk=iphoneos*]": $(PROJECT_DIR)/../target/aarch64-apple-ios/release + "LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*]": $(PROJECT_DIR)/../target/aarch64-apple-ios-sim/release OTHER_LDFLAGS: -lpstr_android info: path: ProtonStream/Info.plist @@ -44,8 +65,18 @@ targets: LSRequiresIPhoneOS: true ITSAppUsesNonExemptEncryption: false UIBackgroundModes: [audio] - UILaunchScreen: {} + # The Proton palette's page colour, so a dark app does not open on white. + UILaunchScreen: + UIColorName: LaunchBackground UIRequiresFullScreen: false + UIAppFonts: + - inter_regular.ttf + - inter_semibold.ttf + # A share link handed over from elsewhere: protonstream://add?url=…, + # or the link with its scheme swapped (IncomingShareLink.swift). + CFBundleURLTypes: + - CFBundleURLName: io.narl.protonstream.share-link + CFBundleURLSchemes: [protonstream] UISupportedInterfaceOrientations: - UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft @@ -55,3 +86,17 @@ targets: - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight + + # The pure logic the Android unit tests pin, pinned again here: hosted in the + # app, so `@testable import` reaches it, on the simulator. + ProtonStreamTests: + type: bundle.unit-test + platform: iOS + sources: + - path: ProtonStreamTests + dependencies: + - target: ProtonStream + settings: + base: + SWIFT_VERSION: "5.0" + GENERATE_INFOPLIST_FILE: YES diff --git a/scripts/build-ios.sh b/scripts/build-ios.sh index 2afa1f1..9b25a8a 100644 --- a/scripts/build-ios.sh +++ b/scripts/build-ios.sh @@ -11,14 +11,16 @@ repo_root="$(cd -- "$script_dir/.." && pwd)" ios_dir="$repo_root/ios" generated="$ios_dir/build/generated" target=aarch64-apple-ios +simulator=aarch64-apple-ios-sim usage() { - echo "usage: $0 {rust|bindings|ipa|all}" + echo "usage: $0 {rust|bindings|ipa|all|test}" echo echo " rust static libpstr_android.a for $target" echo " bindings Swift bindings into ios/build/generated" - echo " ipa xcodegen + xcodebuild, then dist/ProtonStream-.ipa" + echo " ipa xcodegen + xcodebuild, then dist/proton-stream--ios.ipa" echo " all all three, in that order" + echo " test the unit tests on a simulator ($simulator)" } die() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; } @@ -36,11 +38,19 @@ version="$(awk '/^\[workspace.package\]/{found=1; next} found && /^\[/{exit} fou [[ -n "$version" ]] || die "no [workspace.package] version in Cargo.toml" build_rust() { - log "cargo rustc -p pstr-android --target $target (staticlib)" + local triple="${1:-$target}" + log "cargo rustc -p pstr-android --target $triple (staticlib)" # The crate stays `cdylib, rlib` for Android; the archive iOS links is asked # for here instead of being built on every other platform too. ( cd "$repo_root" && IPHONEOS_DEPLOYMENT_TARGET=17.0 cargo rustc -p pstr-android --lib \ - --release --locked --target "$target" --crate-type staticlib ) + --release --locked --target "$triple" --crate-type staticlib ) +} + +generate_project() { + command -v xcodegen >/dev/null || die "xcodegen is required (brew install xcodegen)" + [[ -f "$generated/PstrBridge.swift" ]] || die "run '$0 bindings' first" + log "xcodegen ($version)" + ( cd "$ios_dir" && PSTR_VERSION="$version" PSTR_BUILD="${PSTR_BUILD:-1}" xcodegen generate --quiet ) } build_bindings() { @@ -56,12 +66,8 @@ build_bindings() { } build_ipa() { - command -v xcodegen >/dev/null || die "xcodegen is required (brew install xcodegen)" [[ -f "$repo_root/target/$target/release/libpstr_android.a" ]] || die "run '$0 rust' first" - [[ -f "$generated/PstrBridge.swift" ]] || die "run '$0 bindings' first" - - log "xcodegen ($version)" - ( cd "$ios_dir" && PSTR_VERSION="$version" PSTR_BUILD="${PSTR_BUILD:-1}" xcodegen generate --quiet ) + generate_project log "xcodebuild (unsigned)" xcodebuild -project "$ios_dir/ProtonStream.xcodeproj" -scheme ProtonStream \ @@ -76,16 +82,34 @@ build_ipa() { rm -rf "$stage" mkdir -p "$stage/Payload" "$repo_root/dist" cp -R "$app" "$stage/Payload/" - local ipa="$repo_root/dist/ProtonStream-$version.ipa" + local ipa="$repo_root/dist/proton-stream-$version-ios.ipa" rm -f "$ipa" ( cd "$stage" && zip -qry "$ipa" Payload ) log "wrote $ipa" } +run_tests() { + build_rust "$simulator" + [[ -f "$generated/PstrBridge.swift" ]] || build_bindings + generate_project + # Whichever iPhone this Xcode ships a simulator for: the names change with + # every release, and the tests do not care which one. + local device + device="$(xcrun simctl list devices available | grep -m1 -oE 'iPhone [^(]+' | sed 's/ *$//')" + [[ -n "$device" ]] || die "no iPhone simulator is installed" + log "xcodebuild test ($device)" + xcodebuild -project "$ios_dir/ProtonStream.xcodeproj" -scheme ProtonStream \ + -destination "platform=iOS Simulator,name=$device" \ + -derivedDataPath "$ios_dir/build/DerivedData" \ + CODE_SIGNING_ALLOWED=NO \ + test +} + case "${1:-}" in rust) build_rust ;; bindings) build_bindings ;; ipa) build_ipa ;; all) build_rust; build_bindings; build_ipa ;; + test) run_tests ;; *) usage >&2; exit 2 ;; esac diff --git a/scripts/render-ios-icon.sh b/scripts/render-ios-icon.sh new file mode 100755 index 0000000..e3e2ac0 --- /dev/null +++ b/scripts/render-ios-icon.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# Render ios/ProtonStream/Resources/AppIcon.svg into the asset catalog's one +# 1024 px icon. iOS wants it opaque, so the alpha channel is dropped. +set -euo pipefail + +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +resources="$script_dir/../ios/ProtonStream/Resources" +out="$resources/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png" + +command -v rsvg-convert >/dev/null || { echo "rsvg-convert is required (librsvg)" >&2; exit 1; } +command -v magick >/dev/null || { echo "magick is required (ImageMagick)" >&2; exit 1; } +rsvg-convert -w 1024 -h 1024 "$resources/AppIcon.svg" | magick - -alpha off -strip "$out" +echo "wrote $out"