feat: remember playback progress across player restarts
Persist authoritative per-video positions in UserDefaults. Save periodic checkpoints, pause, close, replacement, and quit; restore a validated position before autoplay. Canonical YouTube webpage URLs survive fresh stream extraction, and local symlinks share file progress. Clear completed or out-of-duration bookmarks. Leave failed opens and live/non-seekable inputs out of persistence. Pending opens hide errors belonging to the previous source. Verified separate-process restart, source replacement, failed-open recovery, completion, checkpoints, real YouTube short-link resume, live exclusion, and make all check.
This commit is contained in:
@@ -44,8 +44,13 @@ final class PlayerModel: ObservableObject {
|
||||
private var seekPending = false
|
||||
private var youtubeTask: Task<Void, Never>?
|
||||
private var youtubeRequest: UUID?
|
||||
private var playbackProgress: PlaybackProgress
|
||||
private var progressURL: URL?
|
||||
private var resumePosition: Double?
|
||||
private var lastSavedPosition: Double?
|
||||
|
||||
private init() {
|
||||
init(defaults: UserDefaults = .standard) {
|
||||
playbackProgress = PlaybackProgress(defaults: defaults)
|
||||
let player = player_create()
|
||||
handle = player
|
||||
if let player, let view = player_macos_make_video_view(player) {
|
||||
@@ -75,7 +80,7 @@ final class PlayerModel: ObservableObject {
|
||||
var duration: Double { state.duration.isFinite ? max(0, state.duration) : 0 }
|
||||
var errorText: String? {
|
||||
if let commandError { return commandError }
|
||||
guard !resolvingYouTube, state.status == PLAYER_ERROR else { return nil }
|
||||
guard !isBusy, state.status == PLAYER_ERROR else { return nil }
|
||||
var error = state.error
|
||||
return withUnsafeBytes(of: &error) { bytes in
|
||||
let message = String(decoding: bytes.prefix { $0 != 0 }, as: UTF8.self)
|
||||
@@ -163,6 +168,7 @@ final class PlayerModel: ObservableObject {
|
||||
|
||||
private func openResolvedYouTube(_ video: YouTubeVideo) {
|
||||
guard let handle else { return }
|
||||
saveProgress()
|
||||
let revision = player_get_state(handle).revision
|
||||
let accepted = video.video.url.withCString { videoURL in
|
||||
video.video.headers.withCString { videoHeaders in
|
||||
@@ -194,8 +200,12 @@ final class PlayerModel: ObservableObject {
|
||||
fileName = title
|
||||
fileIcon = icon
|
||||
isLiveStream = live
|
||||
progressURL = url
|
||||
resumePosition = live ? nil : playbackProgress.position(for: url)
|
||||
lastSavedPosition = nil
|
||||
needsVideoFit = true
|
||||
commandError = player_play(handle) ? nil : "Could not queue playback. Please try again."
|
||||
commandError = resumePosition != nil || player_play(handle) ? nil :
|
||||
"Could not queue playback. Please try again."
|
||||
isSeeking = false
|
||||
seekPending = false
|
||||
seekPosition = 0
|
||||
@@ -211,6 +221,7 @@ final class PlayerModel: ObservableObject {
|
||||
return
|
||||
}
|
||||
cancelYouTubeResolution()
|
||||
saveProgress()
|
||||
let revision = player_get_state(handle).revision
|
||||
let accepted = url.withUnsafeFileSystemRepresentation { path in
|
||||
guard let path else { return false }
|
||||
@@ -243,6 +254,7 @@ final class PlayerModel: ObservableObject {
|
||||
|
||||
func closeFile() {
|
||||
guard let handle, canClose else { return }
|
||||
saveProgress()
|
||||
cancelYouTubeResolution()
|
||||
guard player_close(handle) else {
|
||||
commandError = "Could not queue closing the file. Please try again."
|
||||
@@ -252,6 +264,9 @@ final class PlayerModel: ObservableObject {
|
||||
fileName = nil
|
||||
fileIcon = nil
|
||||
isLiveStream = false
|
||||
progressURL = nil
|
||||
resumePosition = nil
|
||||
lastSavedPosition = nil
|
||||
needsVideoFit = false
|
||||
window?.aspectRatio = .zero
|
||||
commandError = nil
|
||||
@@ -419,6 +434,20 @@ final class PlayerModel: ObservableObject {
|
||||
window.setFrame(frame, display: true)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
private func poll() {
|
||||
guard let handle else { return }
|
||||
applyState(player_get_state(handle))
|
||||
@@ -442,9 +471,30 @@ final class PlayerModel: ObservableObject {
|
||||
fitVideoWindow()
|
||||
if statusChanged { revealControls() }
|
||||
if let hideDeadline, ContinuousClock.now >= hideDeadline { hideControls() }
|
||||
saveProgress(next, force: statusChanged && (next.status == PLAYER_PAUSED || next.status == PLAYER_ENDED))
|
||||
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 = player_play(handle) ? nil : "Could not queue playback. Please try again."
|
||||
}
|
||||
}
|
||||
|
||||
func shutdown() {
|
||||
saveProgress()
|
||||
cancelYouTubeResolution()
|
||||
compactWindow.restore(resumeFullScreen: false)
|
||||
timer?.invalidate()
|
||||
|
||||
Reference in New Issue
Block a user