feat: choose YouTube quality with a 1080p default
This commit is contained in:
+71
-11
@@ -12,6 +12,9 @@ final class PlayerModel: ObservableObject {
|
||||
@Published private(set) var pendingRevision: UInt64?
|
||||
@Published private(set) var resolvingYouTube = false
|
||||
@Published private(set) var isLiveStream = false
|
||||
@Published private(set) var youtubeHeights: [Int] = []
|
||||
@Published private(set) var youtubeHeight: Int?
|
||||
@Published private(set) var youtubeQuality: Int?
|
||||
@Published var seekPosition = 0.0
|
||||
@Published var isSeeking = false
|
||||
@Published private(set) var volume = 1.0
|
||||
@@ -47,6 +50,7 @@ final class PlayerModel: ObservableObject {
|
||||
private var playbackProgress: PlaybackProgress
|
||||
private var progressURL: URL?
|
||||
private var resumePosition: Double?
|
||||
private var resumePlaying = true
|
||||
private var lastSavedPosition: Double?
|
||||
private var systemPlayback: SystemPlaybackControls?
|
||||
|
||||
@@ -76,6 +80,9 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
var canSeek: Bool { canTogglePlayback && state.can_seek && state.duration.isFinite && state.duration > 0 }
|
||||
var canStepFrame: Bool { canTogglePlayback && state.has_video && state.can_seek && !isSeeking }
|
||||
var canChangeYouTubeQuality: Bool {
|
||||
canTogglePlayback && !youtubeHeights.isEmpty && !isSeeking && !seekPending && !state.seek_pending
|
||||
}
|
||||
var canOpen: Bool { handle != nil }
|
||||
var canClose: Bool { handle != nil && (isBusy || state.status != PLAYER_EMPTY || fileName != nil) }
|
||||
var canSetVolume: Bool { handle != nil && !isBusy && state.has_audio && state.status != PLAYER_ERROR }
|
||||
@@ -122,8 +129,7 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
|
||||
func openYouTube(_ text: String) {
|
||||
guard let handle else { return }
|
||||
cancelYouTubeResolution()
|
||||
guard canOpen else { return }
|
||||
let url: URL
|
||||
do {
|
||||
url = try YouTubeResolver.validateURL(text)
|
||||
@@ -132,6 +138,25 @@ final class PlayerModel: ObservableObject {
|
||||
revealControls()
|
||||
return
|
||||
}
|
||||
resolveYouTube(url, height: nil)
|
||||
}
|
||||
|
||||
func setYouTubeQuality(_ height: Int?) {
|
||||
guard canChangeYouTubeQuality, let url = progressURL, let handle,
|
||||
height != youtubeQuality, height == nil || youtubeHeights.contains(height!) else { return }
|
||||
let target = height ?? youtubeHeights.filter { $0 <= 1080 }.max()
|
||||
if target == youtubeHeight {
|
||||
youtubeQuality = height
|
||||
return
|
||||
}
|
||||
let current = player_get_state(handle)
|
||||
let position = !isLiveStream && current.can_seek ? current.position : nil
|
||||
resolveYouTube(url, height: height, restoring: (position, current.status == PLAYER_PLAYING))
|
||||
}
|
||||
|
||||
private func resolveYouTube(_ url: URL, height: Int?, restoring: (position: Double?, playing: Bool)? = nil) {
|
||||
guard let handle else { return }
|
||||
cancelYouTubeResolution()
|
||||
if !player_pause(handle) {
|
||||
commandError = "Could not pause the current media to open YouTube. Please try again."
|
||||
revealControls()
|
||||
@@ -145,18 +170,21 @@ final class PlayerModel: ObservableObject {
|
||||
revealControls()
|
||||
youtubeTask = Task { [weak self] in
|
||||
do {
|
||||
let video = try await YouTubeResolver.resolve(url)
|
||||
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)
|
||||
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.revealControls()
|
||||
}
|
||||
}
|
||||
@@ -169,7 +197,7 @@ final class PlayerModel: ObservableObject {
|
||||
resolvingYouTube = false
|
||||
}
|
||||
|
||||
private func openResolvedYouTube(_ video: YouTubeVideo) {
|
||||
private func openResolvedYouTube(_ video: YouTubeVideo, height: Int?, restoring: (position: Double?, playing: Bool)?) {
|
||||
guard let handle else { return }
|
||||
saveProgress()
|
||||
let revision = player_get_state(handle).revision
|
||||
@@ -191,23 +219,39 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
guard accepted else {
|
||||
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."
|
||||
}
|
||||
revealControls()
|
||||
return
|
||||
}
|
||||
didAcceptOpen(revision: revision, title: video.title, icon: nil, url: video.webpageURL, live: video.isLive)
|
||||
didAcceptOpen(revision: revision, title: video.title, icon: nil, url: video.webpageURL,
|
||||
live: video.isLive, restoring: restoring)
|
||||
youtubeHeights = video.availableHeights
|
||||
youtubeHeight = video.height
|
||||
youtubeQuality = height
|
||||
}
|
||||
|
||||
private func didAcceptOpen(revision: UInt64, title: String, icon: NSImage?, url: URL, live: Bool = false) {
|
||||
private func didAcceptOpen(revision: UInt64, title: String, icon: NSImage?, url: URL, live: Bool = false,
|
||||
restoring: (position: Double?, playing: Bool)? = nil) {
|
||||
guard let handle else { return }
|
||||
pendingRevision = revision
|
||||
fileName = title
|
||||
fileIcon = icon
|
||||
isLiveStream = live
|
||||
youtubeHeights = []
|
||||
youtubeHeight = nil
|
||||
youtubeQuality = nil
|
||||
progressURL = url
|
||||
resumePosition = live ? nil : playbackProgress.position(for: url)
|
||||
if let restoring {
|
||||
resumePosition = restoring.position
|
||||
} else {
|
||||
resumePosition = live ? nil : playbackProgress.position(for: url)
|
||||
}
|
||||
resumePlaying = restoring?.playing ?? true
|
||||
lastSavedPosition = nil
|
||||
needsVideoFit = true
|
||||
commandError = resumePosition != nil || player_play(handle) ? nil :
|
||||
needsVideoFit = restoring == nil
|
||||
commandError = resumePosition != nil || !resumePlaying || player_play(handle) ? nil :
|
||||
"Could not queue playback. Please try again."
|
||||
isSeeking = false
|
||||
seekPending = false
|
||||
@@ -274,6 +318,9 @@ final class PlayerModel: ObservableObject {
|
||||
fileName = nil
|
||||
fileIcon = nil
|
||||
isLiveStream = false
|
||||
youtubeHeights = []
|
||||
youtubeHeight = nil
|
||||
youtubeQuality = nil
|
||||
progressURL = nil
|
||||
resumePosition = nil
|
||||
lastSavedPosition = nil
|
||||
@@ -503,7 +550,7 @@ final class PlayerModel: ObservableObject {
|
||||
playbackProgress.save(nil, for: url)
|
||||
}
|
||||
}
|
||||
commandError = player_play(handle) ? nil : "Could not queue playback. Please try again."
|
||||
commandError = !resumePlaying || player_play(handle) ? nil : "Could not queue playback. Please try again."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -733,6 +780,19 @@ private struct PlayerContent: View {
|
||||
Button("Open File…", action: model.openPanel).disabled(!model.canOpen)
|
||||
Button("Open YouTube URL…", action: model.openYouTubePanel).disabled(!model.canOpen)
|
||||
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)
|
||||
ForEach(model.youtubeHeights, id: \.self) { height in
|
||||
Text("\(height)p").tag(height)
|
||||
}
|
||||
}
|
||||
.disabled(!model.canChangeYouTubeQuality)
|
||||
Text(model.youtubeHeight.map { "Current: \($0)p" } ?? "Current resolution unknown")
|
||||
}
|
||||
Divider()
|
||||
Button(model.isCompact ? "Exit Compact Mode" : "Enter Compact Mode", action: model.toggleCompact)
|
||||
.disabled(!model.canToggleCompact)
|
||||
|
||||
@@ -14,6 +14,8 @@ struct YouTubeVideo: Sendable {
|
||||
let duration: Double
|
||||
let isLive: Bool
|
||||
let webpageURL: URL
|
||||
let height: Int?
|
||||
let availableHeights: [Int]
|
||||
}
|
||||
|
||||
private struct YouTubeResolutionError: LocalizedError {
|
||||
@@ -58,7 +60,7 @@ enum YouTubeResolver {
|
||||
return URL(string: "https://www.youtube.com/watch?v=\(id)")!
|
||||
}
|
||||
|
||||
static func decode(_ data: Data, webpageURL: URL) throws -> YouTubeVideo {
|
||||
static func decode(_ data: Data, webpageURL: URL, height: Int? = nil) throws -> YouTubeVideo {
|
||||
guard data.count <= outputLimit else {
|
||||
throw YouTubeResolutionError(message: "yt-dlp returned too much metadata for one video.")
|
||||
}
|
||||
@@ -102,6 +104,27 @@ enum YouTubeResolver {
|
||||
guard videoFormat.dynamic_range == nil || videoFormat.dynamic_range?.uppercased() == "SDR" else {
|
||||
throw YouTubeResolutionError(message: "This video has no supported SDR stream. HDR playback is not supported.")
|
||||
}
|
||||
guard videoFormat.height == nil || videoFormat.height! > 0 else {
|
||||
throw YouTubeResolutionError(message: "yt-dlp returned an invalid video resolution.")
|
||||
}
|
||||
if let height {
|
||||
guard height > 0, videoFormat.height == height else {
|
||||
throw YouTubeResolutionError(message: "The requested \(height)p resolution is no longer available. Choose another quality.")
|
||||
}
|
||||
} else if let selectedHeight = videoFormat.height, selectedHeight > 1080 {
|
||||
throw YouTubeResolutionError(message: "YouTube did not provide a supported stream at or below 1080p.")
|
||||
}
|
||||
let formats = metadata.formats ?? [videoFormat] + (audioFormat.map { [$0] } ?? [])
|
||||
let hasSeparateAudio = formats.contains {
|
||||
$0.hasAudio && !$0.hasVideo && (try? stream($0, fallbackHeaders: metadata.http_headers)) != nil
|
||||
}
|
||||
let availableHeights = Set(formats.compactMap { format -> Int? in
|
||||
guard format.hasVideo, format.hasAudio || hasSeparateAudio,
|
||||
format.dynamic_range == nil || format.dynamic_range?.uppercased() == "SDR",
|
||||
let height = format.height, height > 0,
|
||||
(try? stream(format, fallbackHeaders: metadata.http_headers)) != nil else { return nil }
|
||||
return height
|
||||
}).sorted(by: >)
|
||||
let video = try stream(videoFormat, fallbackHeaders: metadata.http_headers)
|
||||
let audio = try audioFormat.map { try stream($0, fallbackHeaders: metadata.http_headers) }
|
||||
let title = metadata.title?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@@ -109,13 +132,17 @@ enum YouTubeResolver {
|
||||
title: title?.isEmpty == false ? title! : "YouTube video",
|
||||
video: video, audio: audio, duration: duration,
|
||||
isLive: metadata.is_live == true || metadata.live_status == "is_live",
|
||||
webpageURL: try validateURL(webpageURL.absoluteString)
|
||||
webpageURL: try validateURL(webpageURL.absoluteString),
|
||||
height: videoFormat.height, availableHeights: availableHeights
|
||||
)
|
||||
}
|
||||
|
||||
static func resolve(_ url: URL, executableURL: URL? = nil, runtimeURL: URL? = nil) async throws -> YouTubeVideo {
|
||||
static func resolve(_ url: URL, height: Int? = nil, executableURL: URL? = nil, runtimeURL: URL? = nil) async throws -> YouTubeVideo {
|
||||
try Task.checkCancellation()
|
||||
let webpageURL = try validateURL(url.absoluteString)
|
||||
guard height == nil || height! > 0 else {
|
||||
throw YouTubeResolutionError(message: "Choose a positive video resolution.")
|
||||
}
|
||||
let directories = searchDirectories()
|
||||
let executable = try executableURL.map { try executableFile($0, name: "yt-dlp") }
|
||||
?? findExecutable("yt-dlp", directories: directories)
|
||||
@@ -135,13 +162,15 @@ enum YouTubeResolver {
|
||||
var environment = ProcessInfo.processInfo.environment
|
||||
environment["PATH"] = directories.joined(separator: ":")
|
||||
let transport = "[protocol~='^(https?|m3u8(_native)?)$']"
|
||||
let resolution = height.map { "[height=\($0)]" } ?? "[height<=1080]"
|
||||
let arguments = [
|
||||
"--ignore-config", "--no-plugin-dirs", "--no-cache-dir", "--no-remote-components",
|
||||
"--no-playlist", "--simulate", "--skip-download", "--dump-single-json",
|
||||
"--no-progress", "--color", "never", "--no-check-formats",
|
||||
"--no-js-runtimes", "--js-runtimes", "\(runtimeName):\(runtime.path)",
|
||||
"--socket-timeout", "20", "--extractor-retries", "0", "--retries", "0",
|
||||
"--format", "bestvideo[dynamic_range=?SDR]\(transport)+bestaudio\(transport)/best[dynamic_range=?SDR]\(transport)",
|
||||
"--format-sort", "res", "--format-sort-force",
|
||||
"--format", "bestvideo*[dynamic_range=?SDR]\(resolution)\(transport)+bestaudio\(transport)/best[dynamic_range=?SDR]\(resolution)\(transport)",
|
||||
"--", webpageURL.absoluteString
|
||||
]
|
||||
let output = try await YouTubeExtraction(executable: executable, arguments: arguments, environment: environment).run()
|
||||
@@ -149,7 +178,7 @@ enum YouTubeResolver {
|
||||
if let error = dependencyError(String(decoding: output.stderr, as: UTF8.self).lowercased()) {
|
||||
throw error
|
||||
}
|
||||
let video = try decode(output.stdout, webpageURL: webpageURL)
|
||||
let video = try decode(output.stdout, webpageURL: webpageURL, height: height)
|
||||
try Task.checkCancellation()
|
||||
return video
|
||||
}
|
||||
@@ -250,6 +279,8 @@ private struct YouTubeMetadata: Decodable {
|
||||
let vcodec: String?
|
||||
let acodec: String?
|
||||
let dynamic_range: String?
|
||||
let height: Int?
|
||||
let formats: [YouTubeMetadata]?
|
||||
let http_headers: [String: String]?
|
||||
let requested_formats: [YouTubeMetadata]?
|
||||
|
||||
|
||||
Reference in New Issue
Block a user