refactor: concentrate playback transition ownership
This commit is contained in:
+366
-232
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user