refactor: concentrate playback transition ownership

This commit is contained in:
2026-10-11 23:20:26 +02:00
parent cc99a2a4f8
commit 8eba97579f
6 changed files with 559 additions and 238 deletions
+366 -232
View File
@@ -12,10 +12,11 @@ final class PlayerModel: ObservableObject {
@Published private(set) var fileName: String?
@Published private(set) var fileIcon: NSImage?
@Published private(set) var commandError: String?
@Published private(set) var pendingRevision: UInt64?
@Published private(set) var resolvingYouTube = false
@Published private(set) var isLiveStream = false
@Published private(set) var isNetworkMedia = false
private var transition = PlaybackTransition()
var pendingRevision: UInt64? { transition.pendingRevision }
var resolvingYouTube: Bool { transition.isResolving }
var isLiveStream: Bool { transition.isLive }
var isNetworkMedia: Bool { transition.isNetwork }
@Published private(set) var youtubeHeights: [Int] = []
@Published private(set) var youtubeHeight: Int?
@Published private(set) var youtubeQuality: Int?
@@ -29,8 +30,7 @@ final class PlayerModel: ObservableObject {
@Published private(set) var isOnTop = false
let compactWindow: CompactWindowController
let keyBindings: KeyBindings
@Published private(set) var playbackIntent: Bool?
private var playbackInFlight: (playing: Bool, revision: UInt64)?
var playbackIntent: Bool? { transition.playbackIntent }
@Published private(set) var fullScreenIntent: Bool?
@Published private(set) var compactIntent: Bool?
var effectivePlaying: Bool { playbackIntent ?? (state.status == PLAYER_PLAYING) }
@@ -68,18 +68,10 @@ final class PlayerModel: ObservableObject {
private var windowActive = true
private var lastAudibleVolume = 1.0
private var needsVideoFit = false
private var seekPending = false
private var youtubeTask: Task<Void, Never>?
private var youtubeRequest: UUID?
private var resolvingAutomaticQuality = false
private let qualityClock: () -> Double
private var automaticQuality = AutomaticQuality()
private var qualityNow: Double { qualityClock() }
private let playbackProgress: PlaybackProgress
private var progressURL: URL?
private var resumePosition: Double?
private var resumePlaying = true
private var lastSavedPosition: Double?
private var systemPlayback: SystemPlaybackControls?
private(set) var progressInitializationError: String?
private var reportingEnabled = false
@@ -131,7 +123,7 @@ final class PlayerModel: ObservableObject {
systemPlayback = SystemPlaybackControls(model: self)
}
var isBusy: Bool { (resolvingYouTube && !resolvingAutomaticQuality) || pendingRevision != nil || state.status == PLAYER_OPENING }
var isBusy: Bool { transition.isBusy(state.status) }
var canTogglePlayback: Bool {
handle != nil && !isBusy &&
(state.status == PLAYER_PAUSED || state.status == PLAYER_PLAYING || state.status == PLAYER_ENDED)
@@ -139,14 +131,14 @@ final class PlayerModel: ObservableObject {
var canSeek: Bool { canTogglePlayback && state.can_seek && state.duration.isFinite && state.duration > 0 }
var canStepFrame: Bool { canTogglePlayback && state.has_video && state.can_seek && !isSeeking }
var canChangeYouTubeQuality: Bool {
canTogglePlayback && !youtubeHeights.isEmpty && !isSeeking && !seekPending && !state.seek_pending
canTogglePlayback && !youtubeHeights.isEmpty && !isSeeking && !transition.seekPending && !state.seek_pending
}
var canOpen: Bool { handle != nil }
var canClose: Bool { handle != nil && (isBusy || state.status != PLAYER_EMPTY || fileName != nil) }
var canSetVolume: Bool { handle != nil && !isBusy && state.has_audio && state.status != PLAYER_ERROR }
var duration: Double { state.duration.isFinite ? max(0, state.duration) : 0 }
var bufferedRange: ClosedRange<Double>? {
guard fileName != nil, canSeek, !seekPending, !state.seek_pending,
guard fileName != nil, canSeek, !transition.seekPending, !state.seek_pending,
state.buffered_start.isFinite, state.buffered_end.isFinite,
state.buffered_start >= 0, state.buffered_end > state.buffered_start,
state.buffered_end <= duration else { return nil }
@@ -214,14 +206,14 @@ final class PlayerModel: ObservableObject {
func setAutomaticYouTubeQuality(_ enabled: Bool) {
automaticYouTubeQuality = enabled
automaticQuality = AutomaticQuality()
if !enabled && resolvingAutomaticQuality { cancelYouTubeResolution() }
if !enabled && transition.resolvingAutomaticQuality { cancelYouTubeResolution() }
revealControls()
}
func setYouTubeQuality(_ height: Int?) {
// A manual choice is an override, including reselecting the active/default resolution.
setAutomaticYouTubeQuality(false)
guard canChangeYouTubeQuality, let url = progressURL, let handle,
guard canChangeYouTubeQuality, let url = transition.mediaURL, let handle,
height == nil || youtubeHeights.contains(height!) else { return }
guard height != youtubeQuality else { return }
let target = height ?? youtubeHeights.filter { $0 <= 1080 }.max()
@@ -229,180 +221,148 @@ final class PlayerModel: ObservableObject {
youtubeQuality = height
return
}
let current = player_get_state(handle)
let position = !isLiveStream && current.can_seek ? current.position : nil
resolveYouTube(url, height: height, restoring: (position, effectivePlaying))
let change = transition.qualityChange(player_get_state(handle), playing: effectivePlaying)
resolveYouTube(url, height: height, change: change)
}
private func resolveYouTube(_ url: URL, height: Int?, restoring: (position: Double?, playing: Bool)? = nil,
automatic: Bool = false) {
private func resolveYouTube(_ url: URL, height: Int?, change: MediaChange = .open) {
guard let handle else { return }
cancelYouTubeResolution()
if !automatic { automaticQuality = AutomaticQuality() }
if !automatic && !player_pause(handle) {
if !change.automatic { automaticQuality = AutomaticQuality() }
if !change.automatic && !player_pause(handle) {
commandError = "Could not pause the current media to open YouTube. Please try again."
revealControls()
return
}
if !automatic {
playbackIntent = nil
playbackInFlight = nil
}
let request = UUID()
youtubeRequest = request
resolvingYouTube = true
resolvingAutomaticQuality = automatic
objectWillChange.send()
let request = transition.beginResolution(change)
systemPlayback?.update()
commandError = nil
revealControls()
youtubeTask = Task { [weak self] in
let task = Task { [weak self] in
do {
let video = try await YouTubeResolver.resolve(url, height: height)
guard !Task.isCancelled, let self, self.youtubeRequest == request, self.handle != nil else { return }
if automatic {
guard !Task.isCancelled, let self, let handle = self.handle else { return }
self.objectWillChange.send()
guard var change = self.transition.finishResolution(request) else { return }
if change.automatic {
let current = player_get_state(handle)
guard self.automaticYouTubeQuality, current.status == PLAYER_PLAYING,
!self.isSeeking, !self.seekPending, !current.seek_pending,
self.pendingRevision == nil,
self.isLiveStream || !current.duration.isFinite || current.duration <= 0 ||
current.duration - current.position > 15 else {
self.cancelYouTubeResolution()
guard self.transition.canApplyAutomaticQuality(current, enabled: self.automaticYouTubeQuality,
previewingSeek: self.isSeeking) else {
self.automaticQuality.resetHealth()
return
}
let position = !self.isLiveStream && current.can_seek ? current.position : nil
self.cancelYouTubeResolution()
self.openResolvedYouTube(video, height: height, restoring: (position, true), automatic: true)
} else {
self.cancelYouTubeResolution()
self.openResolvedYouTube(video, height: height, restoring: restoring)
change = self.transition.qualityChange(current, playing: true, automatic: true)
}
self.openMedia(.youtube(video, height: height), change: change)
} catch {
guard !Task.isCancelled, let self, self.youtubeRequest == request else { return }
self.cancelYouTubeResolution()
if automatic {
self.automaticQuality.extractionFailed(at: self.qualityNow)
} else {
self.commandError = error.localizedDescription
if restoring?.playing == true, let handle = self.handle, !player_play(handle) {
self.commandError = error.localizedDescription + "\nCould not resume the previous playback."
}
self.revealControls()
}
guard !Task.isCancelled, let self else { return }
self.objectWillChange.send()
guard let change = self.transition.finishResolution(request) else { return }
self.failYouTubeChange(change, error: error)
}
}
transition.attachResolution(task, request: request)
}
private func cancelYouTubeResolution() {
youtubeRequest = nil
youtubeTask?.cancel()
youtubeTask = nil
resolvingYouTube = false
resolvingAutomaticQuality = false
guard transition.isResolving else { return }
objectWillChange.send()
transition.cancelResolution()
}
private func openResolvedYouTube(_ video: YouTubeVideo, height: Int?, restoring: (position: Double?, playing: Bool)?,
automatic: Bool = false) {
private func failYouTubeChange(_ change: MediaChange, error: Error? = nil) {
if change.automatic {
automaticQuality.extractionFailed(at: qualityNow)
return
}
commandError = error?.localizedDescription ?? "Could not queue the YouTube video to open. Please try again."
if change.isQuality && change.playing, let handle, !player_play(handle) {
commandError = error.map { $0.localizedDescription + "\nCould not resume the previous playback." } ??
"Could not queue the YouTube video or resume the previous playback. Please try again."
}
revealControls()
}
private func openMedia(_ source: MediaSource, change: MediaChange = .open) {
guard let handle else { return }
saveProgress()
let revision = player_get_state(handle).revision
let initialPosition = video.isLive ? 0 :
(restoring.map { $0.position ?? 0 } ?? playbackProgress.position(for: video.webpageURL) ?? 0)
let accepted = video.webpageURL.absoluteString.withCString { mediaURL in
video.video.url.withCString { videoURL in
video.video.headers.withCString { videoHeaders in
func enqueue(audioURL: UnsafePointer<CChar>?, audioHeaders: UnsafePointer<CChar>?) -> Bool {
var source = PlayerNetworkSource(video_url: videoURL, audio_url: audioURL,
video_headers: videoHeaders, audio_headers: audioHeaders,
duration: video.duration, is_live: video.isLive,
media_url: mediaURL, initial_position: initialPosition)
return player_open_network(handle, &source)
}
if let audio = video.audio {
return audio.url.withCString { audioURL in
audio.headers.withCString { enqueue(audioURL: audioURL, audioHeaders: $0) }
let savedPosition = change.isQuality ? change.position :
(source.live ? nil : playbackProgress.position(for: source.url))
let accepted: Bool
switch source {
case .file(let url):
accepted = url.withUnsafeFileSystemRepresentation { path in
guard let path else { return false }
return player_open(handle, path)
}
case .youtube(let video, _):
let initialPosition = source.live ? 0 : savedPosition ?? 0
accepted = video.webpageURL.absoluteString.withCString { mediaURL in
video.video.url.withCString { videoURL in
video.video.headers.withCString { videoHeaders in
func enqueue(audioURL: UnsafePointer<CChar>?, audioHeaders: UnsafePointer<CChar>?) -> Bool {
var input = PlayerNetworkSource(video_url: videoURL, audio_url: audioURL,
video_headers: videoHeaders, audio_headers: audioHeaders,
duration: video.duration, is_live: video.isLive,
media_url: mediaURL, initial_position: initialPosition)
return player_open_network(handle, &input)
}
if let audio = video.audio {
return audio.url.withCString { audioURL in
audio.headers.withCString { enqueue(audioURL: audioURL, audioHeaders: $0) }
}
}
return enqueue(audioURL: nil, audioHeaders: nil)
}
return enqueue(audioURL: nil, audioHeaders: nil)
}
}
}
guard accepted else {
if automatic {
automaticQuality.extractionFailed(at: qualityNow)
return
switch source {
case .file:
commandError = "Could not queue the file to open. Please try again."
case .youtube:
failYouTubeChange(change)
}
commandError = "Could not queue the YouTube video to open. Please try again."
if restoring?.playing == true, !player_play(handle) {
commandError = "Could not queue the YouTube video or resume the previous playback. Please try again."
}
revealControls()
return
}
didAcceptOpen(revision: revision, title: video.title, icon: nil, url: video.webpageURL,
live: video.isLive, restoring: restoring, automatic: automatic)
youtubeHeights = video.availableHeights
youtubeHeight = video.height
youtubeQuality = height
}
private func didAcceptOpen(revision: UInt64, title: String, icon: NSImage?, url: URL, live: Bool = false,
restoring: (position: Double?, playing: Bool)? = nil, automatic: Bool = false) {
guard let handle else { return }
playbackIntent = nil
playbackInFlight = nil
if automatic { automaticQuality.resetHealth() } else { automaticQuality = AutomaticQuality() }
pendingRevision = revision
fileName = title
fileIcon = icon
isLiveStream = live
isNetworkMedia = !url.isFileURL
youtubeHeights = []
youtubeHeight = nil
youtubeQuality = nil
progressURL = url
if !url.isFileURL {
// Network restoration is part of the backend open transaction, so
// reporting cannot observe a pre-resume position as initial state.
resumePosition = nil
} else if let restoring {
resumePosition = restoring.position
} else {
resumePosition = live ? nil : playbackProgress.position(for: url)
transition.acceptOpen(revision: revision, source: source, change: change, savedPosition: savedPosition)
if change.automatic { automaticQuality.resetHealth() } else { automaticQuality = AutomaticQuality() }
switch source {
case .file(let url):
fileName = url.lastPathComponent
fileIcon = NSWorkspace.shared.icon(forFile: url.path)
youtubeHeights = []
youtubeHeight = nil
youtubeQuality = nil
case .youtube(let video, let height):
fileName = video.title
fileIcon = nil
youtubeHeights = video.availableHeights
youtubeHeight = video.height
youtubeQuality = height
}
resumePlaying = restoring?.playing ?? true
lastSavedPosition = nil
needsVideoFit = restoring == nil
commandError = resumePosition != nil || !resumePlaying || player_play(handle) ? nil :
needsVideoFit = !change.isQuality
commandError = transition.needsLocalRestoration || !change.playing || player_play(handle) ? nil :
"Could not queue playback. Please try again."
isSeeking = false
seekPending = false
seekPosition = 0
window?.representedURL = url
window?.title = title
window?.representedURL = source.url
window?.title = fileName ?? "terebi"
revealControls()
systemPlayback?.update()
}
func open(_ url: URL) {
guard canOpen, let handle else { return }
guard canOpen else { return }
guard url.isFileURL else {
openYouTube(url.absoluteString)
return
}
cancelYouTubeResolution()
saveProgress()
let revision = player_get_state(handle).revision
let accepted = url.withUnsafeFileSystemRepresentation { path in
guard let path else { return false }
return player_open(handle, path)
}
guard accepted else {
commandError = "Could not queue the file to open. Please try again."
return
}
didAcceptOpen(revision: revision, title: url.lastPathComponent,
icon: NSWorkspace.shared.icon(forFile: url.path), url: url)
openMedia(.file(url))
}
func togglePlayback() {
@@ -412,33 +372,29 @@ final class PlayerModel: ObservableObject {
@discardableResult
func setPlayback(playing: Bool) -> Bool {
guard let handle, canTogglePlayback else { return false }
if !playing && resolvingAutomaticQuality { cancelYouTubeResolution() }
if !playing && transition.resolvingAutomaticQuality { cancelYouTubeResolution() }
if !playing { automaticQuality.resetHealth() }
if playbackInFlight != nil {
playbackIntent = playing
switch transition.setPlayback(playing, status: state.status, handle: handle) {
case .unchanged:
return true
case .deferred:
objectWillChange.send()
revealControls()
return true
case .submitted(let accepted):
commandError = accepted ? nil : "Could not queue playback. Please try again."
revealControls()
return accepted
}
guard effectivePlaying != playing else { return true }
let revision = player_get_state(handle).revision
let accepted = playing ? player_play(handle) : player_pause(handle)
if accepted {
playbackIntent = playing
playbackInFlight = (playing, revision)
}
commandError = accepted ? nil : "Could not queue playback. Please try again."
revealControls()
return accepted
}
func finishSeeking() {
if resolvingAutomaticQuality { cancelYouTubeResolution() }
if transition.resolvingAutomaticQuality { cancelYouTubeResolution() }
automaticQuality.resetHealth()
isSeeking = false
guard let handle, canSeek else { return }
let position = min(max(0, seekPosition), duration)
let accepted = player_seek(handle, position)
if accepted { seekPending = true }
let accepted = transition.seek(position, handle: handle)
commandError = accepted ? nil : "Could not queue the seek. Please try again."
revealControls()
}
@@ -452,25 +408,18 @@ final class PlayerModel: ObservableObject {
commandError = "Could not queue closing the file. Please try again."
return
}
playbackIntent = nil
playbackInFlight = nil
transition.closeMedia()
compactIntent = nil
compactWindow.restore(resumeFullScreen: false)
fileName = nil
fileIcon = nil
isLiveStream = false
isNetworkMedia = false
youtubeHeights = []
youtubeHeight = nil
youtubeQuality = nil
progressURL = nil
resumePosition = nil
lastSavedPosition = nil
needsVideoFit = false
window?.aspectRatio = .zero
commandError = nil
isSeeking = false
seekPending = false
window?.representedURL = nil
window?.title = "terebi"
revealControls()
@@ -480,14 +429,11 @@ final class PlayerModel: ObservableObject {
@discardableResult
func seekRelative(_ seconds: Double) -> Bool {
guard canSeek, let handle, seconds.isFinite else { return false }
if resolvingAutomaticQuality { cancelYouTubeResolution() }
if transition.resolvingAutomaticQuality { cancelYouTubeResolution() }
automaticQuality.resetHealth()
let target = min(max(0, (seekPending ? seekPosition : state.position) + seconds), duration)
let accepted = player_seek(handle, target)
if accepted {
seekPosition = target
seekPending = true
}
let target = min(max(0, (transition.seekPending ? seekPosition : state.position) + seconds), duration)
let accepted = transition.seek(target, handle: handle)
if accepted { seekPosition = target }
commandError = accepted ? nil : "Could not queue the seek. Please try again."
revealControls()
return accepted
@@ -496,12 +442,7 @@ final class PlayerModel: ObservableObject {
@discardableResult
func stepFrame(_ direction: Int32) -> Bool {
guard canStepFrame, let handle else { return false }
let accepted = player_step_frame(handle, direction)
if accepted {
seekPending = false
playbackIntent = nil
playbackInFlight = nil
}
let accepted = transition.stepFrame(direction, handle: handle)
commandError = accepted ? nil : "Could not queue the frame step. Please try again."
revealControls()
return accepted
@@ -686,7 +627,7 @@ final class PlayerModel: ObservableObject {
}
func beginSeeking() {
if resolvingAutomaticQuality { cancelYouTubeResolution() }
if transition.resolvingAutomaticQuality { cancelYouTubeResolution() }
automaticQuality.resetHealth()
isSeeking = true
revealControls()
@@ -706,7 +647,7 @@ final class PlayerModel: ObservableObject {
private var shouldHideControls: Bool {
canTogglePlayback && state.has_video && fileName != nil &&
!isSeeking && !seekPending && !controlsHovered && !titleHovered && !controlsDragging &&
!isSeeking && !transition.seekPending && !controlsHovered && !titleHovered && !controlsDragging &&
(windowActive || isCompact) && errorText == nil &&
!NSWorkspace.shared.isVoiceOverEnabled
}
@@ -741,17 +682,8 @@ final class PlayerModel: ObservableObject {
}
private func saveProgress(_ snapshot: PlayerState? = nil, force: Bool = true) {
guard let handle, let url = progressURL, pendingRevision == nil,
resumePosition == nil, !isLiveStream else { return }
let current = snapshot ?? player_get_state(handle)
guard current.status == PLAYER_PAUSED || current.status == PLAYER_PLAYING || current.status == PLAYER_ENDED,
current.has_video, current.can_seek, !current.seek_pending,
current.duration.isFinite, current.duration > 0,
current.position.isFinite, current.position >= 0 else { return }
let position = current.status == PLAYER_ENDED ? 0 : min(current.position, current.duration)
guard force || lastSavedPosition == nil || abs(position - lastSavedPosition!) >= 5 else { return }
playbackProgress.save(position > 0 ? position : nil, for: url)
lastSavedPosition = position
guard let handle else { return }
transition.saveProgress(snapshot, handle: handle, force: force, history: playbackProgress)
}
private func poll() {
@@ -765,55 +697,28 @@ final class PlayerModel: ObservableObject {
}
func applyState(_ next: PlayerState) {
if let revision = pendingRevision {
guard next.revision != revision, next.status != PLAYER_OPENING else { return }
pendingRevision = nil
}
defer { systemPlayback?.update() }
let statusChanged = state.status != next.status
let observation = transition.advance(next,
forceCheckpoint: statusChanged && (next.status == PLAYER_PAUSED || next.status == PLAYER_ENDED),
handle: handle, history: playbackProgress)
guard observation.ready else { return }
defer { systemPlayback?.update() }
state = next
if next.status == PLAYER_EMPTY || next.status == PLAYER_ERROR {
playbackIntent = nil
playbackInFlight = nil
} else if let flight = playbackInFlight, next.revision != flight.revision,
next.status == PLAYER_ENDED || (next.status == PLAYER_PLAYING) == flight.playing,
next.status == PLAYER_PLAYING || next.status == PLAYER_PAUSED || next.status == PLAYER_ENDED {
let desired = playbackIntent
playbackIntent = nil
playbackInFlight = nil
if let desired, desired != flight.playing { setPlayback(playing: desired) }
}
if let playing = observation.playback { setPlayback(playing: playing) }
if next.status == PLAYER_EMPTY || next.status == PLAYER_ERROR ||
(!isBusy && !next.has_video) {
compactWindow.restore(resumeFullScreen: false)
}
if !next.seek_pending || next.status == PLAYER_ERROR { seekPending = false }
if !isSeeking && !seekPending {
if !isSeeking && !transition.seekPending {
seekPosition = min(max(0, next.position.isFinite ? next.position : 0), duration)
}
fitVideoWindow()
if statusChanged { revealControls() }
if let hideDeadline, ContinuousClock.now >= hideDeadline { hideControls() }
saveProgress(next, force: statusChanged && (next.status == PLAYER_PAUSED || next.status == PLAYER_ENDED))
updateAutomaticQuality()
if let saved = resumePosition {
resumePosition = nil
guard let handle, next.status == PLAYER_PAUSED || next.status == PLAYER_PLAYING else { return }
if next.has_video && next.can_seek && next.duration.isFinite && next.duration > 0 {
if saved < next.duration {
guard player_seek(handle, saved) else {
// Preserve the stored bookmark if restoration could not be queued.
progressURL = nil
commandError = "Could not resume saved progress. Please try opening the video again."
return
}
seekPosition = saved
seekPending = true
} else if let url = progressURL {
playbackProgress.save(nil, for: url)
}
}
commandError = !resumePlaying || player_play(handle) ? nil : "Could not queue playback. Please try again."
if let restored = observation.restored {
if let position = restored.position { seekPosition = position }
commandError = restored.error
}
}
@@ -822,12 +727,12 @@ final class PlayerModel: ObservableObject {
let reserve = bufferedRange.map { max(0, $0.upperBound - state.position) }
let sample = AutomaticQuality.Sample(
enabled: automaticYouTubeQuality, network: isNetworkMedia,
playing: effectivePlaying, opening: isBusy || resolvingYouTube || resumePosition != nil,
seeking: isSeeking || seekPending || state.seek_pending, live: isLiveStream,
playing: effectivePlaying, opening: transition.blocksAutomaticQuality(state.status),
seeking: isSeeking || transition.seekPending || state.seek_pending, live: isLiveStream,
buffering: state.buffering, position: state.position, duration: state.duration,
ahead: reserve, height: youtubeHeight, heights: youtubeHeights)
guard let height = automaticQuality.choose(sample, at: qualityNow), let url = progressURL else { return }
resolveYouTube(url, height: height, automatic: true)
guard let height = automaticQuality.choose(sample, at: qualityNow), let url = transition.mediaURL else { return }
resolveYouTube(url, height: height, change: .quality(position: nil, playing: true, automatic: true))
}
private func showReportingError(_ message: String, terminating: Bool = false) {
@@ -864,6 +769,235 @@ final class PlayerModel: ObservableObject {
handle = nil
player_destroy(player)
}
private enum MediaChange {
case open
case quality(position: Double?, playing: Bool, automatic: Bool)
var automatic: Bool {
if case .quality(_, _, let automatic) = self { return automatic }
return false
}
var playing: Bool {
if case .quality(_, let playing, _) = self { return playing }
return true
}
var position: Double? {
if case .quality(let position, _, _) = self { return position }
return nil
}
var isQuality: Bool {
if case .quality = self { return true }
return false
}
}
private enum MediaSource {
case file(URL)
case youtube(YouTubeVideo, height: Int?)
var url: URL {
switch self {
case .file(let url): return url
case .youtube(let video, _): return video.webpageURL
}
}
var live: Bool {
if case .youtube(let video, _) = self { return video.isLive }
return false
}
}
private struct PlaybackTransition {
enum PlaybackSubmission {
case unchanged
case deferred
case submitted(Bool)
}
private struct PlaybackRequest {
var desired: Bool
let submitted: Bool
let revision: UInt64
}
private struct Resolution {
let id: UUID
let change: MediaChange
var task: Task<Void, Never>?
}
private(set) var pendingRevision: UInt64?
private(set) var seekPending = false
private(set) var mediaURL: URL?
private(set) var isLive = false
private(set) var isNetwork = false
private var playback: PlaybackRequest?
private var resolution: Resolution?
private var restoration: (position: Double, playing: Bool)?
private var lastSavedPosition: Double?
var playbackIntent: Bool? { playback?.desired }
var isResolving: Bool { resolution != nil }
var resolvingAutomaticQuality: Bool { resolution?.change.automatic == true }
func isBusy(_ status: PlayerStatus) -> Bool {
(isResolving && !resolvingAutomaticQuality) || pendingRevision != nil || status == PLAYER_OPENING
}
func qualityChange(_ state: PlayerState, playing: Bool, automatic: Bool = false) -> MediaChange {
.quality(position: !isLive && state.can_seek ? state.position : nil,
playing: playing, automatic: automatic)
}
mutating func beginResolution(_ change: MediaChange) -> UUID {
if !change.automatic { playback = nil }
let id = UUID()
resolution = Resolution(id: id, change: change)
return id
}
mutating func attachResolution(_ task: Task<Void, Never>, request: UUID) {
guard resolution?.id == request else { task.cancel(); return }
resolution?.task = task
}
mutating func finishResolution(_ request: UUID) -> MediaChange? {
guard let resolution, resolution.id == request else { return nil }
let change = resolution.change
cancelResolution()
return change
}
mutating func cancelResolution() {
resolution?.task?.cancel()
resolution = nil
}
func canApplyAutomaticQuality(_ state: PlayerState, enabled: Bool, previewingSeek: Bool) -> Bool {
enabled && state.status == PLAYER_PLAYING && !previewingSeek && !seekPending &&
!state.seek_pending && pendingRevision == nil &&
(isLive || !state.duration.isFinite || state.duration <= 0 || state.duration - state.position > 15)
}
func blocksAutomaticQuality(_ status: PlayerStatus) -> Bool {
isBusy(status) || isResolving || restoration != nil
}
mutating func acceptOpen(revision: UInt64, source: MediaSource, change: MediaChange,
savedPosition: Double?) {
playback = nil
pendingRevision = revision
isLive = source.live
isNetwork = !source.url.isFileURL
mediaURL = source.url
// Network restoration is acknowledged by the backend open transaction.
restoration = isNetwork ? nil : savedPosition.map { ($0, change.playing) }
lastSavedPosition = nil
seekPending = false
}
mutating func advance(_ next: PlayerState, forceCheckpoint: Bool, handle: OpaquePointer?,
history: PlaybackProgress)
-> (ready: Bool, playback: Bool?, restored: (position: Double?, error: String?)?) {
if let revision = pendingRevision {
guard next.revision != revision, next.status != PLAYER_OPENING else { return (false, nil, nil) }
pendingRevision = nil
}
var followUp: Bool?
if next.status == PLAYER_EMPTY || next.status == PLAYER_ERROR {
playback = nil
} else if let request = playback, next.revision != request.revision,
next.status == PLAYER_ENDED || (next.status == PLAYER_PLAYING) == request.submitted,
next.status == PLAYER_PLAYING || next.status == PLAYER_PAUSED || next.status == PLAYER_ENDED {
playback = nil
if request.desired != request.submitted { followUp = request.desired }
}
if !next.seek_pending || next.status == PLAYER_ERROR { seekPending = false }
// Checkpoint before consuming local restoration: ready is not yet resumed.
let restored: (position: Double?, error: String?)?
if let handle {
saveProgress(next, handle: handle, force: forceCheckpoint, history: history)
restored = restoreLocalPosition(next, handle: handle, history: history)
} else {
restored = nil
}
return (true, followUp, restored)
}
mutating func setPlayback(_ playing: Bool, status: PlayerStatus, handle: OpaquePointer) -> PlaybackSubmission {
if playback != nil {
playback?.desired = playing
return .deferred
}
guard (status == PLAYER_PLAYING) != playing else { return .unchanged }
let revision = player_get_state(handle).revision
let accepted = playing ? player_play(handle) : player_pause(handle)
if accepted { playback = PlaybackRequest(desired: playing, submitted: playing, revision: revision) }
return .submitted(accepted)
}
mutating func seek(_ position: Double, handle: OpaquePointer) -> Bool {
let accepted = player_seek(handle, position)
if accepted { seekPending = true }
return accepted
}
mutating func stepFrame(_ direction: Int32, handle: OpaquePointer) -> Bool {
let accepted = player_step_frame(handle, direction)
if accepted {
seekPending = false
playback = nil
}
return accepted
}
mutating func closeMedia() {
playback = nil
isLive = false
isNetwork = false
mediaURL = nil
restoration = nil
lastSavedPosition = nil
seekPending = false
}
mutating func saveProgress(_ snapshot: PlayerState?, handle: OpaquePointer,
force: Bool, history: PlaybackProgress) {
guard let url = mediaURL, pendingRevision == nil, restoration == nil, !isLive else { return }
let state = snapshot ?? player_get_state(handle)
guard state.status == PLAYER_PAUSED || state.status == PLAYER_PLAYING || state.status == PLAYER_ENDED,
state.has_video, state.can_seek, !state.seek_pending,
state.duration.isFinite, state.duration > 0,
state.position.isFinite, state.position >= 0 else { return }
let position = state.status == PLAYER_ENDED ? 0 : min(state.position, state.duration)
guard force || lastSavedPosition == nil || abs(position - lastSavedPosition!) >= 5 else { return }
history.save(position > 0 ? position : nil, for: url)
lastSavedPosition = position
}
mutating func restoreLocalPosition(_ state: PlayerState, handle: OpaquePointer,
history: PlaybackProgress) -> (position: Double?, error: String?)? {
guard let saved = restoration else { return nil }
restoration = nil
guard state.status == PLAYER_PAUSED || state.status == PLAYER_PLAYING else { return nil }
var position: Double?
if state.has_video && state.can_seek && state.duration.isFinite && state.duration > 0 {
if saved.position < state.duration {
guard seek(saved.position, handle: handle) else {
mediaURL = nil
return (nil, "Could not resume saved progress. Please try opening the video again.")
}
position = saved.position
} else if let url = mediaURL {
history.save(nil, for: url)
}
}
let accepted = !saved.playing || player_play(handle)
return (position, accepted ? nil : "Could not queue playback. Please try again.")
}
var needsLocalRestoration: Bool { restoration != nil }
}
}
@MainActor