From 0411064f0c904f320a7515e4af3cbb95e69071ab Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Sat, 10 Oct 2026 23:27:10 +0200 Subject: [PATCH] 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. --- Checks/playback-progress.swift | 49 ++++++++++++++++++++++++++ Makefile | 12 +++++-- README.md | 9 +++++ Sources/App/PlaybackProgress.swift | 29 ++++++++++++++++ Sources/App/TerebiApp.swift | 56 ++++++++++++++++++++++++++++-- 5 files changed, 149 insertions(+), 6 deletions(-) create mode 100644 Checks/playback-progress.swift create mode 100644 Sources/App/PlaybackProgress.swift diff --git a/Checks/playback-progress.swift b/Checks/playback-progress.swift new file mode 100644 index 0000000..027639c --- /dev/null +++ b/Checks/playback-progress.swift @@ -0,0 +1,49 @@ +import Foundation + +@main +struct PlaybackProgressChecks { + static func require(_ condition: @autoclosure () -> Bool, _ message: String) { + if !condition() { fatalError("FAIL: " + message) } + } + + static func main() throws { + let suite = "terebi.progress-check.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defer { defaults.removePersistentDomain(forName: suite) } + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(suite) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let file = directory.appendingPathComponent("video.mp4") + let otherFile = directory.appendingPathComponent("other.mp4") + let alias = directory.appendingPathComponent("alias.mp4") + try Data().write(to: file) + try FileManager.default.createSymbolicLink(at: alias, withDestinationURL: file) + let youtube = URL(string: "https://www.youtube.com/watch?v=jNQXAC9IVRw")! + + var progress = PlaybackProgress(defaults: defaults) + progress.save(123.25, for: file) + progress.save(18, for: otherFile) + progress.save(9.5, for: youtube) + let reopened = PlaybackProgress(defaults: UserDefaults(suiteName: suite)!) + require(reopened.position(for: file) == 123.25, "fractional local progress survives a fresh store") + require(reopened.position(for: alias) == 123.25, "symlink and original file share progress") + require(reopened.position(for: otherFile) == 18, "local files keep separate positions") + require(reopened.position(for: youtube) == 9.5, "canonical YouTube URL persists independently of local files") + + progress.save(nil, for: alias) + let completed = PlaybackProgress(defaults: defaults) + require(completed.position(for: file) == nil, "completion clears progress through either file identity") + require(completed.position(for: otherFile) == 18 && completed.position(for: youtube) == 9.5, + "clearing one video preserves other videos") + for position in [Double.nan, Double.infinity, -Double.infinity, -1, 0] { + progress.save(position, for: file) + require(PlaybackProgress(defaults: defaults).position(for: file) == nil, + "invalid and zero positions never become resumable bookmarks") + } + let isolated = UserDefaults(suiteName: suite + ".isolated")! + defer { isolated.removePersistentDomain(forName: suite + ".isolated") } + require(PlaybackProgress(defaults: isolated).position(for: youtube) == nil, + "injected preference domains remain isolated") + print("Playback progress checks passed") + } +} diff --git a/Makefile b/Makefile index 5897177..f5e3ee8 100644 --- a/Makefile +++ b/Makefile @@ -7,10 +7,10 @@ ARCH := $(shell uname -m) CXXFLAGS := -std=c++20 -mmacosx-version-min=15.0 -O2 -g -Wall -Wextra -Wno-unused-parameter $(FF_CFLAGS) FRAMEWORKS := -framework AppKit -framework AudioToolbox -framework CoreAudio -framework Metal -framework MetalKit -framework QuartzCore OBJECTS := .build/player.o .build/Output.o -SWIFT_SOURCES := Sources/App/TerebiApp.swift Sources/App/CompactWindow.swift Sources/App/YouTubeResolver.swift +SWIFT_SOURCES := Sources/App/TerebiApp.swift Sources/App/CompactWindow.swift Sources/App/YouTubeResolver.swift Sources/App/PlaybackProgress.swift APP := .build/terebi.app -.PHONY: all run check check-fixtures check-audio check-window check-youtube check-network +.PHONY: all run check check-fixtures check-audio check-window check-youtube check-network check-progress all: $(APP)/Contents/MacOS/terebi $(APP)/Contents/Info.plist .build: @@ -36,7 +36,7 @@ run: all .build/playback-check: Checks/playback.mm $(OBJECTS) Makefile $(CXX) $(CXXFLAGS) -fobjc-arc $< $(OBJECTS) $(FF_LIBS) $(FRAMEWORKS) -o $@ -check: .build/playback-check check-fixtures check-audio check-window check-youtube check-network +check: .build/playback-check check-fixtures check-audio check-window check-youtube check-network check-progress .build/playback-check .build/fixtures check-fixtures: @@ -90,3 +90,9 @@ check-network: .build/network-playback-check check-fixtures ffmpeg -hide_banner -loglevel error -y -f lavfi -i testsrc2=size=320x180:rate=24 -f lavfi -i sine=frequency=440:sample_rate=44100 -t 30 -c:v mpeg4 -q:v 2 -bf 2 -c:a aac -movflags +faststart .build/fixtures/streaming.mp4 ffmpeg -hide_banner -loglevel error -y -i .build/fixtures/av.mp4 -c:v libx264 -g 24 -keyint_min 24 -sc_threshold 0 -c:a aac -hls_time 1 -hls_playlist_type vod -hls_segment_filename .build/fixtures/network-vod-%d.ts .build/fixtures/network-vod.m3u8 python3 Checks/streaming.py .build/network-playback-check .build/fixtures + +.build/playback-progress-check: Checks/playback-progress.swift Sources/App/PlaybackProgress.swift Makefile | .build + $(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -O -g Checks/playback-progress.swift Sources/App/PlaybackProgress.swift -framework Foundation -o $@ + +check-progress: .build/playback-progress-check + .build/playback-progress-check diff --git a/README.md b/README.md index f4ad97a..39b343a 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,12 @@ yt-dlp selects the best available SDR video and audio, including separate stream The app ignores yt-dlp configuration and plugins, does not read browser cookies, and does not update or fetch extractor components automatically. Private, age-restricted, region-blocked, or sign-in-required videos can fail. If YouTube extraction changes, run `brew upgrade yt-dlp deno`; local playback needs neither dependency. +## Resume playback + +Reopening a local video or YouTube video automatically resumes its saved position, including after quitting and relaunching the app. Local files use their resolved file paths; YouTube uses the canonical video webpage URL, so short links and newly extracted media URLs share progress. The app does not automatically reopen the last video on launch. + +Progress is stored locally in UserDefaults, checkpointed every five seconds of playback progress, and saved on pause, close, replacement, and normal quit. Only authoritative, acknowledged positions are saved—not an unfinished seek or slider drag. Finished videos clear their bookmark and reopen from the beginning; a bookmark beyond the current video's duration also restarts at the beginning. Failed opens preserve existing bookmarks. Live/non-seekable inputs and audio-only media do not record progress. + ## Playback UI The layout follows [IINA's video-first window](https://iina.io/img/screenshot.png) and the supplied screenshots: a 32-point frosted titlebar with native traffic lights and file icon, plus a dark frosted 440 × 68-point floating controller. AppKit materials blur the video beneath the chrome; muted icons, an orange volume track, a narrow seek thumb, and zero-padded time labels follow the reference. Shared vertical padding and an optical offset for symbol/slider insets balance the controller's visible top and bottom margins in both play and pause states. The controller starts centered with a four-percent bottom inset and keeps its relative position when the window changes size. @@ -71,6 +77,7 @@ Only implemented actions have controls; there are no placeholder playlist, subti - `Sources/Core/network_input.hpp`: network-only demux readers with bounded encoded packet queues, asynchronous coordinated seeks, fifteen-second I/O deadlines, and interruption on seek/replacement/close/destruction. Remote input protocols exclude local-file access through manifests. - `Sources/Core/output.hpp` and `Sources/macOS/Output.mm`: native output contract, allocation-free/nonblocking Core Audio callback and bounded audio ring, latency-aware presentation clock, retained frame mailbox, and Metal YUV conversion. The view can outlive the player safely. - `Sources/App/TerebiApp.swift`: MainActor state polling, file/YouTube panels and Finder/drop handling, cancellable URL resolution, autoplay, video host, AppKit glass and native slider cells, floating controls, visibility, video-fitted sizing, and native window integration. Both accepted open paths enqueue play on the new source's command timeline; the C interface still opens paused for callers that need explicit control. +- `Sources/App/PlaybackProgress.swift`: local per-video position persistence and canonical file identities, using injectable UserDefaults. The player model checkpoints authoritative positions and defers resumed autoplay until the saved seek is queued against the ready source. - `Sources/App/CompactWindow.swift`: same-window compact geometry, saved normal window state, asynchronous fullscreen transitions and failure recovery, all-Spaces/floating behavior, magnetic edge snapping, and its persisted preference. - `Sources/App/YouTubeResolver.swift`: YouTube URL and metadata validation, external yt-dlp/runtime discovery, SDR stream selection and headers, bounded nonblocking process output, sanitized extraction errors, and request-local cancellation. - `Makefile`: compiler/linker commands and app bundle construction. No generated Xcode project or additional build dependency. @@ -91,6 +98,8 @@ Generates short fixtures and exercises the real playback interface and native ou `make check-network` uses a range-capable loopback HTTP server and actual native playback. It checks combined/separate streams, identical stream indices across inputs, selected headers, coordinated seeks, exact VFR/B-frame stepping, asymmetric EOF and audio-tail seeks, recorded native HLS seeks, live/non-range seek rejection, buffering before the entire file arrives, permanently stalled primary/secondary reads, cancellation/replacement/destruction, HTTP failures, and recovery. Both new checks are included in `make check`. +`make check-progress` verifies persisted fractional positions, independent local/YouTube identities, symlink aliases, completion clearing, invalid-position rejection, and isolated preference domains. It is included in `make check` and does not touch real playback history. + Continuous audio uses resampled sample counts for its output timeline; packet timestamp quantization and rounded resampler delay must not insert silence or drop samples between blocks. Volume persists across open, seek, and close. Live gain changes ramp over five milliseconds without callback allocation or locking; a stopped output adopts the requested gain before playback starts, so muted startup cannot leak audio. diff --git a/Sources/App/PlaybackProgress.swift b/Sources/App/PlaybackProgress.swift new file mode 100644 index 0000000..548652c --- /dev/null +++ b/Sources/App/PlaybackProgress.swift @@ -0,0 +1,29 @@ +import Foundation + +struct PlaybackProgress { + private let defaults: UserDefaults + private var positions: [String: Double] + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + positions = defaults.dictionary(forKey: "playbackPositions") as? [String: Double] ?? [:] + } + + func position(for url: URL) -> Double? { + guard let position = positions[key(for: url)], position.isFinite, position > 0 else { return nil } + return position + } + + mutating func save(_ position: Double?, for url: URL) { + let key = key(for: url) + let position = position.flatMap { $0.isFinite && $0 > 0 ? $0 : nil } + guard positions[key] != position else { return } + positions[key] = position + defaults.set(positions, forKey: "playbackPositions") + } + + private func key(for url: URL) -> String { + // YouTube callers supply the canonical webpage URL, never an expiring media URL. + return url.isFileURL ? url.standardizedFileURL.resolvingSymlinksInPath().absoluteString : url.absoluteString + } +} diff --git a/Sources/App/TerebiApp.swift b/Sources/App/TerebiApp.swift index 931ee7d..8a06cc6 100644 --- a/Sources/App/TerebiApp.swift +++ b/Sources/App/TerebiApp.swift @@ -44,8 +44,13 @@ final class PlayerModel: ObservableObject { private var seekPending = false private var youtubeTask: Task? 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()