feat: retain streaming media and add opt-in automatic quality
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
import Foundation
|
||||
|
||||
// The caller supplies monotonic seconds, keeping the policy independent of timers and extraction.
|
||||
struct AutomaticQuality {
|
||||
struct Sample {
|
||||
var enabled = true
|
||||
var network = true
|
||||
var playing = true
|
||||
var opening = false
|
||||
var seeking = false
|
||||
var live = false
|
||||
var buffering = false
|
||||
var position: Double
|
||||
var duration: Double
|
||||
var ahead: Double?
|
||||
var height: Int?
|
||||
var heights: [Int]
|
||||
}
|
||||
|
||||
private var lowSince: Double?
|
||||
private var healthySince: Double?
|
||||
private var stallSince: Double?
|
||||
private var lastPosition: Double?
|
||||
private var lastProgress: Double?
|
||||
private var notBefore = -Double.infinity
|
||||
|
||||
mutating func resetHealth() {
|
||||
lowSince = nil
|
||||
healthySince = nil
|
||||
stallSince = nil
|
||||
lastPosition = nil
|
||||
lastProgress = nil
|
||||
}
|
||||
|
||||
mutating func extractionFailed(at now: Double) {
|
||||
resetHealth()
|
||||
notBefore = max(notBefore, now + 120)
|
||||
}
|
||||
|
||||
mutating func choose(_ sample: Sample, at now: Double) -> Int? {
|
||||
guard sample.enabled, sample.network, sample.playing, !sample.opening, !sample.seeking,
|
||||
sample.position.isFinite, let height = sample.height, height > 0 else {
|
||||
resetHealth()
|
||||
return nil
|
||||
}
|
||||
guard !sample.heights.isEmpty,
|
||||
sample.live || !sample.duration.isFinite || sample.duration <= 0 ||
|
||||
sample.duration - sample.position > 15 else {
|
||||
resetHealth()
|
||||
return nil
|
||||
}
|
||||
if let lastPosition, sample.position < lastPosition - 0.1 {
|
||||
resetHealth()
|
||||
}
|
||||
if lastPosition == nil || sample.position > lastPosition! + 0.01 {
|
||||
lastPosition = sample.position
|
||||
lastProgress = now
|
||||
}
|
||||
let stalled = sample.buffering || now - (lastProgress ?? now) >= 3
|
||||
let ahead = sample.ahead.flatMap { $0.isFinite && $0 >= 0 ? $0 : nil }
|
||||
// Live packet timestamps are not a reliable reserve estimate.
|
||||
let knownReserve = !sample.live && ahead != nil
|
||||
if stalled {
|
||||
stallSince = stallSince ?? now
|
||||
} else {
|
||||
stallSince = nil
|
||||
}
|
||||
if knownReserve && ahead! < 4 {
|
||||
lowSince = lowSince ?? now
|
||||
} else {
|
||||
lowSince = nil
|
||||
}
|
||||
if !stalled && (!knownReserve || ahead! >= 20) {
|
||||
healthySince = healthySince ?? now
|
||||
} else {
|
||||
healthySince = nil
|
||||
}
|
||||
guard now >= notBefore else { return nil }
|
||||
let downshift = stallSince.map({ now - $0 >= 3 }) == true || lowSince.map({ now - $0 >= 6 }) == true
|
||||
let upshift = healthySince.map({ now - $0 >= (knownReserve ? 60 : 120) }) == true
|
||||
guard height > 1080 || downshift || upshift else { return nil }
|
||||
var target: Int?
|
||||
for available in sample.heights where available > 0 && available <= 1080 {
|
||||
if height > 1080 || downshift {
|
||||
if available < height && available > (target ?? 0) { target = available }
|
||||
} else if available > height && available < (target ?? Int.max) {
|
||||
target = available
|
||||
}
|
||||
}
|
||||
guard let target else { return nil }
|
||||
notBefore = now + 30
|
||||
resetHealth()
|
||||
return target
|
||||
}
|
||||
}
|
||||
+127
-31
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user