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:
2026-10-10 23:27:10 +02:00
parent 5bc0d81bbf
commit 0411064f0c
5 changed files with 149 additions and 6 deletions
+49
View File
@@ -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")
}
}
+9 -3
View File
@@ -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) 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 FRAMEWORKS := -framework AppKit -framework AudioToolbox -framework CoreAudio -framework Metal -framework MetalKit -framework QuartzCore
OBJECTS := .build/player.o .build/Output.o 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 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 all: $(APP)/Contents/MacOS/terebi $(APP)/Contents/Info.plist
.build: .build:
@@ -36,7 +36,7 @@ run: all
.build/playback-check: Checks/playback.mm $(OBJECTS) Makefile .build/playback-check: Checks/playback.mm $(OBJECTS) Makefile
$(CXX) $(CXXFLAGS) -fobjc-arc $< $(OBJECTS) $(FF_LIBS) $(FRAMEWORKS) -o $@ $(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 .build/playback-check .build/fixtures
check-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 -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 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 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
+9
View File
@@ -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. 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 ## 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. 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/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/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/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/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. - `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. - `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-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. 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. 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.
+29
View File
@@ -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
}
}
+53 -3
View File
@@ -44,8 +44,13 @@ final class PlayerModel: ObservableObject {
private var seekPending = false private var seekPending = false
private var youtubeTask: Task<Void, Never>? private var youtubeTask: Task<Void, Never>?
private var youtubeRequest: UUID? 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() let player = player_create()
handle = player handle = player
if let player, let view = player_macos_make_video_view(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 duration: Double { state.duration.isFinite ? max(0, state.duration) : 0 }
var errorText: String? { var errorText: String? {
if let commandError { return commandError } 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 var error = state.error
return withUnsafeBytes(of: &error) { bytes in return withUnsafeBytes(of: &error) { bytes in
let message = String(decoding: bytes.prefix { $0 != 0 }, as: UTF8.self) let message = String(decoding: bytes.prefix { $0 != 0 }, as: UTF8.self)
@@ -163,6 +168,7 @@ final class PlayerModel: ObservableObject {
private func openResolvedYouTube(_ video: YouTubeVideo) { private func openResolvedYouTube(_ video: YouTubeVideo) {
guard let handle else { return } guard let handle else { return }
saveProgress()
let revision = player_get_state(handle).revision let revision = player_get_state(handle).revision
let accepted = video.video.url.withCString { videoURL in let accepted = video.video.url.withCString { videoURL in
video.video.headers.withCString { videoHeaders in video.video.headers.withCString { videoHeaders in
@@ -194,8 +200,12 @@ final class PlayerModel: ObservableObject {
fileName = title fileName = title
fileIcon = icon fileIcon = icon
isLiveStream = live isLiveStream = live
progressURL = url
resumePosition = live ? nil : playbackProgress.position(for: url)
lastSavedPosition = nil
needsVideoFit = true 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 isSeeking = false
seekPending = false seekPending = false
seekPosition = 0 seekPosition = 0
@@ -211,6 +221,7 @@ final class PlayerModel: ObservableObject {
return return
} }
cancelYouTubeResolution() cancelYouTubeResolution()
saveProgress()
let revision = player_get_state(handle).revision let revision = player_get_state(handle).revision
let accepted = url.withUnsafeFileSystemRepresentation { path in let accepted = url.withUnsafeFileSystemRepresentation { path in
guard let path else { return false } guard let path else { return false }
@@ -243,6 +254,7 @@ final class PlayerModel: ObservableObject {
func closeFile() { func closeFile() {
guard let handle, canClose else { return } guard let handle, canClose else { return }
saveProgress()
cancelYouTubeResolution() cancelYouTubeResolution()
guard player_close(handle) else { guard player_close(handle) else {
commandError = "Could not queue closing the file. Please try again." commandError = "Could not queue closing the file. Please try again."
@@ -252,6 +264,9 @@ final class PlayerModel: ObservableObject {
fileName = nil fileName = nil
fileIcon = nil fileIcon = nil
isLiveStream = false isLiveStream = false
progressURL = nil
resumePosition = nil
lastSavedPosition = nil
needsVideoFit = false needsVideoFit = false
window?.aspectRatio = .zero window?.aspectRatio = .zero
commandError = nil commandError = nil
@@ -419,6 +434,20 @@ final class PlayerModel: ObservableObject {
window.setFrame(frame, display: true) 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() { private func poll() {
guard let handle else { return } guard let handle else { return }
applyState(player_get_state(handle)) applyState(player_get_state(handle))
@@ -442,9 +471,30 @@ final class PlayerModel: ObservableObject {
fitVideoWindow() fitVideoWindow()
if statusChanged { revealControls() } if statusChanged { revealControls() }
if let hideDeadline, ContinuousClock.now >= hideDeadline { hideControls() } 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() { func shutdown() {
saveProgress()
cancelYouTubeResolution() cancelYouTubeResolution()
compactWindow.restore(resumeFullScreen: false) compactWindow.restore(resumeFullScreen: false)
timer?.invalidate() timer?.invalidate()