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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,553 @@
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
import MediaPlayer
|
||||
import Observation
|
||||
import UIKit
|
||||
|
||||
/// How long the "up next" card counts down before it loads the next episode.
|
||||
let upNextSeconds = 10
|
||||
|
||||
/// Past this much of an episode it counts as watched.
|
||||
private let watchedFraction = 0.9
|
||||
|
||||
/// How an episode is named in watch state and player readings.
|
||||
func mediaKey(_ episode: EpisodeRecord) -> String { "\(episode.shareId):\(episode.linkId)" }
|
||||
|
||||
/// The open episode's stream, from Rust: opened, handed to mpv, given back.
|
||||
///
|
||||
/// The native handle belongs to mpv once a load succeeds — `close_fn` in the
|
||||
/// stream protocol releases it after mpv's last read. Only a load that never
|
||||
/// happened leaves it here to release.
|
||||
private final class StreamSession {
|
||||
let episode: EpisodeRecord
|
||||
private(set) var handle: UInt64?
|
||||
private(set) var size: UInt64 = 0
|
||||
|
||||
init(episode: EpisodeRecord) {
|
||||
self.episode = episode
|
||||
}
|
||||
|
||||
func open() async throws {
|
||||
let stream = try await NativeRuntime.engine().openStream(shareId: episode.shareId, volumeId: episode.volumeId, linkId: episode.linkId)
|
||||
size = stream.size()
|
||||
handle = stream.nativeHandle()
|
||||
}
|
||||
|
||||
/// The player now owns the published handle until stop, end or close.
|
||||
func transferToPlayer() {
|
||||
handle = nil
|
||||
}
|
||||
|
||||
/// Give the episode's stream back to Rust, from a task of its own: the one
|
||||
/// moment this has to run is when the player moves on, and nothing that
|
||||
/// goes away with it may cancel it.
|
||||
func close() {
|
||||
if let handle { pstr_android_stream_release(handle) }
|
||||
handle = nil
|
||||
let episode = episode
|
||||
Task.detached {
|
||||
try? await NativeRuntime.engine().releaseStream(shareId: episode.shareId, volumeId: episode.volumeId, linkId: episode.linkId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the player has open and everything that walks it: the stream per
|
||||
/// episode, the resume position, autoplay, auto-skip, the lock screen and the
|
||||
/// audio session.
|
||||
///
|
||||
/// Android splits this between `PlayerScreen`, `NativeMpvHost` and
|
||||
/// `PlaybackService`. Here it is one object that outlives the player's view,
|
||||
/// because the episode keeps playing while the view is minimised or the app
|
||||
/// is in the background.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class PlayerHost {
|
||||
static let shared = PlayerHost()
|
||||
|
||||
/// The core, once something has been played. Nil if libmpv would not start.
|
||||
private(set) var player: MpvPlayer?
|
||||
private(set) var unavailable = false
|
||||
private(set) var title: TitleRecord?
|
||||
private(set) var episodes: [EpisodeRecord] = []
|
||||
private(set) var index = 0
|
||||
/// Playing, but not on screen: the episode keeps going and the mini
|
||||
/// transport is what says so.
|
||||
var minimized = false
|
||||
/// The open file's chapters, already resolved into openings and endings.
|
||||
private(set) var chapters = ChapterPlan(entries: [], creditsStart: nil)
|
||||
/// What stopped this episode, until the viewer dismisses it.
|
||||
var playbackError: String?
|
||||
/// Read once per episode: a preference the viewer changes mid-episode
|
||||
/// applies to the next one.
|
||||
private(set) var autoSkip = false
|
||||
private(set) var autoplay = true
|
||||
/// The rate playing, so the speed list opens on it.
|
||||
var speed = 1.0
|
||||
|
||||
/// Persist where an episode was left: position, duration, watched.
|
||||
@ObservationIgnored var onSaveProgress: ((EpisodeRecord, Double, Double, Bool) -> Void)?
|
||||
/// The player closed: where it stopped is what another device wants next.
|
||||
@ObservationIgnored var onClosed: (() -> Void)?
|
||||
|
||||
@ObservationIgnored private var session: StreamSession?
|
||||
@ObservationIgnored private var loadTask: Task<Void, Never>?
|
||||
@ObservationIgnored private var saveTask: Task<Void, Never>?
|
||||
@ObservationIgnored private var plannedFor: (Double, Int)?
|
||||
/// Chapters already auto-skipped in this episode: seeking back into an
|
||||
/// opening on purpose must not be undone by the setting that skipped it.
|
||||
@ObservationIgnored private var skipped = Set<Int64>()
|
||||
@ObservationIgnored private var pausedByInterruption = false
|
||||
@ObservationIgnored private var artworkFor: String?
|
||||
|
||||
private init() {
|
||||
observeAudioSession()
|
||||
configureRemoteCommands()
|
||||
}
|
||||
|
||||
var episode: EpisodeRecord? { episodes.indices.contains(index) ? episodes[index] : nil }
|
||||
var key: String? { episode.map(mediaKey) }
|
||||
var next: EpisodeRecord? { episodes.indices.contains(index + 1) ? episodes[index + 1] : nil }
|
||||
var isOpen: Bool { title != nil }
|
||||
|
||||
// MARK: - Opening
|
||||
|
||||
/// Open a title's episodes at `index`, or move to it if the title is open.
|
||||
func play(_ title: TitleRecord, at index: Int) {
|
||||
let episodes = title.playlist
|
||||
guard !episodes.isEmpty else { return }
|
||||
let target = min(max(index, 0), episodes.count - 1)
|
||||
// The same episode, still open: only bring it back on screen. One that
|
||||
// has ended has nothing loaded, and is played again.
|
||||
if self.title?.key == title.key, mediaKey(episodes[target]) == key, player?.endReason == nil {
|
||||
self.title = title
|
||||
self.episodes = episodes
|
||||
minimized = false
|
||||
return
|
||||
}
|
||||
save()
|
||||
self.title = title
|
||||
self.episodes = episodes
|
||||
self.index = target
|
||||
minimized = false
|
||||
load()
|
||||
}
|
||||
|
||||
/// Keep the open title's records current after the library reloads, so the
|
||||
/// episode list shows what was just watched.
|
||||
func update(titles: [TitleRecord]) {
|
||||
guard let key = title?.key, let fresh = titles.first(where: { $0.key == key }) else { return }
|
||||
let current = self.key
|
||||
title = fresh
|
||||
episodes = fresh.playlist
|
||||
if let current, let moved = episodes.firstIndex(where: { mediaKey($0) == current }) { index = moved }
|
||||
}
|
||||
|
||||
func advance() {
|
||||
guard next != nil else { return }
|
||||
move(to: index + 1)
|
||||
}
|
||||
|
||||
func retreat() {
|
||||
guard index > 0 else { return }
|
||||
move(to: index - 1)
|
||||
}
|
||||
|
||||
func jump(to target: Int) {
|
||||
guard target != index, episodes.indices.contains(target) else { return }
|
||||
move(to: target)
|
||||
}
|
||||
|
||||
private func move(to target: Int) {
|
||||
save()
|
||||
index = target
|
||||
load()
|
||||
}
|
||||
|
||||
/// Stop playback and close the player. The mini transport's ✕ and the
|
||||
/// player's own are the one place it is unambiguous.
|
||||
func close() {
|
||||
save()
|
||||
loadTask?.cancel()
|
||||
saveTask?.cancel()
|
||||
player?.stop()
|
||||
session?.close()
|
||||
session = nil
|
||||
title = nil
|
||||
episodes = []
|
||||
index = 0
|
||||
minimized = false
|
||||
playbackError = nil
|
||||
chapters = ChapterPlan(entries: [], creditsStart: nil)
|
||||
OrientationLock.set(false)
|
||||
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
|
||||
UIApplication.shared.isIdleTimerDisabled = false
|
||||
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
||||
onClosed?()
|
||||
}
|
||||
|
||||
private func load() {
|
||||
guard let title, let episode else { return }
|
||||
loadTask?.cancel()
|
||||
session?.close()
|
||||
playbackError = nil
|
||||
chapters = ChapterPlan(entries: [], creditsStart: nil)
|
||||
plannedFor = nil
|
||||
skipped = []
|
||||
let session = StreamSession(episode: episode)
|
||||
self.session = session
|
||||
let key = mediaKey(episode)
|
||||
let titleKey = title.key
|
||||
publishNowPlaying()
|
||||
if player == nil, !unavailable {
|
||||
player = MpvPlayer.create()
|
||||
unavailable = player == nil
|
||||
player?.onEnded = { [weak self] reason, media in self?.ended(reason, media) }
|
||||
player?.onTick = { [weak self] in self?.tick() }
|
||||
player?.onStateChanged = { [weak self] in self?.publishNowPlaying() }
|
||||
}
|
||||
guard let player else {
|
||||
playbackError = "Playback is unavailable: libmpv did not start."
|
||||
return
|
||||
}
|
||||
try? AVAudioSession.sharedInstance().setActive(true)
|
||||
loadTask = Task {
|
||||
do {
|
||||
try await session.open()
|
||||
let startup = try await Task.detached {
|
||||
let engine = try NativeRuntime.blockingEngine()
|
||||
// A choice made on this title wins; without one, the choice
|
||||
// the viewer made anywhere else does — the desktop client's
|
||||
// fallback chain.
|
||||
let show = try engine.titleTrackPreferences(titleKey: titleKey)
|
||||
let global = try engine.playbackPrefs()
|
||||
let watch = try engine.watchState(shareId: episode.shareId, linkId: episode.linkId)
|
||||
return (show, global, watch)
|
||||
}.value
|
||||
if Task.isCancelled || self.session !== session {
|
||||
abandon(session)
|
||||
return
|
||||
}
|
||||
let (show, global, watch) = startup
|
||||
guard let handle = session.handle, session.size > 0 else { throw PlaybackFailure("Cannot play an empty stream") }
|
||||
autoSkip = global.autoSkip
|
||||
autoplay = global.autoplayNext
|
||||
speed = global.speed
|
||||
let loaded = player.load(
|
||||
media: key,
|
||||
handle: handle,
|
||||
// A finished episode restarts rather than resuming three
|
||||
// seconds from its own credits.
|
||||
start: watch.flatMap { $0.watched ? nil : $0.positionSecs } ?? 0,
|
||||
audio: show?.audioLanguage ?? global.audioLanguage,
|
||||
subtitle: show?.subtitleLanguage ?? global.subtitleLanguage,
|
||||
subtitles: show?.subtitles ?? global.subtitles,
|
||||
hardwareDecoding: SettingsStore().hardwareDecoding
|
||||
)
|
||||
guard loaded else { throw PlaybackFailure("libmpv rejected the stream") }
|
||||
session.transferToPlayer()
|
||||
// After the load, because mpv keeps none of the three across one.
|
||||
player.setVolume(global.volume)
|
||||
player.setMuted(global.muted)
|
||||
player.setSpeed(global.speed)
|
||||
player.setPaused(false)
|
||||
startSaving()
|
||||
} catch {
|
||||
if Task.isCancelled || self.session !== session {
|
||||
abandon(session)
|
||||
return
|
||||
}
|
||||
playbackError = errorMessage(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A session the player moved past while it was still opening. Its
|
||||
/// `close()` already ran, before there was a handle to release, so the one
|
||||
/// it has published since is released here — and only that, since the
|
||||
/// stream itself was already given back.
|
||||
private func abandon(_ session: StreamSession) {
|
||||
if let handle = session.handle {
|
||||
pstr_android_stream_release(handle)
|
||||
session.transferToPlayer()
|
||||
}
|
||||
}
|
||||
|
||||
private struct PlaybackFailure: LocalizedError {
|
||||
let errorDescription: String?
|
||||
init(_ message: String) { errorDescription = message }
|
||||
}
|
||||
|
||||
// MARK: - Progress
|
||||
|
||||
/// Resume position, written only from readings that carry this episode's
|
||||
/// name: an unattributed reading is the previous one's clock.
|
||||
func save() {
|
||||
guard let player, let episode, player.media == mediaKey(episode), player.duration > 0 else { return }
|
||||
onSaveProgress?(
|
||||
episode,
|
||||
min(max(player.position, 0), player.duration),
|
||||
player.duration,
|
||||
// Watched means played to the end. An episode that was stopped, or
|
||||
// that mpv could not play at all, also "ended" — recording either
|
||||
// as watched retires an episode nobody saw.
|
||||
player.endReason == .eof || player.position >= player.duration * watchedFraction
|
||||
)
|
||||
}
|
||||
|
||||
private func startSaving() {
|
||||
saveTask?.cancel()
|
||||
saveTask = Task {
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(for: .seconds(5))
|
||||
if Task.isCancelled { return }
|
||||
save()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Reacting to the player
|
||||
|
||||
private func ended(_ reason: EndReason, _ media: String) {
|
||||
guard media == key else { return }
|
||||
save()
|
||||
// Autoplay fires on a clean end of file only — never on a failure.
|
||||
if reason == .eof, autoplay, next != nil {
|
||||
advance()
|
||||
} else if reason == .failed {
|
||||
playbackError = player?.problem ?? "This episode could not be played."
|
||||
}
|
||||
publishNowPlaying()
|
||||
}
|
||||
|
||||
private func tick() {
|
||||
guard let player else { return }
|
||||
replanChapters(player)
|
||||
// Auto-skip, once per chapter.
|
||||
if autoSkip, !player.paused, let offer = skipOffer(plan: chapters, position: player.position),
|
||||
let chapter = chapters.entries.last(where: { player.position + 0.001 >= $0.start }),
|
||||
skipped.insert(chapter.index).inserted {
|
||||
player.seek(to: offer.target)
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-read the chapters when there is a reason to: a different file, or a
|
||||
/// duration that has only just arrived. Both change the answer — the length
|
||||
/// of a chapter is what settles whether `Intro` is an opening or ten minutes
|
||||
/// of story — and neither changes again after that.
|
||||
private func replanChapters(_ player: MpvPlayer) {
|
||||
let wanted = (player.duration, player.generation)
|
||||
if let plannedFor, plannedFor == wanted, !(chapters.entries.isEmpty && !player.chapters.isEmpty) { return }
|
||||
plannedFor = wanted
|
||||
chapters = chapterPlan(chapters: player.chapters, duration: player.duration > 0 ? player.duration : nil)
|
||||
}
|
||||
|
||||
/// The skip button's offer at the current position, if any.
|
||||
var offer: SkipOffer? {
|
||||
guard let player else { return nil }
|
||||
return skipOffer(plan: chapters, position: player.position)
|
||||
}
|
||||
|
||||
// MARK: - Transport
|
||||
|
||||
func setPaused(_ paused: Bool) {
|
||||
player?.setPaused(paused)
|
||||
if !paused { try? AVAudioSession.sharedInstance().setActive(true) }
|
||||
publishNowPlaying()
|
||||
}
|
||||
|
||||
func seek(to seconds: Double) {
|
||||
player?.seek(to: seconds)
|
||||
updateElapsed(seconds)
|
||||
}
|
||||
|
||||
/// Persist a volume or a mute, leaving whichever was not given as it was.
|
||||
/// Written when the thumb is released rather than while it moves.
|
||||
func rememberVolume(volume: Double? = nil, muted: Bool? = nil) {
|
||||
Task.detached {
|
||||
guard let engine = try? NativeRuntime.blockingEngine(), var prefs = try? engine.playbackPrefs() else { return }
|
||||
prefs.volume = volume ?? prefs.volume
|
||||
prefs.muted = muted ?? prefs.muted
|
||||
try? engine.setPlaybackPrefs(prefs: prefs)
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist a playback rate, so the next episode starts at it.
|
||||
func setSpeed(_ rate: Double) {
|
||||
speed = rate
|
||||
player?.setSpeed(rate)
|
||||
publishNowPlaying()
|
||||
Task.detached {
|
||||
guard let engine = try? NativeRuntime.blockingEngine(), var prefs = try? engine.playbackPrefs() else { return }
|
||||
prefs.speed = rate
|
||||
try? engine.setPlaybackPrefs(prefs: prefs)
|
||||
}
|
||||
}
|
||||
|
||||
/// A track for this title. The choice is the title's from now on.
|
||||
func select(type: String, track: MpvTrack?) {
|
||||
player?.select(type: type, track: track)
|
||||
guard let titleKey = title?.key else { return }
|
||||
Task.detached {
|
||||
guard let engine = try? NativeRuntime.blockingEngine(), let global = try? engine.playbackPrefs() else { return }
|
||||
// No per-title choice yet: start from what plays everywhere else,
|
||||
// so choosing an audio track does not silently drop the subtitle
|
||||
// preference this episode started with.
|
||||
var preferences = (try? engine.titleTrackPreferences(titleKey: titleKey)) ?? TrackPreferencesRecord(
|
||||
audioLanguage: global.audioLanguage,
|
||||
subtitleLanguage: global.subtitleLanguage,
|
||||
subtitles: global.subtitles
|
||||
)
|
||||
let language = track.flatMap { $0.language.trimmingCharacters(in: .whitespaces).isEmpty ? nil : $0.language }
|
||||
if type == "audio" {
|
||||
preferences.audioLanguage = language
|
||||
} else {
|
||||
// Turning subtitles off keeps the language it was off *from*, so
|
||||
// turning them back on does not have to be told it again.
|
||||
preferences.subtitleLanguage = language ?? preferences.subtitleLanguage
|
||||
preferences.subtitles = track != nil
|
||||
}
|
||||
try? engine.setTitleTrackPreferences(titleKey: titleKey, preferences: preferences)
|
||||
}
|
||||
}
|
||||
|
||||
func dismissProblem() {
|
||||
playbackError = nil
|
||||
player?.problem = nil
|
||||
}
|
||||
|
||||
// MARK: - Background
|
||||
|
||||
/// Leaving the screen: the picture goes, the sound stays if the viewer
|
||||
/// asked for background audio, and the position is saved either way.
|
||||
func enteredBackground() {
|
||||
save()
|
||||
guard isOpen, let player else { return }
|
||||
if SettingsStore().backgroundAudio {
|
||||
player.setVideoEnabled(false)
|
||||
} else {
|
||||
player.setPaused(true)
|
||||
}
|
||||
publishNowPlaying()
|
||||
}
|
||||
|
||||
func enteredForeground() {
|
||||
player?.setVideoEnabled(true)
|
||||
}
|
||||
|
||||
// MARK: - Lock screen
|
||||
|
||||
/// What the lock screen and Control Center show: the episode, its show and
|
||||
/// its art — never just the app.
|
||||
private func publishNowPlaying() {
|
||||
guard let title, let episode else {
|
||||
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
|
||||
return
|
||||
}
|
||||
let center = MPNowPlayingInfoCenter.default()
|
||||
var info = center.nowPlayingInfo ?? [:]
|
||||
info[MPMediaItemPropertyTitle] = episode.label
|
||||
info[MPMediaItemPropertyArtist] = title.displayName
|
||||
info[MPMediaItemPropertyAlbumTitle] = title.displayName
|
||||
info[MPNowPlayingInfoPropertyMediaType] = MPNowPlayingInfoMediaType.video.rawValue
|
||||
info[MPMediaItemPropertyPlaybackDuration] = player?.duration ?? 0
|
||||
info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = player?.position ?? 0
|
||||
info[MPNowPlayingInfoPropertyPlaybackRate] = (player?.paused ?? true) ? 0.0 : speed
|
||||
center.nowPlayingInfo = info
|
||||
let commands = MPRemoteCommandCenter.shared()
|
||||
commands.nextTrackCommand.isEnabled = next != nil
|
||||
commands.previousTrackCommand.isEnabled = index > 0
|
||||
UIApplication.shared.isIdleTimerDisabled = !(player?.paused ?? true) && !minimized
|
||||
|
||||
let artKey = mediaKey(episode)
|
||||
if artworkFor != artKey {
|
||||
artworkFor = artKey
|
||||
info[MPMediaItemPropertyArtwork] = nil
|
||||
center.nowPlayingInfo = info
|
||||
let url = title.posterUrl ?? title.backdropUrl
|
||||
let source = episode.thumbnailSource
|
||||
Task {
|
||||
guard let image = await ArtworkLoader.shared.artwork(url: url, fallback: source), artworkFor == artKey else { return }
|
||||
var current = center.nowPlayingInfo ?? [:]
|
||||
current[MPMediaItemPropertyArtwork] = MPMediaItemArtwork(boundsSize: image.size) { _ in image }
|
||||
center.nowPlayingInfo = current
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func updateElapsed(_ seconds: Double) {
|
||||
let center = MPNowPlayingInfoCenter.default()
|
||||
guard var info = center.nowPlayingInfo else { return }
|
||||
info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = seconds
|
||||
center.nowPlayingInfo = info
|
||||
}
|
||||
|
||||
private func configureRemoteCommands() {
|
||||
let commands = MPRemoteCommandCenter.shared()
|
||||
commands.playCommand.addTarget { [weak self] _ in
|
||||
MainActor.assumeIsolated { self?.setPaused(false) }
|
||||
return .success
|
||||
}
|
||||
commands.pauseCommand.addTarget { [weak self] _ in
|
||||
MainActor.assumeIsolated { self?.setPaused(true) }
|
||||
return .success
|
||||
}
|
||||
commands.togglePlayPauseCommand.addTarget { [weak self] _ in
|
||||
MainActor.assumeIsolated { self?.setPaused(!(self?.player?.paused ?? true)) }
|
||||
return .success
|
||||
}
|
||||
commands.changePlaybackPositionCommand.addTarget { [weak self] event in
|
||||
guard let event = event as? MPChangePlaybackPositionCommandEvent else { return .commandFailed }
|
||||
MainActor.assumeIsolated { self?.seek(to: event.positionTime) }
|
||||
return .success
|
||||
}
|
||||
commands.nextTrackCommand.addTarget { [weak self] _ in
|
||||
MainActor.assumeIsolated { self?.advance() }
|
||||
return .success
|
||||
}
|
||||
commands.previousTrackCommand.addTarget { [weak self] _ in
|
||||
MainActor.assumeIsolated { self?.retreat() }
|
||||
return .success
|
||||
}
|
||||
commands.stopCommand.addTarget { [weak self] _ in
|
||||
MainActor.assumeIsolated { self?.close() }
|
||||
return .success
|
||||
}
|
||||
}
|
||||
|
||||
/// A call or an alarm pauses, and its end resumes only what this paused —
|
||||
/// Android's transient focus loss. Headphones coming out pause, as
|
||||
/// `BECOMING_NOISY` does there.
|
||||
private func observeAudioSession() {
|
||||
let center = NotificationCenter.default
|
||||
center.addObserver(forName: AVAudioSession.interruptionNotification, object: nil, queue: .main) { [weak self] note in
|
||||
guard let raw = note.userInfo?[AVAudioSessionInterruptionTypeKey] as? UInt,
|
||||
let type = AVAudioSession.InterruptionType(rawValue: raw)
|
||||
else { return }
|
||||
let options = (note.userInfo?[AVAudioSessionInterruptionOptionKey] as? UInt).map(AVAudioSession.InterruptionOptions.init)
|
||||
MainActor.assumeIsolated {
|
||||
guard let self, let player = self.player, self.isOpen else { return }
|
||||
switch type {
|
||||
case .began:
|
||||
if !player.paused {
|
||||
self.pausedByInterruption = true
|
||||
self.setPaused(true)
|
||||
}
|
||||
case .ended:
|
||||
if self.pausedByInterruption, options?.contains(.shouldResume) == true { self.setPaused(false) }
|
||||
self.pausedByInterruption = false
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
center.addObserver(forName: AVAudioSession.routeChangeNotification, object: nil, queue: .main) { [weak self] note in
|
||||
guard let raw = note.userInfo?[AVAudioSessionRouteChangeReasonKey] as? UInt,
|
||||
AVAudioSession.RouteChangeReason(rawValue: raw) == .oldDeviceUnavailable
|
||||
else { return }
|
||||
MainActor.assumeIsolated {
|
||||
guard let self, self.isOpen else { return }
|
||||
self.setPaused(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,614 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
/// How long the controls stay up after a tap.
|
||||
private let controlsSeconds = 4.0
|
||||
|
||||
/// The narrowest layout the in-app volume slider is worth the room it costs.
|
||||
/// Below it — a phone held upright — the hardware volume buttons are the
|
||||
/// better answer.
|
||||
private let volumeSliderMinWidth: CGFloat = 600
|
||||
|
||||
/// The rates worth offering. A short list rather than a slider: the useful
|
||||
/// rates are a handful of ratios, and a slider lands on 1.07 as often as 1.0.
|
||||
private let speeds = [0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0]
|
||||
|
||||
/// The player, over the whole window, notch included.
|
||||
///
|
||||
/// The episode itself lives in `PlayerHost`, so this view can go — minimised to
|
||||
/// the mini transport, or with the app in the background — while it plays on.
|
||||
struct PlayerView: View {
|
||||
let host: PlayerHost
|
||||
let player: MpvPlayer
|
||||
@Environment(\.scheme) private var scheme
|
||||
@State private var showControls = true
|
||||
@State private var hideTask: Task<Void, Never>?
|
||||
@State private var landscapeLocked = OrientationLock.landscape
|
||||
@State private var sheet: PlayerSheet?
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
ZStack {
|
||||
Color.black.ignoresSafeArea()
|
||||
MpvView(player: player).ignoresSafeArea()
|
||||
Color.clear
|
||||
.contentShape(Rectangle())
|
||||
.ignoresSafeArea()
|
||||
.onTapGesture { setControls(!showControls) }
|
||||
|
||||
VStack(spacing: 0) {
|
||||
if showControls { topBar.transition(.opacity) }
|
||||
Spacer(minLength: 0)
|
||||
if showControls {
|
||||
PlayerControls(
|
||||
host: host,
|
||||
player: player,
|
||||
roomForVolume: geometry.size.width >= volumeSliderMinWidth,
|
||||
landscapeLocked: landscapeLocked,
|
||||
onRotate: { landscapeLocked = OrientationLock.toggle() },
|
||||
onSheet: { sheet = $0 },
|
||||
onInteract: { setControls(true) }
|
||||
)
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
|
||||
// Drawn whether or not the rest of the controls are up: an
|
||||
// opening lasts ninety seconds, and a button you have to wake
|
||||
// with a tap is one nobody reaches in time.
|
||||
VStack(alignment: .trailing, spacing: 8) {
|
||||
Spacer()
|
||||
if let offer = host.offer, !host.autoSkip {
|
||||
Button(offer.label) { host.seek(to: offer.target) }.buttonStyle(.tonal)
|
||||
}
|
||||
UpNextCard(
|
||||
next: host.next,
|
||||
visible: host.autoplay && !player.paused && host.chapters.creditsStart.map { player.position >= $0 } == true,
|
||||
onPlayNow: host.advance
|
||||
)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
.padding(.trailing, 16)
|
||||
.padding(.bottom, showControls ? 132 : 24)
|
||||
|
||||
// Only while nothing else explains the still picture: an error
|
||||
// is the more useful thing to say.
|
||||
if player.stalled && host.playbackError == nil {
|
||||
VStack(spacing: 12) {
|
||||
ProgressView().controlSize(.large).tint(scheme.primary)
|
||||
if player.buffering {
|
||||
Text(player.cachePercent > 0 ? "Buffering… \(Int(player.cachePercent.rounded()))%" : "Buffering…")
|
||||
.textStyle(.labelMedium).foregroundStyle(scheme.onSurface)
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.background(scheme.surfaceContainerHighest, in: RoundedRectangle(cornerRadius: Corner.standard))
|
||||
}
|
||||
|
||||
if let message = host.playbackError ?? player.problem {
|
||||
VStack(spacing: 4) {
|
||||
Text(message).foregroundStyle(scheme.onErrorContainer).multilineTextAlignment(.center)
|
||||
// Dismissible, because the message is not always fatal
|
||||
// — a subtitle track that failed to load leaves an
|
||||
// episode that plays perfectly well behind it.
|
||||
Button("Dismiss", action: host.dismissProblem)
|
||||
.buttonStyle(.quiet)
|
||||
.tint(scheme.onErrorContainer)
|
||||
}
|
||||
.textStyle(.bodyLarge)
|
||||
.padding(16)
|
||||
.background(scheme.errorContainer, in: RoundedRectangle(cornerRadius: Corner.standard))
|
||||
.padding(32)
|
||||
}
|
||||
}
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.2), value: showControls)
|
||||
.statusBarHidden(!showControls)
|
||||
.persistentSystemOverlays(.hidden)
|
||||
.onAppear { setControls(true) }
|
||||
.onChange(of: player.paused) { setControls(showControls) }
|
||||
.sheet(item: $sheet) { which in
|
||||
switch which {
|
||||
case .speed: SpeedSheet(current: host.speed, onSelect: host.setSpeed)
|
||||
case .episodes: EpisodeSheet(episodes: host.episodes, playing: host.index, onSelect: host.jump)
|
||||
case .chapters: ChapterSheet(chapters: host.chapters.entries, position: player.position) { host.seek(to: $0.start) }
|
||||
case let .tracks(type):
|
||||
TrackSheet(type: type, tracks: player.tracks.filter { $0.type == type }) { host.select(type: type, track: $0) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var topBar: some View {
|
||||
HStack(spacing: 0) {
|
||||
// Two ways out, because they mean different things: this leaves the
|
||||
// video and keeps the episode playing, ✕ ends it.
|
||||
IconButton("chevron.down", "Leave the video", tint: .white) {
|
||||
// The rest of the app is not a landscape app.
|
||||
landscapeLocked = false
|
||||
OrientationLock.set(false)
|
||||
host.minimized = true
|
||||
}
|
||||
// The episode leads and the show follows it: which episode is the
|
||||
// thing a viewer who has just woken the controls is checking.
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(host.episode?.label ?? "").textStyle(.titleMedium).foregroundStyle(.white).lineLimit(1)
|
||||
Text([host.title?.displayName, host.episode?.detail].compactMap { $0 }.filter { !$0.isEmpty }.joined(separator: " • "))
|
||||
.textStyle(.bodySmall).foregroundStyle(.white.opacity(0.7)).lineLimit(1)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
IconButton("xmark", "Stop playback", tint: .white, action: host.close)
|
||||
if player.duration > 0, player.duration - player.position >= 1 {
|
||||
Text("\(clock(player.duration - player.position)) left")
|
||||
.textStyle(.labelMedium).foregroundStyle(.white.opacity(0.7))
|
||||
.padding(.leading, 12).padding(.trailing, 4)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(scheme.scrim.opacity(0.45).ignoresSafeArea(edges: [.top, .horizontal]))
|
||||
}
|
||||
|
||||
/// Show or hide the controls; shown and playing, they hide themselves.
|
||||
private func setControls(_ shown: Bool) {
|
||||
showControls = shown
|
||||
hideTask?.cancel()
|
||||
guard shown, !player.paused else { return }
|
||||
hideTask = Task {
|
||||
try? await Task.sleep(for: .seconds(controlsSeconds))
|
||||
if !Task.isCancelled { showControls = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum PlayerSheet: Identifiable {
|
||||
case speed, episodes, chapters
|
||||
case tracks(String)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .speed: "speed"
|
||||
case .episodes: "episodes"
|
||||
case .chapters: "chapters"
|
||||
case let .tracks(type): "tracks-\(type)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything the transport offers, in two rows: the seek bar on its own,
|
||||
/// then the transport cluster on the left, where a thumb already is, and the
|
||||
/// pickers on the right.
|
||||
private struct PlayerControls: View {
|
||||
let host: PlayerHost
|
||||
let player: MpvPlayer
|
||||
let roomForVolume: Bool
|
||||
let landscapeLocked: Bool
|
||||
let onRotate: () -> Void
|
||||
let onSheet: (PlayerSheet) -> Void
|
||||
let onInteract: () -> Void
|
||||
@Environment(\.scheme) private var scheme
|
||||
// Where the thumb is while a finger is on it: a seek per drag step is a
|
||||
// block fetch per drag step, and the thumb would snap back under the
|
||||
// finger between readings.
|
||||
@State private var scrubbing: Double?
|
||||
@State private var dragVolume: Double?
|
||||
|
||||
var body: some View {
|
||||
let duration = max(player.duration, 1)
|
||||
VStack(spacing: 0) {
|
||||
HStack(spacing: 8) {
|
||||
Text(clock(scrubbing ?? player.position)).textStyle(.labelMedium).foregroundStyle(.white).monospacedDigit()
|
||||
AccentTrack(
|
||||
value: Binding(
|
||||
get: { scrubbing ?? min(max(player.position, 0), duration) },
|
||||
set: { scrubbing = $0 }
|
||||
),
|
||||
range: 0 ... duration,
|
||||
buffered: player.duration > 0 ? player.cachedUntil / player.duration : 0,
|
||||
ticks: player.duration > 0 && host.chapters.entries.count > 1
|
||||
? host.chapters.entries.dropFirst().map { $0.start / player.duration } : [],
|
||||
onEditing: { editing in
|
||||
onInteract()
|
||||
if !editing, let target = scrubbing {
|
||||
host.seek(to: target)
|
||||
scrubbing = nil
|
||||
}
|
||||
}
|
||||
)
|
||||
Text(clock(player.duration)).textStyle(.labelMedium).foregroundStyle(.white).monospacedDigit()
|
||||
}
|
||||
HStack(spacing: 0) {
|
||||
CompactIcon("backward.end.fill", "Previous episode", enabled: host.index > 0, action: host.retreat)
|
||||
CompactIcon("gobackward.10", "Back ten seconds") { host.seek(to: player.position - 10) }
|
||||
Button { host.setPaused(!player.paused); onInteract() } label: {
|
||||
Image(systemName: player.paused ? "play.fill" : "pause.fill")
|
||||
.font(.system(size: 30))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 48, height: 48)
|
||||
}
|
||||
.accessibilityLabel(player.paused ? "Play" : "Pause")
|
||||
CompactIcon("goforward.30", "Forward thirty seconds") { host.seek(to: player.position + 30) }
|
||||
CompactIcon("forward.end.fill", "Next episode", enabled: host.next != nil, action: host.advance)
|
||||
Spacer(minLength: 4)
|
||||
if host.episodes.count > 1 {
|
||||
CompactIcon("rectangle.stack", "Episodes") { onSheet(.episodes) }
|
||||
}
|
||||
if host.chapters.entries.count > 1 {
|
||||
CompactIcon("list.bullet", "Chapters") { onSheet(.chapters) }
|
||||
}
|
||||
CompactIcon("speedometer", "Playback speed") { onSheet(.speed) }
|
||||
CompactIcon("waveform", "Audio track") { onSheet(.tracks("audio")) }
|
||||
CompactIcon("captions.bubble", "Subtitles") { onSheet(.tracks("sub")) }
|
||||
CompactIcon(player.muted ? "speaker.slash.fill" : "speaker.wave.2.fill", "Mute") {
|
||||
player.setMuted(!player.muted)
|
||||
host.rememberVolume(muted: !player.muted)
|
||||
}
|
||||
// The app's own volume, which is not the device's: a file
|
||||
// mastered quiet is turned up here and stays up for the next.
|
||||
if roomForVolume {
|
||||
Slider(
|
||||
value: Binding(
|
||||
get: { dragVolume ?? min(max(player.volume, 0), 100) },
|
||||
set: { dragVolume = $0; player.setVolume($0) }
|
||||
),
|
||||
in: 0 ... 100
|
||||
) { editing in
|
||||
onInteract()
|
||||
if !editing, let chosen = dragVolume {
|
||||
dragVolume = nil
|
||||
host.rememberVolume(volume: chosen)
|
||||
}
|
||||
}
|
||||
.tint(scheme.primary)
|
||||
.frame(width: 96)
|
||||
}
|
||||
CompactIcon(
|
||||
landscapeLocked ? "arrow.down.right.and.arrow.up.left" : "arrow.up.left.and.arrow.down.right",
|
||||
landscapeLocked ? "Unlock rotation" : "Lock to landscape",
|
||||
action: onRotate
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 4)
|
||||
.background(scheme.scrim.opacity(0.68).ignoresSafeArea(edges: [.bottom, .horizontal]))
|
||||
}
|
||||
}
|
||||
|
||||
private struct CompactIcon: View {
|
||||
let icon: String
|
||||
let label: String
|
||||
var enabled = true
|
||||
let action: () -> Void
|
||||
|
||||
init(_ icon: String, _ label: String, enabled: Bool = true, action: @escaping () -> Void) {
|
||||
self.icon = icon
|
||||
self.label = label
|
||||
self.enabled = enabled
|
||||
self.action = action
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 19))
|
||||
.foregroundStyle(enabled ? Color.white : Color.white.opacity(0.35))
|
||||
.frame(width: 40, height: 40)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!enabled)
|
||||
.accessibilityLabel(label)
|
||||
}
|
||||
}
|
||||
|
||||
/// The countdown into the next episode, shown once the credits run starts.
|
||||
///
|
||||
/// "Watch till the end" holds for the rest of the file: someone who wants the
|
||||
/// post-credits scene should have to say so once, not once every ten seconds.
|
||||
private struct UpNextCard: View {
|
||||
let next: EpisodeRecord?
|
||||
let visible: Bool
|
||||
let onPlayNow: () -> Void
|
||||
@Environment(\.scheme) private var scheme
|
||||
@State private var held = false
|
||||
@State private var remaining = upNextSeconds
|
||||
@State private var heldFor: String?
|
||||
|
||||
var body: some View {
|
||||
let showing = visible && next != nil && !(held && heldFor == next?.linkId)
|
||||
Group {
|
||||
if showing, let upNext = next {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 12) {
|
||||
// The next episode's still, so the card says which
|
||||
// episode without being read; the ring over it is both
|
||||
// the countdown and the button that skips it.
|
||||
Button(action: onPlayNow) {
|
||||
RemoteArtwork(upNext.stillUrl, upNext.label, fallback: upNext.thumbnailSource, labelled: false)
|
||||
.frame(width: 112)
|
||||
.aspectRatio(16 / 9, contentMode: .fit)
|
||||
.overlay {
|
||||
ZStack {
|
||||
Circle().fill(.black.opacity(0.5))
|
||||
Circle().stroke(.white.opacity(0.25), lineWidth: 3)
|
||||
Circle()
|
||||
.trim(from: 0, to: Double(remaining) / Double(upNextSeconds))
|
||||
.stroke(.white, style: StrokeStyle(lineWidth: 3, lineCap: .round))
|
||||
.rotationEffect(.degrees(-90))
|
||||
.animation(.linear(duration: 1), value: remaining)
|
||||
Image(systemName: "play.fill").foregroundStyle(.white)
|
||||
}
|
||||
.frame(width: 40, height: 40)
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: Corner.standard))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Play now")
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Up next in \(remaining)s").textStyle(.labelMedium).foregroundStyle(scheme.onSurfaceVariant)
|
||||
Text(upNext.providerName ?? upNext.label).textStyle(.titleSmall).foregroundStyle(scheme.onSurface).lineLimit(2)
|
||||
}
|
||||
}
|
||||
Button("Watch till the end") {
|
||||
held = true
|
||||
heldFor = upNext.linkId
|
||||
}
|
||||
.buttonStyle(.quiet)
|
||||
}
|
||||
.padding(12)
|
||||
.frame(width: 280)
|
||||
.background(scheme.surfaceContainerHighest, in: RoundedRectangle(cornerRadius: Corner.standard))
|
||||
.task(id: upNext.linkId) {
|
||||
remaining = upNextSeconds
|
||||
while remaining > 0 {
|
||||
try? await Task.sleep(for: .seconds(1))
|
||||
if Task.isCancelled { return }
|
||||
remaining -= 1
|
||||
}
|
||||
onPlayNow()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct SpeedSheet: View {
|
||||
let current: Double
|
||||
let onSelect: (Double) -> Void
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
DialogSheet("Playback speed") {
|
||||
ForEach(speeds, id: \.self) { rate in
|
||||
Button {
|
||||
onSelect(rate)
|
||||
dismiss()
|
||||
} label: {
|
||||
Text((abs(rate - current) < 0.001 ? "✓ " : "") + (rate == 1 ? "Normal" : "\(rate.formatted())×"))
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.edgedWide)
|
||||
}
|
||||
} buttons: {
|
||||
Button("Close") { dismiss() }.buttonStyle(.tonal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ChapterSheet: View {
|
||||
let chapters: [ChapterEntry]
|
||||
let position: Double
|
||||
let onSelect: (ChapterEntry) -> Void
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
let current = chapters.lastIndex { position + 0.001 >= $0.start }
|
||||
DialogSheet("Chapters") {
|
||||
ForEach(Array(chapters.enumerated()), id: \.element.index) { position, chapter in
|
||||
Button {
|
||||
onSelect(chapter)
|
||||
dismiss()
|
||||
} label: {
|
||||
Text((position == current ? "✓ " : "") + "\(clock(chapter.start)) \(chapter.label)")
|
||||
.lineLimit(1)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.buttonStyle(.edgedWide)
|
||||
}
|
||||
} buttons: {
|
||||
Button("Close") { dismiss() }.buttonStyle(.tonal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct TrackSheet: View {
|
||||
let type: String
|
||||
let tracks: [MpvTrack]
|
||||
let onSelect: (MpvTrack?) -> Void
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
DialogSheet(type == "audio" ? "Audio track" : "Subtitle track") {
|
||||
if type == "sub" {
|
||||
Button {
|
||||
onSelect(nil)
|
||||
dismiss()
|
||||
} label: {
|
||||
Text(tracks.contains(where: \.selected) ? "Off" : "✓ Off").frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.buttonStyle(.edgedWide)
|
||||
}
|
||||
ForEach(tracks) { track in
|
||||
Button {
|
||||
onSelect(track)
|
||||
dismiss()
|
||||
} label: {
|
||||
Text(track.selected ? "✓ \(track.label)" : track.label).frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.buttonStyle(.edgedWide)
|
||||
}
|
||||
} buttons: {
|
||||
Button("Close") { dismiss() }.buttonStyle(.tonal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The title's episodes, from the player, opened scrolled to the one playing
|
||||
/// so the next few are in view. Each row has its still, its name, and whether
|
||||
/// it is playing, watched or part way.
|
||||
private struct EpisodeSheet: View {
|
||||
let episodes: [EpisodeRecord]
|
||||
let playing: Int
|
||||
let onSelect: (Int) -> Void
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.scheme) private var scheme
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text("Episodes").textStyle(.titleMedium).foregroundStyle(scheme.onSurface)
|
||||
.padding(.horizontal, 24).padding(.vertical, 8)
|
||||
ScrollViewReader { reader in
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 0) {
|
||||
ForEach(Array(episodes.enumerated()), id: \.element.linkId) { position, episode in
|
||||
row(position, episode).id(position)
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 24)
|
||||
}
|
||||
.onAppear { reader.scrollTo(max(playing - 1, 0), anchor: .top) }
|
||||
}
|
||||
}
|
||||
.padding(.top, 20)
|
||||
.presentationDetents([.medium, .large])
|
||||
.presentationDragIndicator(.visible)
|
||||
.presentationBackground(scheme.surfaceContainerLow)
|
||||
.presentationCornerRadius(Corner.sheet)
|
||||
}
|
||||
|
||||
private func row(_ position: Int, _ episode: EpisodeRecord) -> some View {
|
||||
let current = position == playing
|
||||
return Button {
|
||||
dismiss()
|
||||
onSelect(position)
|
||||
} label: {
|
||||
HStack(spacing: 12) {
|
||||
RemoteArtwork(episode.stillUrl, episode.label, fallback: episode.thumbnailSource, labelled: false)
|
||||
.frame(width: 112)
|
||||
.aspectRatio(16 / 9, contentMode: .fit)
|
||||
.overlay(alignment: .bottom) {
|
||||
if let progress = episode.progress, !episode.watched, !current { AccentProgress(progress: progress) }
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: Corner.standard))
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(episode.providerName ?? episode.label).textStyle(.titleSmall).foregroundStyle(scheme.onSurface).lineLimit(2)
|
||||
Text(current ? "Playing"
|
||||
: episode.watched ? "\(episode.label) · Watched"
|
||||
: episode.resumeAt != nil ? "\(episode.label) · Part way" : episode.label)
|
||||
.textStyle(.bodySmall)
|
||||
.foregroundStyle(current ? scheme.primary : scheme.onSurfaceVariant)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
.background(current ? scheme.secondaryContainer : .clear)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
/// The bar that says playback is still going, and gets back to it.
|
||||
///
|
||||
/// Leaving the player does not stop it — the episode keeps playing — so there
|
||||
/// has to be something on screen that says so and takes one tap to return to.
|
||||
struct MiniTransport: View {
|
||||
let host: PlayerHost
|
||||
@Environment(\.scheme) private var scheme
|
||||
|
||||
var body: some View {
|
||||
if let episode = host.episode, let title = host.title {
|
||||
HStack(spacing: 0) {
|
||||
RemoteArtwork(title.posterUrl ?? title.backdropUrl, title.displayName, fallback: episode.thumbnailSource)
|
||||
.frame(width: 64)
|
||||
.aspectRatio(16 / 9, contentMode: .fit)
|
||||
.clipShape(RoundedRectangle(cornerRadius: Corner.standard))
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(episode.label).textStyle(.bodyLarge, semibold: true).foregroundStyle(scheme.onSurface).lineLimit(1)
|
||||
Text(title.displayName).textStyle(.bodySmall).foregroundStyle(scheme.onSurfaceVariant).lineLimit(1)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 12)
|
||||
let paused = host.player?.paused ?? true
|
||||
IconButton(paused ? "play.fill" : "pause.fill", paused ? "Play" : "Pause", tint: scheme.onSurface) {
|
||||
host.setPaused(!paused)
|
||||
}
|
||||
IconButton("xmark", "Stop playback", tint: scheme.onSurface, action: host.close)
|
||||
}
|
||||
.padding(8)
|
||||
.card()
|
||||
.shadow(color: .black.opacity(0.25), radius: 8, y: 2)
|
||||
.padding(.horizontal, 8)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { host.minimized = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The view libmpv draws into: its Metal layer, sized to this view.
|
||||
struct MpvView: UIViewRepresentable {
|
||||
let player: MpvPlayer
|
||||
|
||||
func makeUIView(context _: Context) -> LayerView {
|
||||
LayerView(layer: player.layer)
|
||||
}
|
||||
|
||||
func updateUIView(_: LayerView, context _: Context) {}
|
||||
|
||||
final class LayerView: UIView {
|
||||
private let video: CALayer
|
||||
|
||||
init(layer video: CALayer) {
|
||||
self.video = video
|
||||
super.init(frame: .zero)
|
||||
backgroundColor = .black
|
||||
video.removeFromSuperlayer()
|
||||
layer.addSublayer(video)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder _: NSCoder) { nil }
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
video.frame = bounds
|
||||
video.contentsScale = window?.screen.scale ?? UIScreen.main.scale
|
||||
CATransaction.commit()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Lock to landscape, or hand rotation back to the device.
|
||||
@MainActor
|
||||
enum OrientationLock {
|
||||
private(set) static var landscape = false
|
||||
|
||||
/// What the app delegate answers for every window.
|
||||
static var mask: UIInterfaceOrientationMask { landscape ? .landscape : .all }
|
||||
|
||||
/// Reports which way it went.
|
||||
static func toggle() -> Bool {
|
||||
set(!landscape)
|
||||
return landscape
|
||||
}
|
||||
|
||||
static func set(_ locked: Bool) {
|
||||
landscape = locked
|
||||
guard let scene = UIApplication.shared.connectedScenes.first(where: { $0 is UIWindowScene }) as? UIWindowScene else { return }
|
||||
scene.keyWindow?.rootViewController?.setNeedsUpdateOfSupportedInterfaceOrientations()
|
||||
scene.requestGeometryUpdate(.iOS(interfaceOrientations: mask))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user