feat: stream YouTube video with coordinated audio and video inputs
Resolve public YouTube URLs through external yt-dlp and play selected streams in the existing native window. Independent bounded network readers keep one authored timeline. Successful seeks clear FFmpeg's sticky aborted-read AVIO error; manifest seekability uses native format flags rather than a top-level I/O handle. Verified real YouTube playback, seeking, frame stepping, compact mode, cancellation, and the complete native regression checks.
This commit is contained in:
+151
-28
@@ -10,6 +10,8 @@ final class PlayerModel: ObservableObject {
|
||||
@Published private(set) var fileIcon: NSImage?
|
||||
@Published private(set) var commandError: String?
|
||||
@Published private(set) var pendingRevision: UInt64?
|
||||
@Published private(set) var resolvingYouTube = false
|
||||
@Published private(set) var isLiveStream = false
|
||||
@Published var seekPosition = 0.0
|
||||
@Published var isSeeking = false
|
||||
@Published private(set) var volume = 1.0
|
||||
@@ -40,6 +42,8 @@ final class PlayerModel: ObservableObject {
|
||||
private var lastAudibleVolume = 1.0
|
||||
private var needsVideoFit = false
|
||||
private var seekPending = false
|
||||
private var youtubeTask: Task<Void, Never>?
|
||||
private var youtubeRequest: UUID?
|
||||
|
||||
private init() {
|
||||
let player = player_create()
|
||||
@@ -58,20 +62,20 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
var isBusy: Bool { pendingRevision != nil || state.status == PLAYER_OPENING }
|
||||
var isBusy: Bool { resolvingYouTube || pendingRevision != nil || state.status == PLAYER_OPENING }
|
||||
var canTogglePlayback: Bool {
|
||||
handle != nil && !isBusy &&
|
||||
(state.status == PLAYER_PAUSED || state.status == PLAYER_PLAYING || state.status == PLAYER_ENDED)
|
||||
}
|
||||
var canSeek: Bool { canTogglePlayback && state.duration.isFinite && state.duration > 0 }
|
||||
var canStepFrame: Bool { canTogglePlayback && state.has_video && !isSeeking }
|
||||
var canOpen: Bool { handle != nil && !isBusy }
|
||||
var canClose: Bool { handle != nil && !isBusy && state.status != PLAYER_EMPTY }
|
||||
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 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 }
|
||||
var duration: Double { state.duration.isFinite ? max(0, state.duration) : 0 }
|
||||
var errorText: String? {
|
||||
if let commandError { return commandError }
|
||||
guard state.status == PLAYER_ERROR else { return nil }
|
||||
guard !resolvingYouTube, state.status == PLAYER_ERROR else { return nil }
|
||||
var error = state.error
|
||||
return withUnsafeBytes(of: &error) { bytes in
|
||||
let message = String(decoding: bytes.prefix { $0 != 0 }, as: UTF8.self)
|
||||
@@ -91,13 +95,120 @@ final class PlayerModel: ObservableObject {
|
||||
open(url)
|
||||
}
|
||||
|
||||
func open(_ url: URL) {
|
||||
guard canOpen, let handle else { return }
|
||||
guard url.isFileURL else {
|
||||
commandError = "Only local media files are supported."
|
||||
func openYouTubePanel() {
|
||||
guard canOpen else { return }
|
||||
revealControls()
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "Open YouTube Video"
|
||||
alert.informativeText = "Paste a YouTube video URL."
|
||||
alert.addButton(withTitle: "Open")
|
||||
alert.addButton(withTitle: "Cancel")
|
||||
let field = NSTextField(frame: NSRect(x: 0, y: 0, width: 360, height: 24))
|
||||
field.placeholderString = "https://www.youtube.com/watch?v=…"
|
||||
field.setAccessibilityLabel("YouTube video URL")
|
||||
alert.accessoryView = field
|
||||
alert.window.initialFirstResponder = field
|
||||
guard alert.runModal() == .alertFirstButtonReturn else { return }
|
||||
openYouTube(field.stringValue)
|
||||
}
|
||||
|
||||
func openYouTube(_ text: String) {
|
||||
guard let handle else { return }
|
||||
cancelYouTubeResolution()
|
||||
let url: URL
|
||||
do {
|
||||
url = try YouTubeResolver.validateURL(text)
|
||||
} catch {
|
||||
commandError = error.localizedDescription
|
||||
revealControls()
|
||||
return
|
||||
}
|
||||
if !player_pause(handle) {
|
||||
commandError = "Could not pause the current media to open YouTube. Please try again."
|
||||
revealControls()
|
||||
return
|
||||
}
|
||||
let request = UUID()
|
||||
youtubeRequest = request
|
||||
resolvingYouTube = true
|
||||
commandError = nil
|
||||
revealControls()
|
||||
youtubeTask = Task { [weak self] in
|
||||
do {
|
||||
let video = try await YouTubeResolver.resolve(url)
|
||||
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)
|
||||
} catch {
|
||||
guard !Task.isCancelled, let self, self.youtubeRequest == request else { return }
|
||||
self.youtubeTask = nil
|
||||
self.youtubeRequest = nil
|
||||
self.resolvingYouTube = false
|
||||
self.commandError = error.localizedDescription
|
||||
self.revealControls()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func cancelYouTubeResolution() {
|
||||
youtubeRequest = nil
|
||||
youtubeTask?.cancel()
|
||||
youtubeTask = nil
|
||||
resolvingYouTube = false
|
||||
}
|
||||
|
||||
private func openResolvedYouTube(_ video: YouTubeVideo) {
|
||||
guard let handle else { return }
|
||||
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) }
|
||||
}
|
||||
}
|
||||
return enqueue(audioURL: nil, audioHeaders: nil)
|
||||
}
|
||||
}
|
||||
guard accepted else {
|
||||
commandError = "Could not queue the YouTube video to open. Please try again."
|
||||
revealControls()
|
||||
return
|
||||
}
|
||||
didAcceptOpen(revision: revision, title: video.title, icon: nil, url: video.webpageURL, live: video.isLive)
|
||||
}
|
||||
|
||||
private func didAcceptOpen(revision: UInt64, title: String, icon: NSImage?, url: URL, live: Bool = false) {
|
||||
guard let handle else { return }
|
||||
pendingRevision = revision
|
||||
fileName = title
|
||||
fileIcon = icon
|
||||
isLiveStream = live
|
||||
needsVideoFit = true
|
||||
commandError = player_play(handle) ? nil : "Could not queue playback. Please try again."
|
||||
isSeeking = false
|
||||
seekPending = false
|
||||
seekPosition = 0
|
||||
window?.representedURL = url
|
||||
window?.title = title
|
||||
revealControls()
|
||||
}
|
||||
|
||||
func open(_ url: URL) {
|
||||
guard canOpen, let handle else { return }
|
||||
guard url.isFileURL else {
|
||||
openYouTube(url.absoluteString)
|
||||
return
|
||||
}
|
||||
cancelYouTubeResolution()
|
||||
let revision = player_get_state(handle).revision
|
||||
let accepted = url.withUnsafeFileSystemRepresentation { path in
|
||||
guard let path else { return false }
|
||||
@@ -107,17 +218,8 @@ final class PlayerModel: ObservableObject {
|
||||
commandError = "Could not queue the file to open. Please try again."
|
||||
return
|
||||
}
|
||||
pendingRevision = revision
|
||||
fileName = url.lastPathComponent
|
||||
fileIcon = NSWorkspace.shared.icon(forFile: url.path)
|
||||
needsVideoFit = true
|
||||
commandError = player_play(handle) ? nil : "Could not queue playback. Please try again."
|
||||
isSeeking = false
|
||||
seekPending = false
|
||||
seekPosition = 0
|
||||
window?.representedURL = url
|
||||
window?.title = url.lastPathComponent
|
||||
revealControls()
|
||||
didAcceptOpen(revision: revision, title: url.lastPathComponent,
|
||||
icon: NSWorkspace.shared.icon(forFile: url.path), url: url)
|
||||
}
|
||||
|
||||
func togglePlayback() {
|
||||
@@ -139,6 +241,7 @@ final class PlayerModel: ObservableObject {
|
||||
|
||||
func closeFile() {
|
||||
guard let handle, canClose else { return }
|
||||
cancelYouTubeResolution()
|
||||
guard player_close(handle) else {
|
||||
commandError = "Could not queue closing the file. Please try again."
|
||||
return
|
||||
@@ -146,6 +249,7 @@ final class PlayerModel: ObservableObject {
|
||||
compactWindow.restore(resumeFullScreen: false)
|
||||
fileName = nil
|
||||
fileIcon = nil
|
||||
isLiveStream = false
|
||||
needsVideoFit = false
|
||||
window?.aspectRatio = .zero
|
||||
commandError = nil
|
||||
@@ -339,6 +443,7 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
|
||||
func shutdown() {
|
||||
cancelYouTubeResolution()
|
||||
compactWindow.restore(resumeFullScreen: false)
|
||||
timer?.invalidate()
|
||||
timer = nil
|
||||
@@ -558,6 +663,7 @@ private struct PlayerContent: View {
|
||||
private var actionsMenu: some View {
|
||||
Menu {
|
||||
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)
|
||||
Divider()
|
||||
Button(model.isCompact ? "Exit Compact Mode" : "Enter Compact Mode", action: model.toggleCompact)
|
||||
@@ -651,13 +757,16 @@ private struct PlayerContent: View {
|
||||
showsRemainingTime.toggle()
|
||||
model.revealControls()
|
||||
} label: {
|
||||
Text(showsRemainingTime ? "−" + time(model.duration - model.seekPosition) : time(model.duration))
|
||||
Text(model.isLiveStream ? "LIVE" :
|
||||
(showsRemainingTime ? "−" + time(model.duration - model.seekPosition) : time(model.duration)))
|
||||
.frame(minWidth: 32, alignment: .leading)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help(showsRemainingTime ? "Show Duration" : "Show Remaining Time")
|
||||
.accessibilityLabel(showsRemainingTime ? "Remaining Time" : "Duration")
|
||||
.accessibilityValue(time(showsRemainingTime ? model.duration - model.seekPosition : model.duration))
|
||||
.disabled(model.isLiveStream)
|
||||
.help(model.isLiveStream ? "Live Stream" : (showsRemainingTime ? "Show Duration" : "Show Remaining Time"))
|
||||
.accessibilityLabel(model.isLiveStream ? "Live Stream" : (showsRemainingTime ? "Remaining Time" : "Duration"))
|
||||
.accessibilityValue(model.isLiveStream ? "Live" :
|
||||
time(showsRemainingTime ? model.duration - model.seekPosition : model.duration))
|
||||
}
|
||||
.font(.system(size: 11).monospacedDigit())
|
||||
.padding(.horizontal, 16)
|
||||
@@ -707,7 +816,8 @@ private struct PlayerContent: View {
|
||||
} else if model.isBusy {
|
||||
VStack(spacing: 12) {
|
||||
ProgressView().controlSize(.large)
|
||||
Text("Opening…").font(.callout).foregroundStyle(.secondary)
|
||||
Text(model.resolvingYouTube ? "Resolving YouTube…" : "Opening…")
|
||||
.font(.callout).foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else if model.fileName == nil {
|
||||
@@ -715,9 +825,11 @@ private struct PlayerContent: View {
|
||||
Image(systemName: "play.rectangle").font(.system(size: 56, weight: .ultraLight))
|
||||
.foregroundStyle(.white.opacity(0.65))
|
||||
Text("terebi").font(.system(size: 22, weight: .medium))
|
||||
Text("Drop a media file here").font(.callout).foregroundStyle(.secondary)
|
||||
Text("Drop a media file or YouTube link here").font(.callout).foregroundStyle(.secondary)
|
||||
Button("Open File…", action: model.openPanel)
|
||||
.buttonStyle(.bordered).controlSize(.large).disabled(!model.canOpen)
|
||||
Button("Open YouTube URL…", action: model.openYouTubePanel)
|
||||
.buttonStyle(.bordered).disabled(!model.canOpen)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else if !model.state.has_video && model.state.has_audio {
|
||||
@@ -729,6 +841,13 @@ private struct PlayerContent: View {
|
||||
.padding(40)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
if model.state.buffering && !model.isBusy && model.errorText == nil {
|
||||
ProgressView("Buffering…")
|
||||
.padding(12)
|
||||
.background(PlayerGlass().clipShape(RoundedRectangle(cornerRadius: 10)))
|
||||
.position(x: geometry.size.width / 2, y: geometry.size.height / 2)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
|
||||
if model.isCompact {
|
||||
Text(model.fileName ?? "terebi")
|
||||
@@ -782,7 +901,8 @@ private struct PlayerContent: View {
|
||||
}
|
||||
}
|
||||
.dropDestination(for: URL.self) { urls, _ in
|
||||
guard model.canOpen, let url = urls.first, url.isFileURL else { return false }
|
||||
guard model.canOpen, let url = urls.first else { return false }
|
||||
if !url.isFileURL && (try? YouTubeResolver.validateURL(url.absoluteString)) == nil { return false }
|
||||
model.open(url)
|
||||
return true
|
||||
} isTargeted: {
|
||||
@@ -974,6 +1094,9 @@ struct TerebiApp: App {
|
||||
CommandGroup(replacing: .newItem) {
|
||||
Button("Open File…", action: model.openPanel).keyboardShortcut("o")
|
||||
.disabled(!model.canOpen)
|
||||
Button("Open YouTube URL…", action: model.openYouTubePanel)
|
||||
.keyboardShortcut("o", modifiers: [.command, .shift])
|
||||
.disabled(!model.canOpen)
|
||||
Button("Close File", action: model.closeFile)
|
||||
.keyboardShortcut("w", modifiers: [.command, .shift])
|
||||
.disabled(!model.canClose)
|
||||
|
||||
Reference in New Issue
Block a user