feat: report authoritative playback progress from shared backend

This commit is contained in:
2026-10-11 19:29:35 +02:00
parent a4c38bfa51
commit cc99a2a4f8
14 changed files with 1369 additions and 62 deletions
+92 -14
View File
@@ -5,7 +5,8 @@ import TerebiConfiguration
@MainActor
final class PlayerModel: ObservableObject {
static var startupConfiguration = PlayerConfiguration()
static let shared = PlayerModel(configuration: startupConfiguration)
static var startupProgressFile: String?
static let shared = PlayerModel(configuration: startupConfiguration, progressFile: startupProgressFile)
@Published private(set) var state = PlayerState()
@Published private(set) var fileName: String?
@@ -80,8 +81,16 @@ final class PlayerModel: ObservableObject {
private var resumePlaying = true
private var lastSavedPosition: Double?
private var systemPlayback: SystemPlaybackControls?
private(set) var progressInitializationError: String?
private var reportingEnabled = false
private var reportingErrorShown = false
private var reportingErrorBuffer = [CChar]()
private var reportingErrorMessage: String {
reportingErrorBuffer.withUnsafeBufferPointer { String(cString: $0.baseAddress!) }
}
init(defaults: UserDefaults = .standard, configuration: PlayerConfiguration = PlayerConfiguration(),
progressFile: String? = nil,
qualityClock: @escaping () -> Double = { ProcessInfo.processInfo.systemUptime }) {
self.qualityClock = qualityClock
keyBindings = configuration.keys
@@ -99,6 +108,15 @@ final class PlayerModel: ObservableObject {
videoView = nil
commandError = "Could not create the media player."
}
if let progressFile {
reportingErrorBuffer = [CChar](repeating: 0, count: 512)
if let player {
reportingEnabled = progressFile.withCString { player_set_progress_file(player, $0, &reportingErrorBuffer) }
if !reportingEnabled { progressInitializationError = reportingErrorMessage }
} else {
progressInitializationError = "Could not create the media player to initialize progress reporting."
}
}
compactWindow.onChange = { [weak self] in
guard let self else { return }
if !self.compactWindow.isTransitioning {
@@ -288,20 +306,25 @@ final class PlayerModel: ObservableObject {
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
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)
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 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) }
}
}
return enqueue(audioURL: nil, audioHeaders: nil)
}
return enqueue(audioURL: nil, audioHeaders: nil)
}
}
guard accepted else {
@@ -338,7 +361,11 @@ final class PlayerModel: ObservableObject {
youtubeHeight = nil
youtubeQuality = nil
progressURL = url
if let restoring {
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)
@@ -729,6 +756,11 @@ final class PlayerModel: ObservableObject {
private func poll() {
guard let handle else { return }
if reportingEnabled && !reportingErrorShown {
if player_get_progress_error(handle, &reportingErrorBuffer) {
showReportingError(reportingErrorMessage)
}
}
applyState(player_get_state(handle))
}
@@ -798,6 +830,21 @@ final class PlayerModel: ObservableObject {
resolveYouTube(url, height: height, automatic: true)
}
private func showReportingError(_ message: String, terminating: Bool = false) {
guard !reportingErrorShown else { return }
reportingErrorShown = true
FileHandle.standardError.write(Data("terebi: \(message)\n".utf8))
let alert = NSAlert()
alert.alertStyle = .warning
alert.messageText = "Playback progress reporting failed"
alert.informativeText = message + "\n\nReporting has been disabled. Playback is unaffected."
if let window, !terminating {
alert.beginSheetModal(for: window)
} else {
alert.runModal()
}
}
func shutdown() {
saveProgress()
cancelYouTubeResolution()
@@ -809,6 +856,11 @@ final class PlayerModel: ObservableObject {
hideDeadline = nil
NSCursor.setHiddenUntilMouseMoves(false)
guard let player = handle else { return }
if reportingEnabled {
if !player_shutdown(player, &reportingErrorBuffer) {
showReportingError(reportingErrorMessage, terminating: true)
}
}
handle = nil
player_destroy(player)
}
@@ -1526,5 +1578,31 @@ public func terebiAppMain() {
alert.runModal()
exit(78)
}
let arguments = Array(CommandLine.arguments.dropFirst())
if let index = arguments.firstIndex(of: "--progress-file") {
guard index + 1 < arguments.count, arguments[index + 1].hasPrefix("/"),
arguments.filter({ $0 == "--progress-file" }).count == 1 else {
let alert = NSAlert()
alert.alertStyle = .critical
alert.messageText = "Could not initialize playback progress reporting"
alert.informativeText = "--progress-file requires one absolute filesystem path."
alert.runModal()
exit(73)
}
PlayerModel.startupProgressFile = arguments[index + 1]
_ = NSApplication.shared
if let error = PlayerModel.shared.progressInitializationError {
PlayerModel.shared.shutdown()
FileHandle.standardError.write(Data("terebi: \(error)\n".utf8))
NSApp.setActivationPolicy(.regular)
NSApp.activate(ignoringOtherApps: true)
let alert = NSAlert()
alert.alertStyle = .critical
alert.messageText = "Could not initialize playback progress reporting"
alert.informativeText = error
alert.runModal()
exit(73)
}
}
TerebiApp.main()
}