feat: retain streaming media and add opt-in automatic quality

This commit is contained in:
2026-10-11 11:17:41 +02:00
parent 041d2b0644
commit 13554a1574
12 changed files with 921 additions and 164 deletions
+127 -31
View File
@@ -16,6 +16,7 @@ final class PlayerModel: ObservableObject {
@Published private(set) var youtubeHeights: [Int] = []
@Published private(set) var youtubeHeight: Int?
@Published private(set) var youtubeQuality: Int?
@Published private(set) var automaticYouTubeQuality = false
@Published var seekPosition = 0.0
@Published var isSeeking = false
@Published private(set) var volume = 1.0
@@ -48,6 +49,11 @@ final class PlayerModel: ObservableObject {
private var seekPending = false
private var youtubeTask: Task<Void, Never>?
private var youtubeRequest: UUID?
private var resolvingAutomaticQuality = false
private let defaults: UserDefaults
private let qualityClock: () -> Double
private var automaticQuality = AutomaticQuality()
private var qualityNow: Double { qualityClock() }
private var playbackProgress: PlaybackProgress
private var progressURL: URL?
private var resumePosition: Double?
@@ -55,7 +61,10 @@ final class PlayerModel: ObservableObject {
private var lastSavedPosition: Double?
private var systemPlayback: SystemPlaybackControls?
init(defaults: UserDefaults = .standard) {
init(defaults: UserDefaults = .standard, qualityClock: @escaping () -> Double = { ProcessInfo.processInfo.systemUptime }) {
self.defaults = defaults
self.qualityClock = qualityClock
automaticYouTubeQuality = defaults.bool(forKey: "automaticYouTubeQuality")
playbackProgress = PlaybackProgress(defaults: defaults)
let player = player_create()
handle = player
@@ -74,7 +83,7 @@ final class PlayerModel: ObservableObject {
systemPlayback = SystemPlaybackControls(model: self)
}
var isBusy: Bool { resolvingYouTube || pendingRevision != nil || state.status == PLAYER_OPENING }
var isBusy: Bool { (resolvingYouTube && !resolvingAutomaticQuality) || pendingRevision != nil || state.status == PLAYER_OPENING }
var canTogglePlayback: Bool {
handle != nil && !isBusy &&
(state.status == PLAYER_PAUSED || state.status == PLAYER_PLAYING || state.status == PLAYER_ENDED)
@@ -149,9 +158,20 @@ final class PlayerModel: ObservableObject {
resolveYouTube(url, height: nil)
}
func setAutomaticYouTubeQuality(_ enabled: Bool) {
automaticYouTubeQuality = enabled
defaults.set(enabled, forKey: "automaticYouTubeQuality")
automaticQuality = AutomaticQuality()
if !enabled && 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,
height != youtubeQuality, height == nil || youtubeHeights.contains(height!) else { return }
height == nil || youtubeHeights.contains(height!) else { return }
guard height != youtubeQuality else { return }
let target = height ?? youtubeHeights.filter { $0 <= 1080 }.max()
if target == youtubeHeight {
youtubeQuality = height
@@ -162,10 +182,12 @@ final class PlayerModel: ObservableObject {
resolveYouTube(url, height: height, restoring: (position, current.status == PLAYER_PLAYING))
}
private func resolveYouTube(_ url: URL, height: Int?, restoring: (position: Double?, playing: Bool)? = nil) {
private func resolveYouTube(_ url: URL, height: Int?, restoring: (position: Double?, playing: Bool)? = nil,
automatic: Bool = false) {
guard let handle else { return }
cancelYouTubeResolution()
if !player_pause(handle) {
if !automatic { automaticQuality = AutomaticQuality() }
if !automatic && !player_pause(handle) {
commandError = "Could not pause the current media to open YouTube. Please try again."
revealControls()
return
@@ -173,6 +195,7 @@ final class PlayerModel: ObservableObject {
let request = UUID()
youtubeRequest = request
resolvingYouTube = true
resolvingAutomaticQuality = automatic
systemPlayback?.update()
commandError = nil
revealControls()
@@ -180,20 +203,36 @@ final class PlayerModel: ObservableObject {
do {
let video = try await YouTubeResolver.resolve(url, height: height)
guard !Task.isCancelled, let self, self.youtubeRequest == request, self.handle != nil else { return }
self.youtubeTask = nil
self.youtubeRequest = nil
self.resolvingYouTube = false
self.openResolvedYouTube(video, height: height, restoring: restoring)
if 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()
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)
}
} catch {
guard !Task.isCancelled, let self, self.youtubeRequest == request else { return }
self.youtubeTask = nil
self.youtubeRequest = nil
self.resolvingYouTube = false
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.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()
}
self.revealControls()
}
}
}
@@ -203,9 +242,11 @@ final class PlayerModel: ObservableObject {
youtubeTask?.cancel()
youtubeTask = nil
resolvingYouTube = false
resolvingAutomaticQuality = false
}
private func openResolvedYouTube(_ video: YouTubeVideo, height: Int?, restoring: (position: Double?, playing: Bool)?) {
private func openResolvedYouTube(_ video: YouTubeVideo, height: Int?, restoring: (position: Double?, playing: Bool)?,
automatic: Bool = false) {
guard let handle else { return }
saveProgress()
let revision = player_get_state(handle).revision
@@ -226,6 +267,10 @@ final class PlayerModel: ObservableObject {
}
}
guard accepted else {
if automatic {
automaticQuality.extractionFailed(at: qualityNow)
return
}
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."
@@ -234,15 +279,16 @@ final class PlayerModel: ObservableObject {
return
}
didAcceptOpen(revision: revision, title: video.title, icon: nil, url: video.webpageURL,
live: video.isLive, restoring: restoring)
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) {
restoring: (position: Double?, playing: Bool)? = nil, automatic: Bool = false) {
guard let handle else { return }
if automatic { automaticQuality.resetHealth() } else { automaticQuality = AutomaticQuality() }
pendingRevision = revision
fileName = title
fileIcon = icon
@@ -299,6 +345,8 @@ final class PlayerModel: ObservableObject {
@discardableResult
func setPlayback(playing: Bool) -> Bool {
guard let handle, canTogglePlayback else { return false }
if !playing && resolvingAutomaticQuality { cancelYouTubeResolution() }
if !playing { automaticQuality.resetHealth() }
let accepted = playing ? player_play(handle) : player_pause(handle)
commandError = accepted ? nil : "Could not queue playback. Please try again."
revealControls()
@@ -306,6 +354,8 @@ final class PlayerModel: ObservableObject {
}
func finishSeeking() {
if resolvingAutomaticQuality { cancelYouTubeResolution() }
automaticQuality.resetHealth()
isSeeking = false
guard let handle, canSeek else { return }
let position = min(max(0, seekPosition), duration)
@@ -319,6 +369,7 @@ final class PlayerModel: ObservableObject {
guard let handle, canClose else { return }
saveProgress()
cancelYouTubeResolution()
automaticQuality = AutomaticQuality()
guard player_close(handle) else {
commandError = "Could not queue closing the file. Please try again."
return
@@ -348,6 +399,8 @@ final class PlayerModel: ObservableObject {
@discardableResult
func seekRelative(_ seconds: Double) -> Bool {
guard canSeek, let handle, seconds.isFinite else { return false }
if resolvingAutomaticQuality { cancelYouTubeResolution() }
automaticQuality.resetHealth()
let target = min(max(0, (seekPending ? seekPosition : state.position) + seconds), duration)
let accepted = player_seek(handle, target)
if accepted {
@@ -452,6 +505,8 @@ final class PlayerModel: ObservableObject {
}
func beginSeeking() {
if resolvingAutomaticQuality { cancelYouTubeResolution() }
automaticQuality.resetHealth()
isSeeking = true
revealControls()
}
@@ -543,6 +598,7 @@ final class PlayerModel: ObservableObject {
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 }
@@ -564,6 +620,19 @@ final class PlayerModel: ObservableObject {
}
}
private func updateAutomaticQuality() {
guard automaticYouTubeQuality else { return }
let reserve = bufferedRange.map { max(0, $0.upperBound - state.position) }
let sample = AutomaticQuality.Sample(
enabled: automaticYouTubeQuality, network: isNetworkMedia,
playing: state.status == PLAYER_PLAYING, opening: isBusy || resolvingYouTube || resumePosition != nil,
seeking: isSeeking || 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)
}
func shutdown() {
saveProgress()
cancelYouTubeResolution()
@@ -756,15 +825,28 @@ private struct PlayerContent: View {
private var controlHeight: CGFloat { model.isNetworkMedia ? 88 : 68 }
private var compactControlHeight: CGFloat { model.isNetworkMedia ? 52 : 40 }
private var bufferedStatus: String? {
model.bufferedRange.map {
let behind = max(0, model.state.position - $0.lowerBound)
let ahead = max(0, $0.upperBound - model.state.position)
return "Reusable buffer: \(String(format: "%.1f", behind)) seconds behind, " +
"\(String(format: "%.1f", ahead)) seconds ahead; buffered through \(time($0.upperBound))."
}
}
private var bufferReadout: some View {
Text(model.bufferedRange.map {
"Buffered: \(String(format: "%.1f", $0.upperBound - $0.lowerBound))s ahead"
} ?? "Buffered: —")
let behind = max(0, model.state.position - $0.lowerBound)
let ahead = max(0, $0.upperBound - model.state.position)
return (model.isCompact ? "" : "Buffer: ") + String(format: "−%.0fs +%.0fs", behind, ahead)
} ?? (model.isCompact ? "Buffer —" : "Buffer: —"))
.font(.system(size: 10).monospacedDigit())
.foregroundStyle(.white.opacity(0.75))
.lineLimit(1)
.frame(height: 12)
.help("Conservative playable buffer ahead; — means the estimate is unavailable.")
.help(bufferedStatus ?? "Reusable buffer unavailable.")
.accessibilityLabel("Reusable Buffer")
.accessibilityValue(bufferedStatus ?? "Unavailable")
}
private func time(_ seconds: Double) -> String {
@@ -820,15 +902,32 @@ private struct PlayerContent: View {
Button("Close File", action: model.closeFile).disabled(!model.canClose)
if !model.youtubeHeights.isEmpty {
Divider()
Picker("Quality", selection: Binding(
get: { model.youtubeQuality ?? 0 },
set: { model.setYouTubeQuality($0 == 0 ? nil : $0) })) {
Text("Default (up to 1080p)").tag(0)
Menu("Quality") {
Toggle("Automatic Quality (up to 1080p)", isOn: Binding(
get: { model.automaticYouTubeQuality },
set: { model.setAutomaticYouTubeQuality($0) }))
Divider()
Button { model.setYouTubeQuality(nil) } label: {
HStack {
Text("Default (up to 1080p)")
if !model.automaticYouTubeQuality && model.youtubeQuality == nil {
Image(systemName: "checkmark")
}
}
}
.disabled(!model.canChangeYouTubeQuality)
ForEach(model.youtubeHeights, id: \.self) { height in
Text("\(height)p").tag(height)
Button { model.setYouTubeQuality(height) } label: {
HStack {
Text("\(height)p")
if !model.automaticYouTubeQuality && model.youtubeQuality == height {
Image(systemName: "checkmark")
}
}
}
.disabled(!model.canChangeYouTubeQuality)
}
}
.disabled(!model.canChangeYouTubeQuality)
Text(model.youtubeHeight.map { "Current: \($0)p" } ?? "Current resolution unknown")
}
Divider()
@@ -874,9 +973,6 @@ private struct PlayerContent: View {
private var playbackControls: some View {
let buffered = model.bufferedRange
let bufferedStatus = buffered.map {
"Buffered through \(time($0.upperBound)) — \(String(format: "%.1f", $0.upperBound - $0.lowerBound)) seconds ahead"
}
return VStack(spacing: 8) {
HStack(spacing: 0) {
HStack(spacing: 4) {