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