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)
|
||||
|
||||
@@ -0,0 +1,454 @@
|
||||
import Foundation
|
||||
import Dispatch
|
||||
import Darwin
|
||||
|
||||
struct YouTubeStream: Sendable {
|
||||
let url: String
|
||||
let headers: String
|
||||
}
|
||||
|
||||
struct YouTubeVideo: Sendable {
|
||||
let title: String
|
||||
let video: YouTubeStream
|
||||
let audio: YouTubeStream?
|
||||
let duration: Double
|
||||
let isLive: Bool
|
||||
let webpageURL: URL
|
||||
}
|
||||
|
||||
private struct YouTubeResolutionError: LocalizedError {
|
||||
let message: String
|
||||
var errorDescription: String? { message }
|
||||
}
|
||||
|
||||
enum YouTubeResolver {
|
||||
fileprivate static let outputLimit = 16 * 1024 * 1024
|
||||
|
||||
static func validateURL(_ text: String) throws -> URL {
|
||||
let text = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty,
|
||||
!text.unicodeScalars.contains(where: { CharacterSet.whitespacesAndNewlines.contains($0) || CharacterSet.controlCharacters.contains($0) }),
|
||||
let components = URLComponents(string: text),
|
||||
let scheme = components.scheme?.lowercased(), ["http", "https"].contains(scheme),
|
||||
components.user == nil, components.password == nil,
|
||||
components.port == nil || components.port == (scheme == "https" ? 443 : 80),
|
||||
let host = components.host?.lowercased() else {
|
||||
throw YouTubeResolutionError(message: "Enter a valid HTTP or HTTPS YouTube video URL.")
|
||||
}
|
||||
let id: String?
|
||||
let path = components.path.split(separator: "/", omittingEmptySubsequences: false)
|
||||
if host == "youtu.be", path.count == 2, path[0].isEmpty {
|
||||
id = String(path[1])
|
||||
} else if ["youtube.com", "www.youtube.com", "m.youtube.com", "music.youtube.com"].contains(host) {
|
||||
if components.path == "/watch" {
|
||||
let videoIDs = components.queryItems?.filter { $0.name == "v" } ?? []
|
||||
id = videoIDs.count == 1 ? videoIDs[0].value : nil
|
||||
} else if path.count == 3, path[0].isEmpty, ["shorts", "live", "embed"].contains(String(path[1])) {
|
||||
id = String(path[2])
|
||||
} else {
|
||||
id = nil
|
||||
}
|
||||
} else {
|
||||
throw YouTubeResolutionError(message: "Only YouTube video URLs are supported.")
|
||||
}
|
||||
guard let id, id.utf8.count == 11,
|
||||
id.utf8.allSatisfy({ (65...90).contains($0) || (97...122).contains($0) || (48...57).contains($0) || $0 == 45 || $0 == 95 }) else {
|
||||
throw YouTubeResolutionError(message: "The YouTube URL must identify one video, not a playlist or channel.")
|
||||
}
|
||||
return URL(string: "https://www.youtube.com/watch?v=\(id)")!
|
||||
}
|
||||
|
||||
static func decode(_ data: Data, webpageURL: URL) throws -> YouTubeVideo {
|
||||
guard data.count <= outputLimit else {
|
||||
throw YouTubeResolutionError(message: "yt-dlp returned too much metadata for one video.")
|
||||
}
|
||||
let metadata: YouTubeMetadata
|
||||
do {
|
||||
metadata = try JSONDecoder().decode(YouTubeMetadata.self, from: data)
|
||||
} catch {
|
||||
throw YouTubeResolutionError(message: "yt-dlp returned malformed video metadata. Update yt-dlp and try again.")
|
||||
}
|
||||
guard metadata._type == nil || metadata._type == "video" else {
|
||||
throw YouTubeResolutionError(message: "yt-dlp did not resolve a single playable video.")
|
||||
}
|
||||
guard metadata.live_status != "is_upcoming" else {
|
||||
throw YouTubeResolutionError(message: "This livestream has not started yet.")
|
||||
}
|
||||
let duration = metadata.duration ?? 0
|
||||
guard duration.isFinite, duration >= 0 else {
|
||||
throw YouTubeResolutionError(message: "yt-dlp returned an invalid video duration.")
|
||||
}
|
||||
let videoFormat: YouTubeMetadata
|
||||
let audioFormat: YouTubeMetadata?
|
||||
if let formats = metadata.requested_formats {
|
||||
if formats.count == 1, formats[0].hasVideo, formats[0].hasAudio {
|
||||
videoFormat = formats[0]
|
||||
audioFormat = nil
|
||||
} else if formats.count == 2,
|
||||
let video = formats.first(where: { $0.hasVideo && $0.acodec == "none" }),
|
||||
let audio = formats.first(where: { $0.hasAudio && $0.vcodec == "none" }) {
|
||||
videoFormat = video
|
||||
audioFormat = audio
|
||||
} else {
|
||||
throw YouTubeResolutionError(message: "yt-dlp did not select a supported video and audio stream pair.")
|
||||
}
|
||||
} else {
|
||||
guard metadata.hasVideo, metadata.hasAudio else {
|
||||
throw YouTubeResolutionError(message: "No supported combined video and audio stream was returned.")
|
||||
}
|
||||
videoFormat = metadata
|
||||
audioFormat = nil
|
||||
}
|
||||
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.")
|
||||
}
|
||||
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)
|
||||
return YouTubeVideo(
|
||||
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)
|
||||
)
|
||||
}
|
||||
|
||||
static func resolve(_ url: URL, executableURL: URL? = nil, runtimeURL: URL? = nil) async throws -> YouTubeVideo {
|
||||
try Task.checkCancellation()
|
||||
let webpageURL = try validateURL(url.absoluteString)
|
||||
let directories = searchDirectories()
|
||||
let executable = try executableURL.map { try executableFile($0, name: "yt-dlp") }
|
||||
?? findExecutable("yt-dlp", directories: directories)
|
||||
guard let executable else {
|
||||
throw YouTubeResolutionError(message: "YouTube playback requires yt-dlp. Install it with ‘brew install yt-dlp deno’, then try again.")
|
||||
}
|
||||
let runtime: URL?
|
||||
if let runtimeURL {
|
||||
runtime = try executableFile(runtimeURL, name: "JavaScript runtime")
|
||||
} else {
|
||||
runtime = ["deno", "node", "qjs", "quickjs", "bun"].lazy.compactMap { findExecutable($0, directories: directories) }.first
|
||||
}
|
||||
guard let runtime,
|
||||
let runtimeName = ["deno": "deno", "node": "node", "qjs": "quickjs", "quickjs": "quickjs", "bun": "bun"][runtime.lastPathComponent] else {
|
||||
throw YouTubeResolutionError(message: "YouTube playback requires a supported JavaScript runtime. Install Deno with ‘brew install deno’. Supported runtimes are Deno, Node, QuickJS, and Bun.")
|
||||
}
|
||||
var environment = ProcessInfo.processInfo.environment
|
||||
environment["PATH"] = directories.joined(separator: ":")
|
||||
let transport = "[protocol~='^(https?|m3u8(_native)?)$']"
|
||||
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)",
|
||||
"--", webpageURL.absoluteString
|
||||
]
|
||||
let output = try await YouTubeExtraction(executable: executable, arguments: arguments, environment: environment).run()
|
||||
try Task.checkCancellation()
|
||||
if let error = dependencyError(String(decoding: output.stderr, as: UTF8.self).lowercased()) {
|
||||
throw error
|
||||
}
|
||||
let video = try decode(output.stdout, webpageURL: webpageURL)
|
||||
try Task.checkCancellation()
|
||||
return video
|
||||
}
|
||||
|
||||
private static func stream(_ format: YouTubeMetadata, fallbackHeaders: [String: String]?) throws -> YouTubeStream {
|
||||
guard format.protocol == nil || ["http", "https", "m3u8", "m3u8_native"].contains(format.protocol!) else {
|
||||
throw YouTubeResolutionError(message: "yt-dlp selected a format that requires downloading and assembling fragments instead of native streaming.")
|
||||
}
|
||||
guard let text = format.url,
|
||||
!text.unicodeScalars.contains(where: { CharacterSet.whitespacesAndNewlines.contains($0) || CharacterSet.controlCharacters.contains($0) }),
|
||||
let components = URLComponents(string: text),
|
||||
let scheme = components.scheme?.lowercased(), ["http", "https"].contains(scheme),
|
||||
let host = components.host, !host.isEmpty,
|
||||
components.user == nil, components.password == nil,
|
||||
components.port == nil || (1...65535).contains(components.port!),
|
||||
let url = components.url else {
|
||||
throw YouTubeResolutionError(message: "yt-dlp returned an invalid HTTP stream URL.")
|
||||
}
|
||||
var headers = ""
|
||||
for (name, value) in (format.http_headers ?? fallbackHeaders ?? [:]).sorted(by: { $0.key < $1.key }) {
|
||||
guard !name.isEmpty,
|
||||
name.utf8.allSatisfy({ (65...90).contains($0) || (97...122).contains($0) || (48...57).contains($0) || "!#$%&'*+-.^_`|~".utf8.contains($0) }),
|
||||
!value.unicodeScalars.contains(where: { CharacterSet.controlCharacters.contains($0) || CharacterSet.newlines.contains($0) }) else {
|
||||
throw YouTubeResolutionError(message: "yt-dlp returned unsafe HTTP stream headers.")
|
||||
}
|
||||
headers += "\(name): \(value)\r\n"
|
||||
}
|
||||
return YouTubeStream(url: url.absoluteString, headers: headers)
|
||||
}
|
||||
|
||||
private static func searchDirectories() -> [String] {
|
||||
let paths = (ProcessInfo.processInfo.environment["PATH"] ?? "").split(separator: ":").map(String.init)
|
||||
+ ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"]
|
||||
var result: [String] = []
|
||||
for path in paths where path.hasPrefix("/") && !result.contains(path) { result.append(path) }
|
||||
return result
|
||||
}
|
||||
|
||||
private static func findExecutable(_ name: String, directories: [String]) -> URL? {
|
||||
for directory in directories {
|
||||
let url = URL(fileURLWithPath: directory).appendingPathComponent(name)
|
||||
if isExecutableFile(url) { return url }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func isExecutableFile(_ url: URL) -> Bool {
|
||||
var isDirectory: ObjCBool = false
|
||||
return url.isFileURL && FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory)
|
||||
&& !isDirectory.boolValue && FileManager.default.isExecutableFile(atPath: url.path)
|
||||
}
|
||||
|
||||
private static func executableFile(_ url: URL, name: String) throws -> URL {
|
||||
guard isExecutableFile(url) else {
|
||||
throw YouTubeResolutionError(message: "The \(name) executable is missing or is not executable.")
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
fileprivate static func dependencyError(_ text: String) -> YouTubeResolutionError? {
|
||||
if text.contains("yt-dlp-ejs") || text.contains("challenge solving failed") || text.contains("no supported javascript") || text.contains("no javascript runtime") {
|
||||
return YouTubeResolutionError(message: "YouTube extraction requires current yt-dlp, its EJS components, and a supported JavaScript runtime. Run ‘brew upgrade yt-dlp deno’. For a pip installation, install ‘yt-dlp[default]’ and Deno.")
|
||||
}
|
||||
if text.contains("no such option") || text.contains("unrecognized arguments") {
|
||||
return YouTubeResolutionError(message: "The installed yt-dlp is too old for YouTube streaming. Update it with ‘brew upgrade yt-dlp’.")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
fileprivate static func extractionError(_ data: Data, status: Int32) -> YouTubeResolutionError {
|
||||
let text = String(decoding: data, as: UTF8.self).lowercased()
|
||||
if let error = dependencyError(text) { return error }
|
||||
// Never include extractor output: it can contain signed URLs or credentials.
|
||||
if text.contains("sign in") || text.contains("login") || text.contains("private video") || text.contains("age-restricted") || text.contains("cookies") {
|
||||
return YouTubeResolutionError(message: "YouTube requires sign-in or additional access for this video. terebi does not read browser cookies; try a publicly accessible video.")
|
||||
}
|
||||
if text.contains("no video formats") || text.contains("no formats") || text.contains("requested format") {
|
||||
return YouTubeResolutionError(message: "YouTube did not provide a supported SDR video and audio stream. Update yt-dlp and try again.")
|
||||
}
|
||||
if text.contains("not available") || text.contains("unavailable") || text.contains("removed") || text.contains("copyright") || text.contains("blocked") {
|
||||
return YouTubeResolutionError(message: "This YouTube video is unavailable or restricted in your region.")
|
||||
}
|
||||
if text.contains("timed out") || text.contains("connection") || text.contains("http error") || text.contains("certificate") || text.contains("urlopen") {
|
||||
return YouTubeResolutionError(message: "Could not reach YouTube. Check your network connection and try again; update yt-dlp if the problem persists.")
|
||||
}
|
||||
return YouTubeResolutionError(message: "yt-dlp could not resolve this video (exit status \(status)). Update yt-dlp and Deno, then try again.")
|
||||
}
|
||||
}
|
||||
|
||||
private struct YouTubeMetadata: Decodable {
|
||||
let _type: String?
|
||||
let title: String?
|
||||
let duration: Double?
|
||||
let is_live: Bool?
|
||||
let live_status: String?
|
||||
let url: String?
|
||||
let `protocol`: String?
|
||||
let vcodec: String?
|
||||
let acodec: String?
|
||||
let dynamic_range: String?
|
||||
let http_headers: [String: String]?
|
||||
let requested_formats: [YouTubeMetadata]?
|
||||
|
||||
var hasVideo: Bool { vcodec.map { !$0.isEmpty && $0 != "none" } ?? false }
|
||||
var hasAudio: Bool { acodec.map { !$0.isEmpty && $0 != "none" } ?? false }
|
||||
}
|
||||
|
||||
private struct YouTubeExtractionOutput: Sendable {
|
||||
let stdout: Data
|
||||
let stderr: Data
|
||||
}
|
||||
|
||||
// All Process, pipe, and continuation state belongs to this request's serial queue.
|
||||
private final class YouTubeExtraction: @unchecked Sendable {
|
||||
private let queue = DispatchQueue(label: "terebi.youtube-extraction", qos: .userInitiated)
|
||||
private let process = Process()
|
||||
private let stdoutPipe = Pipe()
|
||||
private let stderrPipe = Pipe()
|
||||
private var stdoutSource: (any DispatchSourceRead)?
|
||||
private var stderrSource: (any DispatchSourceRead)?
|
||||
private var continuation: CheckedContinuation<YouTubeExtractionOutput, any Error>?
|
||||
private var stdout = Data()
|
||||
private var stderr = Data()
|
||||
private var scratch = [UInt8](repeating: 0, count: 16 * 1024)
|
||||
private var cancelled = false
|
||||
private var launched = false
|
||||
private var exited = false
|
||||
private var stdoutDone = false
|
||||
private var stderrDone = false
|
||||
private var finished = false
|
||||
private var failure: (any Error)?
|
||||
|
||||
init(executable: URL, arguments: [String], environment: [String: String]) {
|
||||
process.executableURL = executable
|
||||
process.arguments = arguments
|
||||
process.environment = environment
|
||||
process.standardInput = FileHandle.nullDevice
|
||||
process.standardOutput = stdoutPipe
|
||||
process.standardError = stderrPipe
|
||||
}
|
||||
|
||||
func run() async throws -> YouTubeExtractionOutput {
|
||||
try await withTaskCancellationHandler {
|
||||
try Task.checkCancellation()
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
queue.async { self.start(continuation) }
|
||||
}
|
||||
} onCancel: {
|
||||
self.queue.async { self.cancel() }
|
||||
}
|
||||
}
|
||||
|
||||
private func start(_ continuation: CheckedContinuation<YouTubeExtractionOutput, any Error>) {
|
||||
self.continuation = continuation
|
||||
guard !cancelled else {
|
||||
finish(.failure(CancellationError()))
|
||||
return
|
||||
}
|
||||
let stdoutFD = stdoutPipe.fileHandleForReading.fileDescriptor
|
||||
let stderrFD = stderrPipe.fileHandleForReading.fileDescriptor
|
||||
for descriptor in [stdoutFD, stderrFD] {
|
||||
let flags = fcntl(descriptor, F_GETFL)
|
||||
guard flags >= 0, fcntl(descriptor, F_SETFL, flags | O_NONBLOCK) >= 0 else {
|
||||
finish(.failure(YouTubeResolutionError(message: "Could not create the yt-dlp output pipes.")))
|
||||
return
|
||||
}
|
||||
}
|
||||
stdoutSource = DispatchSource.makeReadSource(fileDescriptor: stdoutFD, queue: queue)
|
||||
stderrSource = DispatchSource.makeReadSource(fileDescriptor: stderrFD, queue: queue)
|
||||
stdoutSource?.setEventHandler { [weak self] in self?.drain(isStdout: true) }
|
||||
stderrSource?.setEventHandler { [weak self] in self?.drain(isStdout: false) }
|
||||
let stdoutHandle = stdoutPipe.fileHandleForReading
|
||||
let stderrHandle = stderrPipe.fileHandleForReading
|
||||
stdoutSource?.setCancelHandler { try? stdoutHandle.close() }
|
||||
stderrSource?.setCancelHandler { try? stderrHandle.close() }
|
||||
stdoutSource?.resume()
|
||||
stderrSource?.resume()
|
||||
process.terminationHandler = { [weak self] _ in
|
||||
guard let self else { return }
|
||||
self.queue.async { self.didExit() }
|
||||
}
|
||||
do {
|
||||
try process.run()
|
||||
launched = true
|
||||
try? stdoutPipe.fileHandleForWriting.close()
|
||||
try? stderrPipe.fileHandleForWriting.close()
|
||||
} catch {
|
||||
finish(.failure(YouTubeResolutionError(message: "Could not launch yt-dlp. Verify that yt-dlp is installed and executable.")))
|
||||
}
|
||||
}
|
||||
|
||||
private func drain(isStdout: Bool) {
|
||||
guard !finished, !(isStdout ? stdoutDone : stderrDone) else { return }
|
||||
let handle = isStdout ? stdoutPipe.fileHandleForReading : stderrPipe.fileHandleForReading
|
||||
for _ in 0..<64 {
|
||||
let count = scratch.withUnsafeMutableBytes { Darwin.read(handle.fileDescriptor, $0.baseAddress!, $0.count) }
|
||||
if count > 0 {
|
||||
if isStdout {
|
||||
guard count <= YouTubeResolver.outputLimit - stdout.count else {
|
||||
failure = YouTubeResolutionError(message: "yt-dlp returned too much metadata for one video.")
|
||||
stop()
|
||||
return
|
||||
}
|
||||
scratch.withUnsafeBytes { stdout.append($0.bindMemory(to: UInt8.self).baseAddress!, count: count) }
|
||||
} else {
|
||||
let retained = min(count, 256 * 1024 - stderr.count)
|
||||
if retained > 0 {
|
||||
scratch.withUnsafeBytes { stderr.append($0.bindMemory(to: UInt8.self).baseAddress!, count: retained) }
|
||||
}
|
||||
}
|
||||
} else if count == 0 {
|
||||
closeStream(isStdout: isStdout)
|
||||
completeIfReady()
|
||||
return
|
||||
} else if errno == EINTR {
|
||||
continue
|
||||
} else if errno == EAGAIN || errno == EWOULDBLOCK {
|
||||
return
|
||||
} else {
|
||||
failure = YouTubeResolutionError(message: "Could not read yt-dlp output.")
|
||||
stop()
|
||||
return
|
||||
}
|
||||
}
|
||||
// Yield between batches so continuous extractor output cannot starve cancellation.
|
||||
queue.async { self.drain(isStdout: isStdout) }
|
||||
}
|
||||
|
||||
private func didExit() {
|
||||
exited = true
|
||||
drain(isStdout: true)
|
||||
drain(isStdout: false)
|
||||
completeIfReady()
|
||||
}
|
||||
|
||||
private func cancel() {
|
||||
guard !finished else { return }
|
||||
cancelled = true
|
||||
failure = CancellationError()
|
||||
stop()
|
||||
}
|
||||
|
||||
private func stop() {
|
||||
closeStream(isStdout: true)
|
||||
closeStream(isStdout: false)
|
||||
if launched, !exited, process.isRunning {
|
||||
process.terminate()
|
||||
queue.asyncAfter(deadline: .now() + .milliseconds(250)) {
|
||||
if !self.exited, self.process.isRunning {
|
||||
Darwin.kill(self.process.processIdentifier, SIGKILL)
|
||||
}
|
||||
}
|
||||
}
|
||||
completeIfReady()
|
||||
}
|
||||
|
||||
private func closeStream(isStdout: Bool) {
|
||||
if isStdout {
|
||||
guard !stdoutDone else { return }
|
||||
stdoutDone = true
|
||||
if let source = stdoutSource {
|
||||
source.cancel()
|
||||
stdoutSource = nil
|
||||
} else {
|
||||
try? stdoutPipe.fileHandleForReading.close()
|
||||
}
|
||||
} else {
|
||||
guard !stderrDone else { return }
|
||||
stderrDone = true
|
||||
if let source = stderrSource {
|
||||
source.cancel()
|
||||
stderrSource = nil
|
||||
} else {
|
||||
try? stderrPipe.fileHandleForReading.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func completeIfReady() {
|
||||
guard !finished, continuation != nil, (!launched || exited), stdoutDone, stderrDone else { return }
|
||||
if let failure {
|
||||
finish(.failure(failure))
|
||||
} else if process.terminationStatus != 0 {
|
||||
finish(.failure(YouTubeResolver.extractionError(stderr, status: process.terminationStatus)))
|
||||
} else {
|
||||
finish(.success(YouTubeExtractionOutput(stdout: stdout, stderr: stderr)))
|
||||
}
|
||||
}
|
||||
|
||||
private func finish(_ result: Result<YouTubeExtractionOutput, any Error>) {
|
||||
guard !finished else { return }
|
||||
finished = true
|
||||
process.terminationHandler = nil
|
||||
closeStream(isStdout: true)
|
||||
closeStream(isStdout: false)
|
||||
try? stdoutPipe.fileHandleForWriting.close()
|
||||
try? stderrPipe.fileHandleForWriting.close()
|
||||
let completion = continuation
|
||||
continuation = nil
|
||||
completion?.resume(with: result)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
#pragma once
|
||||
|
||||
extern "C" {
|
||||
#include <libavformat/avformat.h>
|
||||
}
|
||||
#include <array>
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <cerrno>
|
||||
#include <condition_variable>
|
||||
#include <cstdint>
|
||||
#include <limits>
|
||||
#include <mutex>
|
||||
#include <new>
|
||||
#include <thread>
|
||||
|
||||
namespace playback {
|
||||
// Only network inputs need a reader: a blocked socket must never own the player worker.
|
||||
// Format metadata is immutable after start(); the reader exclusively owns demux and seek I/O.
|
||||
class NetworkInput {
|
||||
public:
|
||||
static constexpr std::size_t packetLimit = 64;
|
||||
static constexpr std::size_t byteLimit = 16 * 1024 * 1024;
|
||||
AVFormatContext *format = nullptr;
|
||||
|
||||
NetworkInput(const std::atomic<bool> &stopping, const std::atomic<uint64_t> &epoch,
|
||||
uint64_t activeEpoch, std::condition_variable &playerWake)
|
||||
: stopping(stopping), epoch(epoch), activeEpoch(activeEpoch), playerWake(playerWake) {}
|
||||
~NetworkInput() {
|
||||
stop();
|
||||
for (auto &lane : lanes)
|
||||
for (auto *packet : lane.packets) av_packet_free(&packet);
|
||||
av_packet_free(&scratch);
|
||||
avformat_close_input(&format);
|
||||
}
|
||||
NetworkInput(const NetworkInput &) = delete;
|
||||
NetworkInput &operator=(const NetworkInput &) = delete;
|
||||
|
||||
static int interrupt(void *opaque) {
|
||||
auto &input = *static_cast<NetworkInput *>(opaque);
|
||||
return input.cancelled.load(std::memory_order_relaxed) ||
|
||||
input.stopping.load(std::memory_order_relaxed) ||
|
||||
input.epoch.load(std::memory_order_relaxed) != input.activeEpoch ||
|
||||
input.serial.load(std::memory_order_acquire) != input.operationSerial.load(std::memory_order_relaxed) ||
|
||||
now() >= input.deadline.load(std::memory_order_relaxed);
|
||||
}
|
||||
void armIO() {
|
||||
operationSerial.store(serial.load(std::memory_order_acquire), std::memory_order_relaxed);
|
||||
deadline.store(now() + 15'000'000'000LL, std::memory_order_relaxed);
|
||||
}
|
||||
void start(std::array<int, 2> indices) {
|
||||
for (int slot = 0; slot < 2; ++slot) {
|
||||
lanes[slot].index = indices[slot];
|
||||
if (indices[slot] < 0) continue;
|
||||
for (auto &packet : lanes[slot].packets) {
|
||||
packet = av_packet_alloc();
|
||||
if (!packet) throw std::bad_alloc();
|
||||
}
|
||||
}
|
||||
scratch = av_packet_alloc();
|
||||
if (!scratch) throw std::bad_alloc();
|
||||
reader = std::thread([this] { run(); });
|
||||
}
|
||||
void stop() {
|
||||
cancelled.store(true, std::memory_order_relaxed);
|
||||
changed.notify_all();
|
||||
if (reader.joinable()) reader.join();
|
||||
}
|
||||
void seek(int streamIndex, int64_t timestamp) {
|
||||
std::lock_guard lock(mutex);
|
||||
for (auto &lane : lanes) clear(lane);
|
||||
ended = false;
|
||||
failure = 0;
|
||||
seekIndex = streamIndex;
|
||||
seekTimestamp = timestamp;
|
||||
seeking = true;
|
||||
serial.fetch_add(1, std::memory_order_release);
|
||||
changed.notify_all();
|
||||
}
|
||||
void discardAudio(bool discard) {
|
||||
std::lock_guard lock(mutex);
|
||||
skipAudio = discard;
|
||||
if (discard) clear(lanes[0]);
|
||||
changed.notify_all();
|
||||
}
|
||||
void finish() {
|
||||
std::lock_guard lock(mutex);
|
||||
for (auto &lane : lanes) clear(lane);
|
||||
ended = true;
|
||||
seeking = false;
|
||||
failure = 0;
|
||||
serial.fetch_add(1, std::memory_order_release);
|
||||
changed.notify_all();
|
||||
}
|
||||
bool seekPending() const {
|
||||
std::lock_guard lock(mutex);
|
||||
return seeking;
|
||||
}
|
||||
int error() const {
|
||||
std::lock_guard lock(mutex);
|
||||
return failure;
|
||||
}
|
||||
// 1: packet, 0: not yet available, AVERROR_EOF: this input is drained.
|
||||
int take(int slot, AVPacket *destination) {
|
||||
std::lock_guard lock(mutex);
|
||||
if (failure) return failure;
|
||||
auto &lane = lanes[slot];
|
||||
if (lane.count) {
|
||||
auto *packet = lane.packets[lane.first];
|
||||
lane.bytes -= packetBytes(packet);
|
||||
av_packet_move_ref(destination, packet);
|
||||
lane.first = (lane.first + 1) % packetLimit;
|
||||
--lane.count;
|
||||
changed.notify_one();
|
||||
return 1;
|
||||
}
|
||||
return ended ? AVERROR_EOF : 0;
|
||||
}
|
||||
|
||||
private:
|
||||
struct Lane {
|
||||
std::array<AVPacket *, packetLimit> packets{};
|
||||
int index = -1;
|
||||
std::size_t first = 0, count = 0, bytes = 0;
|
||||
};
|
||||
const std::atomic<bool> &stopping;
|
||||
const std::atomic<uint64_t> &epoch;
|
||||
const uint64_t activeEpoch;
|
||||
std::condition_variable &playerWake;
|
||||
mutable std::mutex mutex;
|
||||
std::condition_variable changed;
|
||||
std::array<Lane, 2> lanes;
|
||||
AVPacket *scratch = nullptr;
|
||||
std::atomic<bool> cancelled{false};
|
||||
std::atomic<uint64_t> serial{0}, operationSerial{0};
|
||||
std::atomic<int64_t> deadline{std::numeric_limits<int64_t>::max()};
|
||||
bool seeking = false, ended = false, skipAudio = false;
|
||||
int seekIndex = -1, failure = 0;
|
||||
int64_t seekTimestamp = 0;
|
||||
std::thread reader;
|
||||
|
||||
static int64_t now() {
|
||||
return std::chrono::duration_cast<std::chrono::nanoseconds>(
|
||||
std::chrono::steady_clock::now().time_since_epoch()).count();
|
||||
}
|
||||
static std::size_t packetBytes(const AVPacket *packet) {
|
||||
if (packet->size < 0) return byteLimit + 1;
|
||||
std::size_t bytes = static_cast<std::size_t>(packet->size);
|
||||
if (bytes > byteLimit) return byteLimit + 1;
|
||||
for (int i = 0; i < packet->side_data_elems; ++i) {
|
||||
if (packet->side_data[i].size > byteLimit - bytes) return byteLimit + 1;
|
||||
bytes += packet->side_data[i].size;
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
static void clear(Lane &lane) {
|
||||
while (lane.count) {
|
||||
av_packet_unref(lane.packets[lane.first]);
|
||||
lane.first = (lane.first + 1) % packetLimit;
|
||||
--lane.count;
|
||||
}
|
||||
lane.first = lane.bytes = 0;
|
||||
}
|
||||
bool stoppingNow() const {
|
||||
return cancelled.load(std::memory_order_relaxed) ||
|
||||
stopping.load(std::memory_order_relaxed) || epoch.load(std::memory_order_relaxed) != activeEpoch;
|
||||
}
|
||||
void run() noexcept {
|
||||
try {
|
||||
while (!stoppingNow()) {
|
||||
uint64_t current;
|
||||
int index;
|
||||
int64_t timestamp;
|
||||
bool doSeek;
|
||||
{
|
||||
std::unique_lock lock(mutex);
|
||||
changed.wait_for(lock, std::chrono::milliseconds(20), [this] {
|
||||
return stoppingNow() || seeking || !ended;
|
||||
});
|
||||
if (stoppingNow()) return;
|
||||
if (ended && !seeking) continue;
|
||||
current = serial.load(std::memory_order_acquire);
|
||||
doSeek = seeking;
|
||||
index = seekIndex;
|
||||
timestamp = seekTimestamp;
|
||||
}
|
||||
operationSerial.store(current, std::memory_order_relaxed);
|
||||
deadline.store(now() + 15'000'000'000LL, std::memory_order_relaxed);
|
||||
if (doSeek) {
|
||||
const int result = index >= 0 ?
|
||||
av_seek_frame(format, index, timestamp, AVSEEK_FLAG_BACKWARD) :
|
||||
avformat_seek_file(format, -1, std::numeric_limits<int64_t>::min(), timestamp, timestamp, 0);
|
||||
if (result >= 0 && format->pb) {
|
||||
// An interrupted read leaves AVIO's error sticky even after a successful format seek.
|
||||
format->pb->error = 0;
|
||||
format->pb->eof_reached = 0;
|
||||
}
|
||||
std::lock_guard lock(mutex);
|
||||
if (serial.load(std::memory_order_acquire) != current) continue;
|
||||
seeking = false;
|
||||
if (result < 0) { failure = result; ended = true; }
|
||||
playerWake.notify_one();
|
||||
if (result < 0) continue;
|
||||
}
|
||||
const int result = av_read_frame(format, scratch);
|
||||
std::unique_lock lock(mutex);
|
||||
if (stoppingNow()) return;
|
||||
if (serial.load(std::memory_order_acquire) != current) {
|
||||
av_packet_unref(scratch);
|
||||
continue;
|
||||
}
|
||||
if (result == AVERROR(EAGAIN)) {
|
||||
changed.wait_for(lock, std::chrono::milliseconds(5));
|
||||
continue;
|
||||
}
|
||||
if (result < 0) {
|
||||
av_packet_unref(scratch);
|
||||
ended = true;
|
||||
if (result != AVERROR_EOF) failure = result;
|
||||
playerWake.notify_one();
|
||||
continue;
|
||||
}
|
||||
int slot = -1;
|
||||
for (int i = 0; i < 2; ++i)
|
||||
if (lanes[i].index >= 0 && scratch->stream_index == lanes[i].index) slot = i;
|
||||
if (slot < 0 || (slot == 0 && skipAudio)) { av_packet_unref(scratch); continue; }
|
||||
auto &lane = lanes[slot];
|
||||
const auto bytes = packetBytes(scratch);
|
||||
if (bytes > byteLimit) {
|
||||
failure = AVERROR(ENOBUFS);
|
||||
ended = true;
|
||||
av_packet_unref(scratch);
|
||||
playerWake.notify_one();
|
||||
continue;
|
||||
}
|
||||
changed.wait(lock, [&] {
|
||||
return stoppingNow() || serial.load(std::memory_order_acquire) != current ||
|
||||
(slot == 0 && skipAudio) ||
|
||||
(lane.count < packetLimit && lane.bytes + bytes <= byteLimit);
|
||||
});
|
||||
if (stoppingNow()) return;
|
||||
if (serial.load(std::memory_order_acquire) != current || (slot == 0 && skipAudio)) {
|
||||
av_packet_unref(scratch);
|
||||
continue;
|
||||
}
|
||||
lane.bytes += bytes;
|
||||
av_packet_move_ref(lane.packets[(lane.first + lane.count) % packetLimit], scratch);
|
||||
++lane.count;
|
||||
playerWake.notify_one();
|
||||
}
|
||||
} catch (...) {
|
||||
std::lock_guard lock(mutex);
|
||||
failure = AVERROR(ENOMEM);
|
||||
ended = true;
|
||||
playerWake.notify_one();
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
+471
-144
@@ -26,8 +26,11 @@ extern "C" {
|
||||
#include <mutex>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <thread>
|
||||
#include <arpa/inet.h>
|
||||
#include <vector>
|
||||
#include "network_input.hpp"
|
||||
|
||||
namespace {
|
||||
constexpr int sampleRate = 48000;
|
||||
@@ -53,11 +56,18 @@ struct VideoFrame {
|
||||
struct Track {
|
||||
AVCodecContext *codec = nullptr;
|
||||
int index = -1;
|
||||
AVFormatContext *format = nullptr;
|
||||
playback::NetworkInput *input = nullptr;
|
||||
AVPacket *packet = nullptr;
|
||||
bool packetPending = false;
|
||||
bool receiving = false;
|
||||
bool sourceEnded = false;
|
||||
bool drainSent = false;
|
||||
bool drained = false;
|
||||
double nextPts = 0;
|
||||
int64_t countedTimestamp = AV_NOPTS_VALUE;
|
||||
uint64_t timestampOccurrence = 0;
|
||||
AVStream *stream() const { return format->streams[index]; }
|
||||
};
|
||||
enum class Action { Open, Play, Pause, Seek, Step, Close };
|
||||
struct Command {
|
||||
@@ -67,6 +77,12 @@ struct Command {
|
||||
int direction = 0;
|
||||
uint64_t epoch = 0;
|
||||
uint64_t repetitions = 1;
|
||||
std::string audioURL{};
|
||||
std::string videoHeaders{};
|
||||
std::string audioHeaders{};
|
||||
double sourceDuration = 0;
|
||||
bool network = false;
|
||||
bool live = false;
|
||||
};
|
||||
|
||||
void copyError(char *destination, const char *message) noexcept {
|
||||
@@ -77,6 +93,75 @@ int64_t presentationTimestamp(const AVFrame *frame) {
|
||||
return frame->best_effort_timestamp != AV_NOPTS_VALUE ?
|
||||
frame->best_effort_timestamp : frame->pts;
|
||||
}
|
||||
|
||||
bool validNetworkURL(const char *text) {
|
||||
if (!text) return false;
|
||||
const std::string_view url(text);
|
||||
if (url.size() > 65536) return false;
|
||||
const std::size_t start = url.starts_with("https://") ? 8 : url.starts_with("http://") ? 7 : 0;
|
||||
if (!start) return false;
|
||||
for (unsigned char value : url)
|
||||
if (value <= 32 || value == 127 || value == '\\') return false;
|
||||
const auto authority = url.substr(start, url.find_first_of("/?#", start) - start);
|
||||
if (authority.empty() || authority.find('@') != std::string_view::npos) return false;
|
||||
auto host = authority;
|
||||
std::string_view port;
|
||||
if (host.front() == '[') {
|
||||
const auto end = host.find(']');
|
||||
if (end == std::string_view::npos || end == 1) return false;
|
||||
if (end + 1 < host.size()) {
|
||||
if (host[end + 1] != ':') return false;
|
||||
port = host.substr(end + 2);
|
||||
if (port.empty()) return false;
|
||||
}
|
||||
host = host.substr(1, end - 1);
|
||||
std::array<char, INET6_ADDRSTRLEN> address{};
|
||||
if (host.size() >= address.size()) return false;
|
||||
std::memcpy(address.data(), host.data(), host.size());
|
||||
in6_addr parsed{};
|
||||
if (inet_pton(AF_INET6, address.data(), &parsed) != 1) return false;
|
||||
} else {
|
||||
const auto colon = host.find(':');
|
||||
if (colon != std::string_view::npos) {
|
||||
port = host.substr(colon + 1);
|
||||
host = host.substr(0, colon);
|
||||
if (port.empty()) return false;
|
||||
}
|
||||
if (host.empty()) return false;
|
||||
for (unsigned char value : host)
|
||||
if (!((value >= 'a' && value <= 'z') || (value >= 'A' && value <= 'Z') ||
|
||||
(value >= '0' && value <= '9') || value == '.' || value == '-')) return false;
|
||||
}
|
||||
unsigned number = 0;
|
||||
for (unsigned char value : port) {
|
||||
if (value < '0' || value > '9') return false;
|
||||
number = number * 10 + value - '0';
|
||||
if (number > 65535) return false;
|
||||
}
|
||||
return port.empty() || number > 0;
|
||||
}
|
||||
|
||||
bool validHeaders(const char *text) {
|
||||
if (!text) return true;
|
||||
const std::string_view headers(text);
|
||||
if (headers.size() > 65536) return false;
|
||||
std::size_t offset = 0;
|
||||
while (offset < headers.size()) {
|
||||
const auto end = headers.find("\r\n", offset);
|
||||
if (end == std::string_view::npos) return false;
|
||||
const auto line = headers.substr(offset, end - offset);
|
||||
const auto colon = line.find(':');
|
||||
if (colon == std::string_view::npos || colon == 0) return false;
|
||||
for (unsigned char value : line.substr(0, colon))
|
||||
if (!((value >= 'a' && value <= 'z') || (value >= 'A' && value <= 'Z') ||
|
||||
(value >= '0' && value <= '9') ||
|
||||
std::string_view("!#$%&'*+-.^_`|~").find(value) != std::string_view::npos)) return false;
|
||||
for (unsigned char value : line.substr(colon + 1))
|
||||
if (value < 32 || value == 127) return false;
|
||||
offset = end + 2;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
struct Player {
|
||||
@@ -93,8 +178,12 @@ struct Player {
|
||||
std::atomic<uint64_t> requestedEpoch{0};
|
||||
uint64_t activeEpoch = 0;
|
||||
|
||||
// Everything below, including FFmpeg and Output audio operations, is worker-owned.
|
||||
// Decoders, timeline and output are worker-owned; network readers exclusively own demux I/O.
|
||||
AVFormatContext *format = nullptr;
|
||||
std::array<std::unique_ptr<playback::NetworkInput>, 2> inputs;
|
||||
bool network = false;
|
||||
bool canSeek = false;
|
||||
bool buffering = false;
|
||||
std::array<Track, 2> tracks;
|
||||
AVPacket *packet = nullptr;
|
||||
AVFrame *decoded = nullptr;
|
||||
@@ -105,7 +194,7 @@ struct Player {
|
||||
bool resamplerDrained = false;
|
||||
bool packetPending = false;
|
||||
bool demuxEnded = false;
|
||||
int receiving = -1;
|
||||
int nextDecodeSlot = 0;
|
||||
std::deque<VideoFrame> video;
|
||||
std::size_t videoBytes = 0;
|
||||
VideoFrame presented{};
|
||||
@@ -144,9 +233,10 @@ struct Player {
|
||||
bool enqueue(Command command) {
|
||||
std::lock_guard<std::mutex> lock(mutex);
|
||||
if (stopping.load(std::memory_order_relaxed)) return false;
|
||||
if (command.action == Action::Step &&
|
||||
(!state.has_video || publishedEpoch != requestedEpoch.load(std::memory_order_relaxed) ||
|
||||
(state.status != PLAYER_PAUSED && state.status != PLAYER_PLAYING && state.status != PLAYER_ENDED)))
|
||||
const bool ready = publishedEpoch == requestedEpoch.load(std::memory_order_relaxed) &&
|
||||
(state.status == PLAYER_PAUSED || state.status == PLAYER_PLAYING || state.status == PLAYER_ENDED);
|
||||
if (command.action == Action::Seek && ready && !state.can_seek) return false;
|
||||
if (command.action == Action::Step && (!ready || !state.has_video || !state.can_seek))
|
||||
return false;
|
||||
const bool newTimeline = command.action == Action::Open || command.action == Action::Close;
|
||||
command.epoch = requestedEpoch.load(std::memory_order_relaxed) + (newTimeline ? 1 : 0);
|
||||
@@ -182,9 +272,11 @@ struct Player {
|
||||
copyError(requestedError.data(), message);
|
||||
errorRequested = true;
|
||||
errorEpoch = requestedEpoch.fetch_add(1, std::memory_order_relaxed) + 1;
|
||||
const auto revision = state.revision + 1;
|
||||
state = {};
|
||||
state.status = PLAYER_ERROR;
|
||||
copyError(state.error, message);
|
||||
++state.revision;
|
||||
state.revision = revision;
|
||||
wake.notify_one();
|
||||
} catch (...) {
|
||||
// No exception may cross the C boundary, including a failed mutex operation.
|
||||
@@ -215,14 +307,18 @@ struct Player {
|
||||
state.duration = duration;
|
||||
state.has_audio = tracks[0].codec != nullptr;
|
||||
state.has_video = tracks[1].codec != nullptr;
|
||||
state.can_seek = canSeek;
|
||||
state.buffering = buffering;
|
||||
// Commands remain pending while executing; publication acknowledges only completed work.
|
||||
state.seek_pending = std::any_of(commands.begin(), commands.end(), [this](const Command &command) {
|
||||
return (command.action == Action::Seek || command.action == Action::Step) &&
|
||||
command.epoch == activeEpoch;
|
||||
});
|
||||
state.seek_pending = networkSeeksPending() ||
|
||||
std::any_of(commands.begin(), commands.end(), [this](const Command &command) {
|
||||
return (command.action == Action::Seek || command.action == Action::Step) &&
|
||||
command.epoch == activeEpoch;
|
||||
});
|
||||
state.video_aspect_ratio = 0;
|
||||
if (const auto *video = tracks[1].codec; video && video->width > 0 && video->height > 0) {
|
||||
const auto aspect = av_guess_sample_aspect_ratio(format, format->streams[tracks[1].index], nullptr);
|
||||
const auto &track = tracks[1];
|
||||
const auto aspect = av_guess_sample_aspect_ratio(track.format, track.stream(), nullptr);
|
||||
state.video_aspect_ratio = double(video->width) / video->height *
|
||||
(aspect.num > 0 && aspect.den > 0 ? av_q2d(aspect) : 1);
|
||||
}
|
||||
@@ -232,6 +328,7 @@ struct Player {
|
||||
|
||||
void release() {
|
||||
output.pause();
|
||||
for (auto &input : inputs) if (input) input->stop();
|
||||
output.reset();
|
||||
video.clear();
|
||||
videoBytes = 0;
|
||||
@@ -243,16 +340,20 @@ struct Player {
|
||||
av_channel_layout_uninit(&inputLayout);
|
||||
for (auto &track : tracks) {
|
||||
avcodec_free_context(&track.codec);
|
||||
av_packet_free(&track.packet);
|
||||
track = {};
|
||||
}
|
||||
av_packet_free(&packet);
|
||||
av_frame_free(&decoded);
|
||||
avformat_close_input(&format);
|
||||
if (!network) avformat_close_input(&format);
|
||||
for (auto &input : inputs) input.reset();
|
||||
format = nullptr;
|
||||
network = canSeek = buffering = false;
|
||||
inputRate = 0;
|
||||
inputFormat = AV_SAMPLE_FMT_NONE;
|
||||
pcmFrames = pcmOffset = 0;
|
||||
packetPending = demuxEnded = resamplerDrained = false;
|
||||
receiving = -1;
|
||||
nextDecodeSlot = 0;
|
||||
origin = duration = position = seekTarget = audioWriteEnd = videoEnd = resampleNextPts = 0;
|
||||
previewShown = false;
|
||||
error.fill(0);
|
||||
@@ -269,18 +370,23 @@ struct Player {
|
||||
publish();
|
||||
}
|
||||
|
||||
void openTrack(int slot, AVMediaType type, int related) {
|
||||
void openTrack(int slot, AVFormatContext *source, playback::NetworkInput *input,
|
||||
AVMediaType type, int related) {
|
||||
const AVCodec *decoder = nullptr;
|
||||
int index = av_find_best_stream(format, type, -1, related, &decoder, 0);
|
||||
int index = av_find_best_stream(source, type, -1, related, &decoder, 0);
|
||||
if (index == AVERROR_STREAM_NOT_FOUND) return;
|
||||
check(index, type == AVMEDIA_TYPE_AUDIO ? "Finding audio decoder" : "Finding video decoder");
|
||||
auto &track = tracks[slot];
|
||||
track.index = index;
|
||||
track.format = source;
|
||||
track.input = input;
|
||||
track.packet = av_packet_alloc();
|
||||
if (!track.packet) throw std::bad_alloc();
|
||||
track.codec = avcodec_alloc_context3(decoder);
|
||||
if (!track.codec) throw std::bad_alloc();
|
||||
check(avcodec_parameters_to_context(track.codec, format->streams[index]->codecpar),
|
||||
check(avcodec_parameters_to_context(track.codec, source->streams[index]->codecpar),
|
||||
"Reading codec parameters");
|
||||
track.codec->pkt_timebase = format->streams[index]->time_base;
|
||||
track.codec->pkt_timebase = source->streams[index]->time_base;
|
||||
// A bounded software decoder pool; FFmpeg owns its internal frame reorder buffer.
|
||||
track.codec->thread_count = 2;
|
||||
check(avcodec_open2(track.codec, decoder, nullptr), "Opening decoder");
|
||||
@@ -293,9 +399,11 @@ struct Player {
|
||||
if (!track.codec) continue;
|
||||
avcodec_flush_buffers(track.codec);
|
||||
track.drainSent = track.drained = false;
|
||||
av_packet_unref(track.packet);
|
||||
track.packetPending = track.receiving = track.sourceEnded = false;
|
||||
track.countedTimestamp = AV_NOPTS_VALUE;
|
||||
track.timestampOccurrence = 0;
|
||||
const AVStream *stream = format->streams[track.index];
|
||||
const AVStream *stream = track.stream();
|
||||
track.nextPts = stream->start_time == AV_NOPTS_VALUE ? target :
|
||||
std::max(target, stream->start_time * av_q2d(stream->time_base) - origin);
|
||||
}
|
||||
@@ -303,7 +411,7 @@ struct Player {
|
||||
av_channel_layout_uninit(&inputLayout);
|
||||
inputRate = 0;
|
||||
inputFormat = AV_SAMPLE_FMT_NONE;
|
||||
av_packet_unref(packet);
|
||||
if (packet) av_packet_unref(packet);
|
||||
av_frame_unref(decoded);
|
||||
video.clear();
|
||||
videoBytes = 0;
|
||||
@@ -313,69 +421,130 @@ struct Player {
|
||||
steppingAnchor = AV_NOPTS_VALUE;
|
||||
pcmFrames = pcmOffset = 0;
|
||||
packetPending = demuxEnded = resamplerDrained = false;
|
||||
receiving = -1;
|
||||
nextDecodeSlot = 0;
|
||||
position = seekTarget = audioWriteEnd = videoEnd = resampleNextPts = target;
|
||||
previewShown = false;
|
||||
steadyPosition = target;
|
||||
steadyAnchor = Clock::now();
|
||||
}
|
||||
|
||||
void open(const std::string &path) {
|
||||
playback::NetworkInput *openNetworkInput(int slot, const std::string &url, const std::string &headers) {
|
||||
inputs[slot] = std::make_unique<playback::NetworkInput>(stopping, requestedEpoch, activeEpoch, wake);
|
||||
auto &input = *inputs[slot];
|
||||
input.format = avformat_alloc_context();
|
||||
if (!input.format) throw std::bad_alloc();
|
||||
input.format->interrupt_callback = {playback::NetworkInput::interrupt, &input};
|
||||
AVDictionary *options = nullptr;
|
||||
int result = av_dict_set(&options, "protocol_whitelist", "http,https,tcp,tls,crypto", 0);
|
||||
if (result >= 0) result = av_dict_set(&options, "rw_timeout", "15000000", 0);
|
||||
if (result >= 0 && !headers.empty()) result = av_dict_set(&options, "headers", headers.c_str(), 0);
|
||||
if (result < 0) {
|
||||
av_dict_free(&options);
|
||||
check(result, "Configuring network input");
|
||||
}
|
||||
input.armIO();
|
||||
result = avformat_open_input(&input.format, url.c_str(), nullptr, &options);
|
||||
av_dict_free(&options);
|
||||
check(result, "Opening network media");
|
||||
input.armIO();
|
||||
check(avformat_find_stream_info(input.format, nullptr), "Reading network media information");
|
||||
return &input;
|
||||
}
|
||||
|
||||
bool networkSeeksPending() const {
|
||||
for (const auto &input : inputs)
|
||||
if (input && input->seekPending()) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
void open(const Command &command) {
|
||||
release();
|
||||
network = command.network;
|
||||
status = PLAYER_OPENING;
|
||||
publish();
|
||||
std::error_code filesystemError;
|
||||
if (!std::filesystem::is_regular_file(path, filesystemError))
|
||||
throw std::runtime_error(filesystemError ? filesystemError.message() :
|
||||
"Open requires a local regular media file");
|
||||
const std::string localPath = std::filesystem::absolute(path).string();
|
||||
format = avformat_alloc_context();
|
||||
if (!format) throw std::bad_alloc();
|
||||
format->interrupt_callback = {interrupt, this};
|
||||
AVDictionary *options = nullptr;
|
||||
const int optionResult = av_dict_set(&options, "protocol_whitelist", "file", 0);
|
||||
if (optionResult < 0) {
|
||||
av_dict_free(&options);
|
||||
check(optionResult, "Restricting input to local files");
|
||||
}
|
||||
const int result = avformat_open_input(&format, localPath.c_str(), nullptr, &options);
|
||||
av_dict_free(&options);
|
||||
check(result, "Opening media");
|
||||
check(avformat_find_stream_info(format, nullptr), "Reading media information");
|
||||
openTrack(1, AVMEDIA_TYPE_VIDEO, -1);
|
||||
openTrack(0, AVMEDIA_TYPE_AUDIO, tracks[1].index);
|
||||
if (!tracks[0].codec && !tracks[1].codec)
|
||||
throw std::runtime_error("The file contains no playable audio or video track");
|
||||
for (unsigned i = 0; i < format->nb_streams; ++i) {
|
||||
if (static_cast<int>(i) != tracks[0].index && static_cast<int>(i) != tracks[1].index)
|
||||
format->streams[i]->discard = AVDISCARD_ALL;
|
||||
}
|
||||
if (format->start_time != AV_NOPTS_VALUE) {
|
||||
origin = static_cast<double>(format->start_time) / AV_TIME_BASE;
|
||||
} else {
|
||||
double first = std::numeric_limits<double>::infinity();
|
||||
for (const auto &track : tracks) {
|
||||
if (!track.codec) continue;
|
||||
const AVStream *stream = format->streams[track.index];
|
||||
if (stream->start_time != AV_NOPTS_VALUE)
|
||||
first = std::min(first, stream->start_time * av_q2d(stream->time_base));
|
||||
if (network) {
|
||||
auto *primary = openNetworkInput(0, command.path, command.videoHeaders);
|
||||
format = primary->format;
|
||||
openTrack(1, format, primary, AVMEDIA_TYPE_VIDEO, -1);
|
||||
if (command.audioURL.empty()) {
|
||||
openTrack(0, format, primary, AVMEDIA_TYPE_AUDIO, tracks[1].index);
|
||||
} else {
|
||||
if (!tracks[1].codec)
|
||||
throw std::runtime_error("The selected video stream contains no playable video");
|
||||
auto *audio = openNetworkInput(1, command.audioURL, command.audioHeaders);
|
||||
openTrack(0, audio->format, audio, AVMEDIA_TYPE_AUDIO, -1);
|
||||
if (!tracks[0].codec)
|
||||
throw std::runtime_error("The selected audio stream contains no playable audio");
|
||||
}
|
||||
origin = std::isfinite(first) ? first : 0;
|
||||
} else {
|
||||
std::error_code filesystemError;
|
||||
if (!std::filesystem::is_regular_file(command.path, filesystemError))
|
||||
throw std::runtime_error(filesystemError ? filesystemError.message() :
|
||||
"Open requires a local regular media file");
|
||||
const std::string localPath = std::filesystem::absolute(command.path).string();
|
||||
format = avformat_alloc_context();
|
||||
if (!format) throw std::bad_alloc();
|
||||
format->interrupt_callback = {interrupt, this};
|
||||
AVDictionary *options = nullptr;
|
||||
const int optionResult = av_dict_set(&options, "protocol_whitelist", "file", 0);
|
||||
if (optionResult < 0) {
|
||||
av_dict_free(&options);
|
||||
check(optionResult, "Restricting input to local files");
|
||||
}
|
||||
const int result = avformat_open_input(&format, localPath.c_str(), nullptr, &options);
|
||||
av_dict_free(&options);
|
||||
check(result, "Opening media");
|
||||
check(avformat_find_stream_info(format, nullptr), "Reading media information");
|
||||
openTrack(1, format, nullptr, AVMEDIA_TYPE_VIDEO, -1);
|
||||
openTrack(0, format, nullptr, AVMEDIA_TYPE_AUDIO, tracks[1].index);
|
||||
}
|
||||
if (format->duration != AV_NOPTS_VALUE && format->duration > 0)
|
||||
duration = static_cast<double>(format->duration) / AV_TIME_BASE;
|
||||
if (duration == 0) {
|
||||
if (!tracks[0].codec && !tracks[1].codec)
|
||||
throw std::runtime_error("The source contains no playable audio or video track");
|
||||
double first = std::numeric_limits<double>::infinity();
|
||||
double measuredDuration = 0;
|
||||
bool seekable = true;
|
||||
for (int source = 0; source < (inputs[1] ? 2 : 1); ++source) {
|
||||
auto *context = network ? inputs[source]->format : format;
|
||||
std::array<int, 2> indices{-1, -1};
|
||||
for (int slot = 0; slot < 2; ++slot)
|
||||
if (tracks[slot].codec && tracks[slot].format == context) indices[slot] = tracks[slot].index;
|
||||
for (unsigned i = 0; i < context->nb_streams; ++i)
|
||||
if (static_cast<int>(i) != indices[0] && static_cast<int>(i) != indices[1])
|
||||
context->streams[i]->discard = AVDISCARD_ALL;
|
||||
if (context->start_time != AV_NOPTS_VALUE) {
|
||||
first = std::min(first, static_cast<double>(context->start_time) / AV_TIME_BASE);
|
||||
} else {
|
||||
for (const auto &track : tracks) {
|
||||
if (!track.codec || track.format != context) continue;
|
||||
const auto *stream = track.stream();
|
||||
if (stream->start_time != AV_NOPTS_VALUE)
|
||||
first = std::min(first, stream->start_time * av_q2d(stream->time_base));
|
||||
}
|
||||
}
|
||||
if (context->duration != AV_NOPTS_VALUE && context->duration > 0)
|
||||
measuredDuration = std::max(measuredDuration, static_cast<double>(context->duration) / AV_TIME_BASE);
|
||||
// Manifest demuxers own their I/O; a nil top-level AVIOContext does not forbid time seeks.
|
||||
seekable = seekable && !(context->ctx_flags & AVFMTCTX_UNSEEKABLE) &&
|
||||
(context->pb ? (context->pb->seekable & AVIO_SEEKABLE_NORMAL) != 0 :
|
||||
(context->iformat->flags & AVFMT_NOFILE) != 0);
|
||||
}
|
||||
// One origin for both inputs: independently zeroing them would erase real A/V offsets.
|
||||
origin = std::isfinite(first) ? first : 0;
|
||||
duration = network && command.sourceDuration > 0 ? command.sourceDuration : measuredDuration;
|
||||
if (duration == 0 && !command.live) {
|
||||
for (const auto &track : tracks) {
|
||||
if (!track.codec) continue;
|
||||
const AVStream *stream = format->streams[track.index];
|
||||
const AVStream *stream = track.stream();
|
||||
if (stream->duration == AV_NOPTS_VALUE || stream->duration <= 0) continue;
|
||||
const double start = stream->start_time == AV_NOPTS_VALUE ? origin :
|
||||
stream->start_time * av_q2d(stream->time_base);
|
||||
duration = std::max(duration, start - origin + stream->duration * av_q2d(stream->time_base));
|
||||
}
|
||||
}
|
||||
canSeek = !command.live && duration > 0 && seekable;
|
||||
if (tracks[1].codec) {
|
||||
const AVRational rate = av_guess_frame_rate(format, format->streams[tracks[1].index], nullptr);
|
||||
const auto &track = tracks[1];
|
||||
const AVRational rate = av_guess_frame_rate(track.format, track.stream(), nullptr);
|
||||
videoFrameTime = rate.num > 0 && rate.den > 0 ? av_q2d(av_inv_q(rate)) : 1.0 / 25;
|
||||
}
|
||||
if (tracks[0].codec) {
|
||||
@@ -383,16 +552,25 @@ struct Player {
|
||||
if (!output.configure_audio(deviceError))
|
||||
throw std::runtime_error(deviceError.empty() ? "Cannot configure audio output" : deviceError);
|
||||
}
|
||||
packet = av_packet_alloc();
|
||||
if (!network) packet = av_packet_alloc();
|
||||
decoded = av_frame_alloc();
|
||||
if (!packet || !decoded) throw std::bad_alloc();
|
||||
if ((!network && !packet) || !decoded) throw std::bad_alloc();
|
||||
resetTimeline(0);
|
||||
if (network) {
|
||||
for (auto &input : inputs) {
|
||||
if (!input) continue;
|
||||
std::array<int, 2> indices{-1, -1};
|
||||
for (int slot = 0; slot < 2; ++slot)
|
||||
if (tracks[slot].input == input.get()) indices[slot] = tracks[slot].index;
|
||||
input->start(indices);
|
||||
}
|
||||
}
|
||||
status = PLAYER_PAUSED;
|
||||
publish();
|
||||
}
|
||||
|
||||
double clockPosition() const {
|
||||
if (status != PLAYER_PLAYING) return position;
|
||||
if (status != PLAYER_PLAYING || buffering) return position;
|
||||
if (tracks[0].codec) {
|
||||
const double audioClock = output.clock();
|
||||
return std::isfinite(audioClock) ? std::max(seekTarget, audioClock) : position;
|
||||
@@ -402,9 +580,13 @@ struct Player {
|
||||
|
||||
void start() {
|
||||
if (!format || status == PLAYER_ERROR || status == PLAYER_EMPTY) return;
|
||||
if (status == PLAYER_ENDED) seek(0, false);
|
||||
if (status == PLAYER_ENDED) {
|
||||
if (!canSeek) return;
|
||||
seek(0, false);
|
||||
}
|
||||
if (status == PLAYER_PLAYING) return;
|
||||
if (frameStepping) {
|
||||
for (auto &input : inputs) if (input) input->discardAudio(false);
|
||||
VideoFrame selected = std::move(presented);
|
||||
const int64_t timestamp = presentationTimestamp(selected.frame.get());
|
||||
const uint64_t occurrence = selected.timestampOccurrence;
|
||||
@@ -416,7 +598,7 @@ struct Player {
|
||||
skipPresentedTimestamp = timestamp;
|
||||
skipPresentedOccurrence = occurrence;
|
||||
}
|
||||
if (tracks[0].codec) {
|
||||
if (!network && tracks[0].codec) {
|
||||
std::string deviceError;
|
||||
if (!output.start(deviceError))
|
||||
throw std::runtime_error(deviceError.empty() ? "Cannot start audio output" : deviceError);
|
||||
@@ -424,6 +606,7 @@ struct Player {
|
||||
steadyPosition = position;
|
||||
steadyAnchor = Clock::now();
|
||||
status = PLAYER_PLAYING;
|
||||
buffering = network;
|
||||
}
|
||||
|
||||
void pause() {
|
||||
@@ -435,29 +618,85 @@ struct Player {
|
||||
if (std::isfinite(frozen)) position = std::max(seekTarget, frozen);
|
||||
}
|
||||
status = PLAYER_PAUSED;
|
||||
buffering = false;
|
||||
}
|
||||
|
||||
double audioEnd() const {
|
||||
const auto &track = tracks[0];
|
||||
if (!track.codec) return NAN;
|
||||
const auto *stream = track.stream();
|
||||
if (stream->duration != AV_NOPTS_VALUE && stream->duration > 0) {
|
||||
const double start = stream->start_time == AV_NOPTS_VALUE ? origin :
|
||||
stream->start_time * av_q2d(stream->time_base);
|
||||
return start - origin + stream->duration * av_q2d(stream->time_base);
|
||||
}
|
||||
const auto *context = track.format;
|
||||
if (context->duration == AV_NOPTS_VALUE || context->duration <= 0) return NAN;
|
||||
const double start = context->start_time == AV_NOPTS_VALUE ? origin :
|
||||
static_cast<double>(context->start_time) / AV_TIME_BASE;
|
||||
return start - origin + static_cast<double>(context->duration) / AV_TIME_BASE;
|
||||
}
|
||||
|
||||
bool beyondAudio(double target) const {
|
||||
const double end = audioEnd();
|
||||
return std::isfinite(end) && target + sampleTime * 0.5 >= end;
|
||||
}
|
||||
|
||||
void markAudioTail(double target) {
|
||||
if (!network || !beyondAudio(target)) return;
|
||||
tracks[0].sourceEnded = true;
|
||||
if (tracks[0].input == tracks[1].input && tracks[0].input)
|
||||
tracks[0].input->discardAudio(true);
|
||||
}
|
||||
|
||||
void seek(double seconds, bool resume) {
|
||||
if (!format) return;
|
||||
if (!format || !canSeek) return;
|
||||
const double target = duration > 0 ? std::clamp(seconds, 0.0, duration) : std::max(0.0, seconds);
|
||||
const long double absolute = std::round((static_cast<long double>(target) + origin) * AV_TIME_BASE);
|
||||
if (absolute < -0x1p63L || absolute >= 0x1p63L)
|
||||
throw std::runtime_error("Seek target is outside the supported timestamp range");
|
||||
output.pause();
|
||||
const int64_t timestamp = static_cast<int64_t>(absolute);
|
||||
check(avformat_seek_file(format, -1, std::numeric_limits<int64_t>::min(),
|
||||
timestamp, timestamp, 0), "Seeking media");
|
||||
if (network) {
|
||||
for (auto &input : inputs) {
|
||||
if (!input) continue;
|
||||
input->discardAudio(false);
|
||||
if (input.get() == tracks[0].input && input.get() != tracks[1].input && beyondAudio(target))
|
||||
input->finish();
|
||||
else
|
||||
input->seek(-1, timestamp);
|
||||
}
|
||||
} else {
|
||||
check(avformat_seek_file(format, -1, std::numeric_limits<int64_t>::min(),
|
||||
timestamp, timestamp, 0), "Seeking media");
|
||||
}
|
||||
resetTimeline(target);
|
||||
markAudioTail(target);
|
||||
buffering = network;
|
||||
status = PLAYER_PAUSED;
|
||||
if (resume) start();
|
||||
}
|
||||
|
||||
void seekVideo(int64_t timestamp, double target) {
|
||||
output.pause();
|
||||
check(av_seek_frame(format, tracks[1].index, timestamp, AVSEEK_FLAG_BACKWARD),
|
||||
"Seeking video keyframe");
|
||||
if (network) {
|
||||
auto &videoTrack = tracks[1];
|
||||
videoTrack.input->seek(videoTrack.index, timestamp);
|
||||
if (tracks[0].input && tracks[0].input != videoTrack.input) {
|
||||
const long double audioTime = std::round((static_cast<long double>(target) + origin) * AV_TIME_BASE);
|
||||
if (audioTime < -0x1p63L || audioTime >= 0x1p63L)
|
||||
throw std::runtime_error("Audio seek target is outside the supported timestamp range");
|
||||
if (beyondAudio(target)) tracks[0].input->finish();
|
||||
else tracks[0].input->seek(-1, static_cast<int64_t>(audioTime));
|
||||
}
|
||||
} else {
|
||||
check(av_seek_frame(format, tracks[1].index, timestamp, AVSEEK_FLAG_BACKWARD),
|
||||
"Seeking video keyframe");
|
||||
}
|
||||
// Decoder preroll must not blank the last presented frame between steps.
|
||||
resetTimeline(target, false);
|
||||
markAudioTail(target);
|
||||
buffering = network;
|
||||
status = PLAYER_PAUSED;
|
||||
}
|
||||
|
||||
@@ -483,9 +722,11 @@ struct Player {
|
||||
}
|
||||
|
||||
void step(int direction) {
|
||||
if (!format || !tracks[1].codec) return;
|
||||
if (!format || !canSeek || !tracks[1].codec) return;
|
||||
pause();
|
||||
status = PLAYER_PAUSED;
|
||||
// Combined inputs must not block video stepping behind an unused audio packet queue.
|
||||
if (network && tracks[1].input) tracks[1].input->discardAudio(true);
|
||||
// A seek followed immediately by a queued step must first establish its preview.
|
||||
while (!presented.frame) {
|
||||
auto frame = nextSteppingFrame();
|
||||
@@ -505,7 +746,7 @@ struct Player {
|
||||
const uint64_t referenceOccurrence = selected.timestampOccurrence;
|
||||
if (reference == AV_NOPTS_VALUE)
|
||||
throw std::runtime_error("Frame stepping requires video presentation timestamps");
|
||||
const AVStream *stream = format->streams[tracks[1].index];
|
||||
const AVStream *stream = tracks[1].stream();
|
||||
const long double ticksPerSecond = 1.0L / av_q2d(stream->time_base);
|
||||
const long double originTimestamp = std::clamp(std::floor(origin * ticksPerSecond),
|
||||
static_cast<long double>(std::numeric_limits<int64_t>::min() + 1),
|
||||
@@ -559,6 +800,7 @@ struct Player {
|
||||
position = selected.pts;
|
||||
steppingAnchor = target;
|
||||
frameStepping = true;
|
||||
buffering = false;
|
||||
presentVideo(std::move(selected));
|
||||
}
|
||||
|
||||
@@ -566,7 +808,7 @@ struct Player {
|
||||
if (command.epoch != requestedEpoch.load(std::memory_order_relaxed)) return;
|
||||
activeEpoch = command.epoch;
|
||||
switch (command.action) {
|
||||
case Action::Open: open(command.path); break;
|
||||
case Action::Open: open(command); break;
|
||||
case Action::Play: start(); break;
|
||||
case Action::Pause: pause(); break;
|
||||
case Action::Seek: seek(command.seconds, status == PLAYER_PLAYING); break;
|
||||
@@ -578,8 +820,11 @@ struct Player {
|
||||
|
||||
double framePts(Track &track, const AVFrame *frame) {
|
||||
const int64_t timestamp = presentationTimestamp(frame);
|
||||
if (timestamp == AV_NOPTS_VALUE) return track.nextPts;
|
||||
return timestamp * av_q2d(format->streams[track.index]->time_base) - origin;
|
||||
if (timestamp == AV_NOPTS_VALUE) {
|
||||
if (network) throw std::runtime_error("The network stream has no media presentation timestamps");
|
||||
return track.nextPts;
|
||||
}
|
||||
return timestamp * av_q2d(track.stream()->time_base) - origin;
|
||||
}
|
||||
|
||||
void convertAudio() {
|
||||
@@ -604,7 +849,7 @@ struct Player {
|
||||
throw std::runtime_error("Audio format changes within a track are not supported");
|
||||
}
|
||||
const double timestamp = framePts(track, decoded);
|
||||
const double tick = av_q2d(format->streams[track.index]->time_base);
|
||||
const double tick = av_q2d(track.stream()->time_base);
|
||||
const bool contiguous = continuing && std::abs(timestamp - track.nextPts) <= tick + 1.0 / rate;
|
||||
// Quantized packet PTS and rounded resampler delay must not insert/drop PCM samples.
|
||||
const int64_t delayBase = static_cast<int64_t>(rate) * sampleRate;
|
||||
@@ -701,7 +946,7 @@ struct Player {
|
||||
throw std::runtime_error("Frame stepping requires video presentation timestamps");
|
||||
const double pts = framePts(track, decoded);
|
||||
const double frameDuration = decoded->duration > 0 ?
|
||||
decoded->duration * av_q2d(format->streams[track.index]->time_base) : videoFrameTime;
|
||||
decoded->duration * av_q2d(track.stream()->time_base) : videoFrameTime;
|
||||
track.nextPts = pts + frameDuration;
|
||||
if (decoded->flags & AV_FRAME_FLAG_DISCARD) return;
|
||||
if (timestamp != track.countedTimestamp) {
|
||||
@@ -725,8 +970,9 @@ struct Player {
|
||||
if (bytes > videoByteLimit)
|
||||
throw std::runtime_error("A decoded video frame exceeds the 128 MiB buffer limit");
|
||||
videoEnd = std::max(videoEnd, pts + frameDuration);
|
||||
// ponytail: extreme interleaving can starve audio behind a full video queue.
|
||||
// Keep nearest frames plus the newest; packet spooling is the upgrade if that matters.
|
||||
if (network && videoBytes + bytes > videoByteLimit)
|
||||
throw std::runtime_error("Decoded video exceeds the 128 MiB buffer limit");
|
||||
// Local, extremely interleaved files retain nearest frames when audio needs to catch up.
|
||||
while (!video.empty() && (video.size() >= videoFrameLimit || videoBytes + bytes > videoByteLimit)) {
|
||||
videoBytes -= video.back().bytes;
|
||||
video.pop_back();
|
||||
@@ -741,72 +987,86 @@ struct Player {
|
||||
}
|
||||
|
||||
bool decodeOne(bool stepping = false) {
|
||||
if (!stepping && pcmOffset < pcmFrames) return false;
|
||||
if (receiving >= 0) {
|
||||
if (stepping && receiving == 0) {
|
||||
receiving = -1;
|
||||
return true;
|
||||
}
|
||||
auto &track = tracks[receiving];
|
||||
if (receiving == 1 && videoFull() &&
|
||||
(!tracks[0].codec || output.queued() >= 1024 || status != PLAYER_PLAYING))
|
||||
return false;
|
||||
const int result = avcodec_receive_frame(track.codec, decoded);
|
||||
if (result == AVERROR(EAGAIN)) {
|
||||
receiving = -1;
|
||||
return true;
|
||||
}
|
||||
if (result == AVERROR_EOF) {
|
||||
track.drained = true;
|
||||
receiving = -1;
|
||||
return true;
|
||||
}
|
||||
check(result, "Decoding media");
|
||||
if (receiving == 0) convertAudio(); else queueVideo(stepping);
|
||||
av_frame_unref(decoded);
|
||||
return true;
|
||||
}
|
||||
if (packetPending) {
|
||||
if (stepping && packet->stream_index == tracks[0].index) {
|
||||
av_packet_unref(packet);
|
||||
packetPending = false;
|
||||
return true;
|
||||
}
|
||||
const int slot = packet->stream_index == tracks[0].index ? 0 : 1;
|
||||
const int result = avcodec_send_packet(tracks[slot].codec, packet);
|
||||
if (result == AVERROR(EAGAIN)) {
|
||||
receiving = slot;
|
||||
return true;
|
||||
}
|
||||
check(result, "Sending media packet to decoder");
|
||||
av_packet_unref(packet);
|
||||
packetPending = false;
|
||||
receiving = slot;
|
||||
return true;
|
||||
}
|
||||
if (demuxEnded) {
|
||||
for (int slot = 0; slot < 2; ++slot) {
|
||||
auto &track = tracks[slot];
|
||||
if (!track.codec || track.drainSent || (stepping && slot == 0)) continue;
|
||||
const int result = avcodec_send_packet(track.codec, nullptr);
|
||||
for (const auto &input : inputs)
|
||||
if (input) check(input->error(), "Reading network media");
|
||||
for (int offset = 0; offset < 2; ++offset) {
|
||||
const int slot = (nextDecodeSlot + offset) % 2;
|
||||
auto &track = tracks[slot];
|
||||
if (!track.codec || track.drained || (stepping && slot == 0)) continue;
|
||||
if (slot == 0 && pcmOffset < pcmFrames) continue;
|
||||
if (slot == 1 && videoFull() &&
|
||||
(network || !tracks[0].codec || output.queued() >= 1024 || status != PLAYER_PLAYING)) continue;
|
||||
if (track.receiving) {
|
||||
const int result = avcodec_receive_frame(track.codec, decoded);
|
||||
if (result == AVERROR(EAGAIN)) {
|
||||
receiving = slot;
|
||||
track.receiving = false;
|
||||
} else if (result == AVERROR_EOF) {
|
||||
track.drained = true;
|
||||
track.receiving = false;
|
||||
} else {
|
||||
check(result, "Decoding media");
|
||||
if (slot == 0) convertAudio(); else queueVideo(stepping);
|
||||
av_frame_unref(decoded);
|
||||
}
|
||||
nextDecodeSlot = 1 - slot;
|
||||
return true;
|
||||
}
|
||||
if (track.packetPending) {
|
||||
const int result = avcodec_send_packet(track.codec, track.packet);
|
||||
if (result != AVERROR(EAGAIN)) {
|
||||
check(result, "Sending media packet to decoder");
|
||||
av_packet_unref(track.packet);
|
||||
track.packetPending = false;
|
||||
}
|
||||
track.receiving = true;
|
||||
nextDecodeSlot = 1 - slot;
|
||||
return true;
|
||||
}
|
||||
if (track.input && !track.sourceEnded) {
|
||||
const int result = track.input->take(slot, track.packet);
|
||||
if (result == AVERROR_EOF) track.sourceEnded = true;
|
||||
else if (result == 1) track.packetPending = true;
|
||||
else check(result, "Reading network media packet");
|
||||
if (result != 0) {
|
||||
nextDecodeSlot = 1 - slot;
|
||||
return true;
|
||||
}
|
||||
if (result != AVERROR_EOF) check(result, "Draining decoder");
|
||||
track.drainSent = true;
|
||||
receiving = slot;
|
||||
}
|
||||
if (track.sourceEnded && !track.drainSent) {
|
||||
const int result = avcodec_send_packet(track.codec, nullptr);
|
||||
if (result != AVERROR(EAGAIN)) {
|
||||
if (result != AVERROR_EOF) check(result, "Draining decoder");
|
||||
track.drainSent = true;
|
||||
}
|
||||
track.receiving = true;
|
||||
nextDecodeSlot = 1 - slot;
|
||||
return true;
|
||||
}
|
||||
if (!stepping && (!tracks[0].codec || tracks[0].drained) && !resamplerDrained) {
|
||||
drainResampler();
|
||||
return true;
|
||||
}
|
||||
if (!stepping && pcmOffset >= pcmFrames &&
|
||||
(!tracks[0].codec || tracks[0].drained) && !resamplerDrained) {
|
||||
drainResampler();
|
||||
return true;
|
||||
}
|
||||
if (network || demuxEnded) return false;
|
||||
// The local path keeps one reused demux packet and no reader or packet-queue allocation.
|
||||
if (packetPending) {
|
||||
const int slot = packet->stream_index == tracks[0].index ? 0 : 1;
|
||||
if (stepping && slot == 0) {
|
||||
av_packet_unref(packet);
|
||||
} else {
|
||||
auto &track = tracks[slot];
|
||||
if (track.packetPending) return false;
|
||||
av_packet_move_ref(track.packet, packet);
|
||||
track.packetPending = true;
|
||||
}
|
||||
return false;
|
||||
packetPending = false;
|
||||
return true;
|
||||
}
|
||||
const int result = av_read_frame(format, packet);
|
||||
if (result == AVERROR_EOF) {
|
||||
demuxEnded = true;
|
||||
for (auto &track : tracks) track.sourceEnded = true;
|
||||
return true;
|
||||
}
|
||||
if (result == AVERROR(EAGAIN)) return false;
|
||||
@@ -830,9 +1090,54 @@ struct Player {
|
||||
if (latest.frame) presentVideo(std::move(latest));
|
||||
}
|
||||
|
||||
bool audioDrained() const {
|
||||
return (!tracks[0].codec || tracks[0].drained) && resamplerDrained && pcmOffset >= pcmFrames;
|
||||
}
|
||||
|
||||
void updateBuffering() {
|
||||
if (!network || status != PLAYER_PLAYING) return;
|
||||
const bool seekPending = networkSeeksPending();
|
||||
const bool videoRemaining = tracks[1].codec &&
|
||||
(!tracks[1].drained || audioWriteEnd + sampleTime < videoEnd);
|
||||
const bool audioEmpty = tracks[0].codec && output.queued() == 0 &&
|
||||
(!audioDrained() || videoRemaining);
|
||||
const bool videoEmpty = tracks[1].codec && !tracks[1].drained && video.empty() &&
|
||||
(!presented.frame || position + 0.002 >= presented.end);
|
||||
if (!buffering && (seekPending || audioEmpty || videoEmpty)) {
|
||||
output.pause();
|
||||
if (tracks[0].codec) {
|
||||
const double frozen = output.clock();
|
||||
if (std::isfinite(frozen)) position = std::max(seekTarget, frozen);
|
||||
} else {
|
||||
position = std::min(position, std::max(seekTarget, videoEnd));
|
||||
}
|
||||
buffering = true;
|
||||
}
|
||||
const bool audioReady = !tracks[0].codec || output.queued() >= 4800 ||
|
||||
(audioDrained() && output.queued() > 0) ||
|
||||
(audioDrained() && !videoRemaining);
|
||||
const bool videoReady = !tracks[1].codec || tracks[1].drained || !video.empty() ||
|
||||
(presented.frame && presented.end > position + 0.002);
|
||||
if (buffering && !seekPending && audioReady && videoReady) {
|
||||
if (tracks[0].codec) {
|
||||
std::string deviceError;
|
||||
if (!output.start(deviceError))
|
||||
throw std::runtime_error(deviceError.empty() ? "Cannot start audio output" : deviceError);
|
||||
}
|
||||
steadyPosition = position;
|
||||
steadyAnchor = Clock::now();
|
||||
buffering = false;
|
||||
}
|
||||
}
|
||||
|
||||
bool tick() {
|
||||
if (!format || interrupted() || status == PLAYER_ENDED || status == PLAYER_ERROR) return false;
|
||||
if (frameStepping) return false;
|
||||
for (const auto &input : inputs)
|
||||
if (input) check(input->error(), "Reading network media");
|
||||
if (frameStepping) {
|
||||
if (network) publish(); // Reader seek completion must still acknowledge a paused step.
|
||||
return false;
|
||||
}
|
||||
bool progressed = pumpAudio();
|
||||
if (status == PLAYER_PAUSED) {
|
||||
if (!previewShown && !video.empty()) {
|
||||
@@ -842,25 +1147,31 @@ struct Player {
|
||||
presentVideo(std::move(frame));
|
||||
progressed = true;
|
||||
}
|
||||
const bool needPreview = tracks[1].codec && !previewShown;
|
||||
const bool needAudio = tracks[0].codec && output.queued() < 4800;
|
||||
const bool needPreview = tracks[1].codec && !previewShown && !tracks[1].drained;
|
||||
const bool needAudio = tracks[0].codec && output.queued() < 4800 && !audioDrained();
|
||||
if (needPreview || needAudio) progressed = decodeOne() || progressed;
|
||||
if (network) {
|
||||
buffering = networkSeeksPending() || needPreview ||
|
||||
(needAudio && output.queued() == 0);
|
||||
publish();
|
||||
}
|
||||
return progressed;
|
||||
}
|
||||
position = clockPosition();
|
||||
renderVideo(position);
|
||||
progressed = decodeOne() || progressed;
|
||||
if (demuxEnded && (!tracks[0].codec || tracks[0].drained) &&
|
||||
resamplerDrained && pcmOffset >= pcmFrames && tracks[0].codec)
|
||||
// Audio ending first is not a global demux EOF: keep the audio-master clock through video's tail.
|
||||
if (tracks[0].codec && audioDrained())
|
||||
progressed = writeSilence(videoEnd) || progressed;
|
||||
const bool drained = demuxEnded && resamplerDrained && pcmOffset >= pcmFrames &&
|
||||
(!tracks[0].codec || tracks[0].drained) && (!tracks[1].codec || tracks[1].drained);
|
||||
updateBuffering();
|
||||
const bool drained = audioDrained() && (!tracks[1].codec || tracks[1].drained);
|
||||
if (drained && video.empty()) {
|
||||
const double end = std::max(audioWriteEnd, videoEnd);
|
||||
if (position + sampleTime >= end && (!tracks[0].codec || output.queued() == 0)) {
|
||||
output.pause();
|
||||
position = end;
|
||||
if (duration <= 0) duration = end;
|
||||
buffering = false;
|
||||
status = PLAYER_ENDED;
|
||||
}
|
||||
}
|
||||
@@ -946,6 +1257,22 @@ bool player_open(Player *player, const char *path) {
|
||||
if (!path || !*path) return false;
|
||||
return submit(player, [=] { return player->enqueue({Action::Open, path}); });
|
||||
}
|
||||
bool player_open_network(Player *player, const PlayerNetworkSource *source) {
|
||||
if (!source || !std::isfinite(source->duration) || source->duration < 0 ||
|
||||
!validNetworkURL(source->video_url) ||
|
||||
(source->audio_url && *source->audio_url && !validNetworkURL(source->audio_url)) ||
|
||||
!validHeaders(source->video_headers) || !validHeaders(source->audio_headers)) return false;
|
||||
return submit(player, [=] {
|
||||
Command command{Action::Open, source->video_url};
|
||||
command.audioURL = source->audio_url ? source->audio_url : "";
|
||||
command.videoHeaders = source->video_headers ? source->video_headers : "";
|
||||
command.audioHeaders = source->audio_headers ? source->audio_headers : "";
|
||||
command.sourceDuration = source->duration;
|
||||
command.network = true;
|
||||
command.live = source->is_live;
|
||||
return player->enqueue(std::move(command));
|
||||
});
|
||||
}
|
||||
bool player_play(Player *player) {
|
||||
return submit(player, [=] { return player->enqueue({Action::Play, {}}); });
|
||||
}
|
||||
|
||||
@@ -17,6 +17,10 @@ typedef struct PlayerState {
|
||||
double duration;
|
||||
bool has_audio;
|
||||
bool has_video;
|
||||
// False for live/non-seekable inputs; reflects both inputs for separate streams.
|
||||
bool can_seek;
|
||||
// Network playback is waiting for media; the authoritative clock remains stalled.
|
||||
bool buffering;
|
||||
// True until all accepted seeks/frame steps on this timeline finish.
|
||||
bool seek_pending;
|
||||
// Display width / height, including sample aspect ratio; zero without video.
|
||||
@@ -28,7 +32,19 @@ typedef struct PlayerState {
|
||||
// Playback commands are asynchronous. False means invalid input or a full command queue.
|
||||
// Paths are copied before returning. All other calls require a live handle.
|
||||
Player *player_create(void);
|
||||
typedef struct PlayerNetworkSource {
|
||||
// Primary video or combined audio/video HTTP(S) URL; audio_url is optional.
|
||||
const char *video_url;
|
||||
const char *audio_url;
|
||||
// Optional HTTP headers encoded as "Name: value\r\n"; copied with the URLs.
|
||||
const char *video_headers;
|
||||
const char *audio_headers;
|
||||
double duration;
|
||||
bool is_live;
|
||||
} PlayerNetworkSource;
|
||||
bool player_open(Player *player, const char *path);
|
||||
// Streams directly; never downloads or muxes the entire media file before opening.
|
||||
bool player_open_network(Player *player, const PlayerNetworkSource *source);
|
||||
bool player_play(Player *player);
|
||||
bool player_pause(Player *player);
|
||||
bool player_seek(Player *player, double seconds);
|
||||
|
||||
Reference in New Issue
Block a user