feat(ios): port the Android client to SwiftUI

The iOS client is now a screen-for-screen port of the Android app over the
same pstr-android UniFFI surface, with no Rust changes: Library, title page,
History, Shares (public links and Drive folders), Downloads and Settings,
with the same strings, undo snackbars and two-pane layout on wide screens.

- Theme from the engine's palette (Material role names, Inter, the same
  type ramp and corners), repainted live from the appearance settings.
- Account sign-in with two-factor code, mailbox password and the Proton
  CAPTCHA in a non-persistent WKWebView, the Drive folder browser, and
  watch-history sync every five minutes, after sign-in, on player close and
  when the app leaves the screen.
- Player over libmpv: resume, progress saved every 5 s, autoplay with the
  up-next card, skip and auto-skip from the chapter plan, track, speed,
  volume and episode pickers, landscape lock, mini transport, and
  lock-screen controls with interruption and route-change handling.
- Offline downloads with the Android record format, free-space floor,
  retries, transfer rate and the Wi-Fi-only policy; interrupted transfers
  resume at next launch.
- Config, data and download state are excluded from backup, as Android's
  allowBackup=false: a restore would bring shares back without their
  Keychain secrets and give two devices one sync identity.

Picture-in-picture, the share sheet and background downloads are not
possible the same way on iOS; docs/IOS.md lists why and what replaces them.

Adds XCTest ports of the Android unit tests, a `test` command to
scripts/build-ios.sh, the app icon and its render script, and an iOS
workflow on macos-15 that runs the tests and builds the unsigned .ipa.
This commit is contained in:
nvrl committed 2026-10-07 00:32:03 +02:00
1 parent da16ab0575
commit ed05a1e723
38 files changed
+7546 -208

No files matched your search

+225 -66
View File
@@ -17,6 +17,16 @@ final class MpvMetalLayer: CAMetalLayer {
}
}
/// Why the open file ended, mirroring `pstr_player::EndReason` so every client
/// agrees on what "finished" means.
///
/// Only `eof` is an episode watched to the end. Treating the others as one — a
/// stop, a shutdown, a failed demux — is what marks an episode that never
/// played as watched and autoplays past it.
enum EndReason {
case eof, stopped, quit, failed, other
}
struct MpvTrack: Identifiable, Hashable {
let id: Int64
let type: String
@@ -25,44 +35,81 @@ struct MpvTrack: Identifiable, Hashable {
let selected: Bool
var label: String {
let parts = [title, language.uppercased()].filter { !$0.isEmpty }
return parts.isEmpty ? "Track \(id)" : parts.joined(separator: " · ")
let parts = [language, title].filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty }
return parts.isEmpty ? "\(type.prefix(1).uppercased() + type.dropFirst()) \(id)" : parts.joined(separator: " — ")
}
}
/// One libmpv core rendering into a Metal layer. Every public method runs on
/// the main thread; mpv's events are read on a thread of their own and applied
/// back on the main thread.
/// One libmpv core rendering into a Metal layer, for the life of the app.
///
/// Every public method runs on the main thread; mpv's events are read on a
/// thread of their own and applied back on the main thread. The state mirrors
/// what `pstr_mpv.cpp` publishes on Android, field for field.
@MainActor
@Observable
final class MpvPlayer {
enum EndReason { case eof, stopped, failed }
private(set) var position: Double = 0
private(set) var duration: Double = 0
private(set) var volume: Double = 100
private(set) var paused = false
private(set) var muted = false
/// The file ended, and why. Nil while one is open.
private(set) var endReason: EndReason?
/// mpv has run out of buffered data and stopped to refill — not the same
/// as `paused`, which is the viewer's doing.
private(set) var buffering = false
/// How full the demuxer cache is while `buffering`, 0–100.
private(set) var cachePercent: Double = 0
/// A seek has been issued but playback has not resumed at the new position.
private(set) var seeking = false
/// How far the demuxer has read ahead, in seconds into the file.
private(set) var cachedUntil: Double = 0
/// The picture's display size, or zero until mpv has decoded enough.
private(set) var videoWidth: Double = 0
private(set) var videoHeight: Double = 0
private(set) var tracks: [MpvTrack] = []
private(set) var chapters: [ChapterRecord] = []
private(set) var failure: String?
/// Between `loadfile` and mpv's `FILE_LOADED`. Everything mpv reports in
/// that window still describes the *outgoing* file, including the
/// `END_FILE(stop)` stopping it emits.
private(set) var loading = false
/// Which episode the readings above describe, or nil between `loadfile`
/// and mpv's `START_FILE`. Everything that *writes* per-episode state —
/// the resume position above all — must check it: one core serves every
/// episode, and a reading attributed to the wrong one resumes the next
/// episode where the last one stopped.
private(set) var media: String?
/// What mpv last complained about, until the viewer dismisses it.
var problem: String?
/// Bumped by every load, so a reader can tell "episode two, second zero"
/// from a leftover reading of episode one.
private(set) var generation = 0
/// Whether the picture is stopped for a reason the viewer did not choose.
var stalled: Bool { (buffering || seeking) && !paused && endReason == nil }
/// Called once per file that ends, never for the one a load replaced.
@ObservationIgnored var onEnded: ((EndReason) -> Void)?
@ObservationIgnored var onEnded: ((EndReason, String) -> Void)?
/// Called on every position change, for what has to react to the clock.
@ObservationIgnored var onTick: (() -> Void)?
/// Called when the duration or the pause state changes: what the lock
/// screen shows, and whether the screen may sleep.
@ObservationIgnored var onStateChanged: (() -> Void)?
@ObservationIgnored let layer = MpvMetalLayer()
@ObservationIgnored private var mpv: OpaquePointer?
/// Between `loadfile` and `START_FILE`. Everything mpv reports in that
/// window still describes the *outgoing* file, including the
/// `END_FILE(stop)` that stopping it emits.
@ObservationIgnored private var loading = false
@ObservationIgnored private var pendingMedia: String?
init() {
/// A core, or nil when libmpv would not start.
static func create() -> MpvPlayer? {
let player = MpvPlayer()
return player.mpv == nil ? nil : player
}
private init() {
layer.framebufferOnly = true
layer.backgroundColor = UIColor.black.cgColor
guard let mpv = mpv_create() else {
failure = "libmpv did not start"
return
}
guard let mpv = mpv_create() else { return }
var wid = layer
mpv_set_option(mpv, "wid", MPV_FORMAT_INT64, &wid)
for (name, value) in [
@@ -70,6 +117,7 @@ final class MpvPlayer {
("vo", "gpu-next"),
("gpu-api", "vulkan"),
("gpu-context", "moltenvk"),
// The default; `load` sets it per file from the viewer's setting.
("hwdec", "videotoolbox"),
("video-rotate", "no"),
("keep-open", "no"),
@@ -78,20 +126,21 @@ final class MpvPlayer {
("demuxer-readahead-secs", "30"),
("demuxer-max-bytes", "50331648"),
("subs-fallback", "yes"),
// AVAudioSession is this app's to manage, as audio focus is
// Android's.
("audio-exclusive", "no"),
] {
mpv_set_option_string(mpv, name, value)
}
guard registerStreamProtocol(mpv), mpv_initialize(mpv) >= 0 else {
mpv_terminate_destroy(mpv)
failure = "libmpv did not initialize"
return
}
mpv_observe_property(mpv, 1, "time-pos", MPV_FORMAT_DOUBLE)
mpv_observe_property(mpv, 2, "duration", MPV_FORMAT_DOUBLE)
mpv_observe_property(mpv, 3, "pause", MPV_FORMAT_FLAG)
mpv_observe_property(mpv, 4, "paused-for-cache", MPV_FORMAT_FLAG)
mpv_observe_property(mpv, 5, "track-list/count", MPV_FORMAT_INT64)
mpv_observe_property(mpv, 6, "chapter-list/count", MPV_FORMAT_INT64)
for (id, name, format) in Self.observed {
mpv_observe_property(mpv, id, name, format)
}
// Errors mpv reports about the file itself, rather than about the
// request that opened it. Without these a failed demux is silent.
mpv_request_log_messages(mpv, "error")
self.mpv = mpv
@@ -100,52 +149,106 @@ final class MpvPlayer {
thread.start()
}
private static let observed: [(UInt64, String, mpv_format)] = [
(1, "time-pos", MPV_FORMAT_DOUBLE),
(2, "duration", MPV_FORMAT_DOUBLE),
(3, "pause", MPV_FORMAT_FLAG),
(4, "volume", MPV_FORMAT_DOUBLE),
(5, "mute", MPV_FORMAT_FLAG),
(6, "video-params/dw", MPV_FORMAT_INT64),
(7, "video-params/dh", MPV_FORMAT_INT64),
(8, "paused-for-cache", MPV_FORMAT_FLAG),
(9, "cache-buffering-state", MPV_FORMAT_INT64),
(10, "demuxer-cache-time", MPV_FORMAT_DOUBLE),
(11, "track-list/count", MPV_FORMAT_INT64),
(12, "chapter-list/count", MPV_FORMAT_INT64),
(13, "seeking", MPV_FORMAT_FLAG),
]
// MARK: Commands
/// Load `pstr://<handle>`. On `false` nothing was issued, and the caller
/// still owns the handle's release.
func load(handle: UInt64, start: Double, audio: String?, subtitle: String?, subtitles: Bool) -> Bool {
/// Load `pstr://<handle>` as `media`. On `false` nothing was issued, and
/// the caller still owns the handle's release.
func load(
media: String,
handle: UInt64,
start: Double,
audio: String?,
subtitle: String?,
subtitles: Bool,
hardwareDecoding: Bool
) -> Bool {
guard mpv != nil else { return false }
// Per file, before `loadfile`: a file VideoToolbox decodes wrongly is
// fixed from the next episode without restarting the core.
setString("hwdec", hardwareDecoding ? "videotoolbox" : "no")
setString("start", start > 0 ? String(format: "%.3f", start) : "none")
setString("alang", audio ?? "")
setString("slang", subtitle ?? "")
setString("sid", subtitles ? "auto" : "no")
position = 0
// Retire the outgoing file's readings *before* the new one loads: mpv
// only republishes them once it has demuxed enough of the stream, and
// until then every reader would see the last episode's clock under the
// new episode's name. Volume, mute and pause are the core's, not the
// file's, and carry across.
position = start
duration = 0
endReason = nil
buffering = false
cachePercent = 0
seeking = false
cachedUntil = 0
videoWidth = 0
videoHeight = 0
tracks = []
chapters = []
failure = nil
problem = nil
self.media = nil
generation += 1
pendingMedia = media
loading = true
if command(["loadfile", "pstr://\(handle)", "replace"]) < 0 {
// Nothing was issued, so no START_FILE will arrive to clear this.
loading = false
return false
}
return true
}
func setPaused(_ value: Bool) { setFlag("pause", value) }
func seek(to seconds: Double) { setDouble("time-pos", max(0, seconds)) }
func seek(by seconds: Double) { _ = command(["seek", String(seconds), "relative"]) }
/// Written through rather than left to the observer: a tap that only shows
/// its effect on mpv's next property event reads as a button that missed.
func setPaused(_ value: Bool) {
setFlag("pause", value)
paused = value
}
func seek(to seconds: Double) {
setDouble("time-pos", max(0, seconds))
seeking = true
}
func setSpeed(_ value: Double) { setDouble("speed", min(max(value, 0.25), 4)) }
func setVolume(_ value: Double) { setDouble("volume", min(max(value, 0), 100)) }
func setMuted(_ value: Bool) { setFlag("mute", value) }
func select(audio: Bool, track: MpvTrack?) {
let property = audio ? "aid" : "sid"
if let track { setString(property, String(track.id)) } else { setString(property, "no") }
/// A track, or none: `nil` turns subtitles off.
func select(type: String, track: MpvTrack?) {
setString(type == "audio" ? "aid" : "sid", track.map { String($0.id) } ?? "no")
refreshTracks()
}
/// Showing no picture while backgrounded keeps the audio playing and avoids
/// the black frame MoltenVK otherwise leaves on return.
/// Showing no picture while backgrounded keeps the audio playing: iOS
/// refuses GPU work from a background app, and would end the process for it.
func setVideoEnabled(_ enabled: Bool) { setString("vid", enabled ? "auto" : "no") }
/// Stop and tear the core down. `cancel_fn` interrupts a read parked in a
/// block fetch, and the event thread destroys the core once it is down.
func shutdown() {
guard let mpv else { return }
self.mpv = nil
mpv_command_string(mpv, "quit")
/// Stop the open file. The stream behind it is released by mpv closing it,
/// which this causes; releasing it from here would race that close.
func stop() {
_ = command(["stop"])
media = nil
pendingMedia = nil
chapters = []
tracks = []
}
// MARK: Events
@@ -153,10 +256,13 @@ final class MpvPlayer {
private enum Event {
case double(UInt64, Double)
case flag(UInt64, Bool)
case count(UInt64)
case int(UInt64, Int64)
case unavailable(UInt64)
case started
case loaded
case ended(Int32)
case log(String)
case ended(Int64)
case restarted
case log(prefix: String, text: String)
}
/// Runs until the core shuts down, then destroys it. Nothing else calls
@@ -170,24 +276,34 @@ final class MpvPlayer {
mpv_terminate_destroy(mpv)
return
case MPV_EVENT_PROPERTY_CHANGE:
guard let property = event.data?.assumingMemoryBound(to: mpv_event_property.self).pointee,
let data = property.data else { break }
guard let property = event.data?.assumingMemoryBound(to: mpv_event_property.self).pointee else { break }
guard let data = property.data else {
decoded = .unavailable(event.reply_userdata)
break
}
switch property.format {
case MPV_FORMAT_DOUBLE: decoded = .double(event.reply_userdata, data.load(as: Double.self))
case MPV_FORMAT_FLAG: decoded = .flag(event.reply_userdata, data.load(as: Int32.self) != 0)
case MPV_FORMAT_INT64: decoded = .count(event.reply_userdata)
default: break
case MPV_FORMAT_INT64: decoded = .int(event.reply_userdata, data.load(as: Int64.self))
default: decoded = .unavailable(event.reply_userdata)
}
case MPV_EVENT_START_FILE:
decoded = .started
case MPV_EVENT_FILE_LOADED:
decoded = .loaded
case MPV_EVENT_PLAYBACK_RESTART:
decoded = .restarted
case MPV_EVENT_END_FILE:
if let end = event.data?.assumingMemoryBound(to: mpv_event_end_file.self).pointee {
decoded = .ended(end.reason.rawValue)
decoded = .ended(Int64(end.reason.rawValue))
}
case MPV_EVENT_LOG_MESSAGE:
if let message = event.data?.assumingMemoryBound(to: mpv_event_log_message.self).pointee,
let text = message.text {
decoded = .log(String(cString: text).trimmingCharacters(in: .whitespacesAndNewlines))
decoded = .log(
prefix: message.prefix.map { String(cString: $0) } ?? "",
text: String(cString: text).trimmingCharacters(in: .whitespacesAndNewlines)
)
}
default:
break
@@ -202,28 +318,71 @@ final class MpvPlayer {
switch event {
case let .double(id, value):
if loading { return }
if id == 1 { position = value } else if id == 2 { duration = value }
switch id {
case 1:
position = value
onTick?()
case 2:
duration = value
onStateChanged?()
case 4: volume = value
case 10: cachedUntil = value
default: break
}
case let .flag(id, value):
if id == 3 { paused = value } else if id == 4 { buffering = value }
case let .count(id):
switch id {
case 3:
paused = value
onStateChanged?()
case 5: muted = value
case 8: if !loading { buffering = value }
case 13: if !loading { seeking = value }
default: break
}
case let .int(id, value):
if loading { return }
if id == 5 { refreshTracks() } else if id == 6 { refreshChapters() }
switch id {
case 6: videoWidth = Double(value)
case 7: videoHeight = Double(value)
case 9: cachePercent = Double(value)
case 11: refreshTracks()
case 12: refreshChapters()
default: break
}
case let .unavailable(id):
if loading { return }
if id == 6 || id == 7 { videoWidth = 0; videoHeight = 0 }
case .started:
// From here on, what mpv reports is the file just loaded.
if loading {
loading = false
media = pendingMedia
}
case .loaded:
loading = false
refreshTracks()
refreshChapters()
case .restarted:
seeking = false
case let .ended(reason):
// The outgoing file's END_FILE arrives after the next load began.
if loading { return }
switch reason {
case MPV_END_FILE_REASON_EOF.rawValue: onEnded?(.eof)
case MPV_END_FILE_REASON_ERROR.rawValue:
failure = failure ?? "The file could not be played"
onEnded?(.failed)
default: onEnded?(.stopped)
let ended: EndReason = switch reason {
case Int64(MPV_END_FILE_REASON_EOF.rawValue): .eof
case Int64(MPV_END_FILE_REASON_STOP.rawValue): .stopped
case Int64(MPV_END_FILE_REASON_QUIT.rawValue): .quit
case Int64(MPV_END_FILE_REASON_ERROR.rawValue): .failed
default: .other
}
case let .log(text):
if !text.isEmpty { failure = text }
endReason = ended
if ended == .failed { problem = problem ?? "This episode could not be played." }
if let media { onEnded?(ended, media) }
case let .log(prefix, text):
// The outgoing file's complaints are not the new one's.
if loading { return }
// The first complaint per file, and not ffmpeg's: its decoders log
// recoverable glitches at error level all through a healthy file.
if text.isEmpty || prefix.hasPrefix("ffmpeg") || problem != nil { return }
problem = text
}
}