feat: add Chromecast playback support
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
struct CastDeviceMenu: View {
|
||||
@ObservedObject var model: PlayerModel
|
||||
@ObservedObject private var discovery: CastDiscovery
|
||||
var icon = true
|
||||
|
||||
init(model: PlayerModel, icon: Bool = true) {
|
||||
self.model = model
|
||||
self.discovery = model.castDiscovery
|
||||
self.icon = icon
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Menu {
|
||||
if let device = model.castDevice {
|
||||
Text((model.castConnecting ? "Connecting to " : "Casting to ") + device.name)
|
||||
Button(model.castConnecting ? "Cancel Casting" : "Play on This Mac") { model.stopCasting() }
|
||||
Divider()
|
||||
}
|
||||
ForEach(discovery.devices) { device in
|
||||
Button(device.name) { model.cast(to: device) }
|
||||
.disabled(!model.canCast)
|
||||
}
|
||||
if discovery.devices.isEmpty {
|
||||
Text("No Cast devices discovered")
|
||||
}
|
||||
if let error = discovery.error { Text(error) }
|
||||
Divider()
|
||||
Button("Find Cast Devices…") { discovery.start() }
|
||||
} label: {
|
||||
if icon {
|
||||
Image(systemName: "tv.badge.wifi")
|
||||
.font(.system(size: 14))
|
||||
.frame(width: 24, height: 24)
|
||||
} else {
|
||||
Text("Cast to Device")
|
||||
}
|
||||
}
|
||||
.menuStyle(.button)
|
||||
.buttonStyle(.plain)
|
||||
.menuIndicator(.hidden)
|
||||
.fixedSize()
|
||||
.accessibilityLabel("Cast to Device")
|
||||
.help(model.castDevice.map { "Casting to " + $0.name } ?? "Cast to Device")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import Foundation
|
||||
|
||||
struct CastMediaInput: Sendable {
|
||||
let videoURL: String
|
||||
let videoHeaders: String
|
||||
let audioURL: String?
|
||||
let audioHeaders: String?
|
||||
let local: Bool
|
||||
let restricted: Bool
|
||||
let live: Bool
|
||||
let duration: Double
|
||||
let subtitles: [ResolvedSubtitle]
|
||||
}
|
||||
|
||||
struct CastMediaDescription: Sendable {
|
||||
let url: URL
|
||||
let contentType: String
|
||||
let duration: Double
|
||||
let live: Bool
|
||||
let tracks: [CastTextTrack]
|
||||
}
|
||||
|
||||
struct CastTextTrack: Sendable {
|
||||
let id: Int
|
||||
let url: URL
|
||||
let language: String
|
||||
let label: String
|
||||
}
|
||||
|
||||
private struct CastDeliveryError: LocalizedError {
|
||||
let errorDescription: String? = "Could not deliver cast media. Check the connection and media compatibility. Private details are redacted."
|
||||
}
|
||||
|
||||
// A start task retains this owner until native probing returns. Cancellation never
|
||||
// frees a handle in use; destruction and worker joins always run off the main actor.
|
||||
private final class CastMediaLifetime: @unchecked Sendable {
|
||||
let handle: OpaquePointer
|
||||
init(input: CastMediaInput) throws {
|
||||
var error = [CChar](repeating: 0, count: 512)
|
||||
let result = input.videoURL.withCString { video in
|
||||
input.videoHeaders.withCString { headers in
|
||||
(input.audioURL ?? "").withCString { audio in
|
||||
(input.audioHeaders ?? "").withCString { audioHeaders in
|
||||
var source = CastMediaSource(video_url: video, video_headers: headers,
|
||||
audio_url: audio, audio_headers: audioHeaders, local: input.local,
|
||||
restricted: input.restricted, live: input.live, duration: input.duration)
|
||||
return cast_media_create(&source, &error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
guard let result else { throw CastDeliveryError() }
|
||||
handle = result
|
||||
}
|
||||
func cancel() { cast_media_cancel(handle) }
|
||||
func start(host: String) throws -> (String, Double) {
|
||||
var url = [CChar](repeating: 0, count: 512)
|
||||
var error = [CChar](repeating: 0, count: 512)
|
||||
var duration = 0.0
|
||||
guard host.withCString({ cast_media_start(handle, $0, &url, &duration, &error) }) else {
|
||||
throw CastDeliveryError()
|
||||
}
|
||||
return (url.withUnsafeBufferPointer { String(cString: $0.baseAddress!) }, duration)
|
||||
}
|
||||
func subtitle(id: Int, data: Data) throws -> URL {
|
||||
var url = [CChar](repeating: 0, count: 512)
|
||||
let success = data.withUnsafeBytes {
|
||||
cast_media_subtitle(handle, Int32(id), $0.baseAddress, $0.count, &url)
|
||||
}
|
||||
guard success, let result = URL(string: url.withUnsafeBufferPointer { String(cString: $0.baseAddress!) }) else { throw CastDeliveryError() }
|
||||
return result
|
||||
}
|
||||
deinit {
|
||||
// Capture an explicitly Sendable owner instead of crossing an actor with a pointer.
|
||||
let cleanup = CastMediaCleanup(handle: handle)
|
||||
DispatchQueue.global(qos: .utility).async { cast_media_destroy(cleanup.handle) }
|
||||
}
|
||||
}
|
||||
private struct CastMediaCleanup: @unchecked Sendable { let handle: OpaquePointer }
|
||||
|
||||
@MainActor final class CastMediaServer {
|
||||
private var lifetime: CastMediaLifetime?
|
||||
private var operation: Task<CastMediaDescription, Error>?
|
||||
|
||||
init() {}
|
||||
|
||||
func start(input: CastMediaInput, receiverHost: String) async throws -> CastMediaDescription {
|
||||
stop()
|
||||
guard input.subtitles.count <= 32 else { throw CastDeliveryError() }
|
||||
let owner = try CastMediaLifetime(input: input)
|
||||
lifetime = owner
|
||||
let task = Task.detached(priority: .userInitiated) {
|
||||
do {
|
||||
let (text, duration) = try owner.start(host: receiverHost)
|
||||
try Task.checkCancellation()
|
||||
guard let url = URL(string: text) else { throw CastDeliveryError() }
|
||||
var tracks: [CastTextTrack] = []
|
||||
for (index, subtitle) in input.subtitles.enumerated() {
|
||||
try Task.checkCancellation()
|
||||
let data = try await WebVTTDocument.loadData(url: subtitle.url, headers: subtitle.headers)
|
||||
guard let text = String(data: data, encoding: .utf8) else { throw CastDeliveryError() }
|
||||
_ = try WebVTTDocument.parse(text)
|
||||
let trackURL = try owner.subtitle(id: index + 1, data: data)
|
||||
tracks.append(CastTextTrack(id: index + 1, url: trackURL,
|
||||
language: subtitle.language, label: subtitle.label))
|
||||
}
|
||||
try Task.checkCancellation()
|
||||
return CastMediaDescription(url: url, contentType: "application/vnd.apple.mpegurl",
|
||||
duration: duration, live: input.live, tracks: tracks)
|
||||
} catch {
|
||||
owner.cancel()
|
||||
if Task.isCancelled { throw CancellationError() }
|
||||
throw CastDeliveryError()
|
||||
}
|
||||
}
|
||||
operation = task
|
||||
do {
|
||||
let result = try await withTaskCancellationHandler {
|
||||
try await task.value
|
||||
} onCancel: {
|
||||
owner.cancel()
|
||||
task.cancel()
|
||||
}
|
||||
guard lifetime === owner else { throw CancellationError() }
|
||||
operation = nil
|
||||
return result
|
||||
} catch {
|
||||
if lifetime === owner { stop() }
|
||||
if error is CancellationError { throw error }
|
||||
throw CastDeliveryError()
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
operation?.cancel()
|
||||
operation = nil
|
||||
lifetime?.cancel()
|
||||
lifetime = nil
|
||||
}
|
||||
|
||||
deinit {
|
||||
operation?.cancel()
|
||||
lifetime?.cancel()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,806 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
@preconcurrency import Network
|
||||
import Security
|
||||
|
||||
struct CastDevice: Identifiable, Equatable, Sendable {
|
||||
let id: String
|
||||
let name: String
|
||||
let host: String
|
||||
let port: UInt16
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class CastDiscovery: NSObject, ObservableObject, @preconcurrency NetServiceBrowserDelegate,
|
||||
@preconcurrency NetServiceDelegate {
|
||||
@Published private(set) var devices: [CastDevice] = []
|
||||
@Published private(set) var error: String?
|
||||
private var browser: NetServiceBrowser?
|
||||
private var services: [String: NetService] = [:]
|
||||
private var resolved: [String: CastDevice] = [:]
|
||||
|
||||
func start() {
|
||||
if error != nil { stop() }
|
||||
guard browser == nil else { return }
|
||||
error = nil
|
||||
let browser = NetServiceBrowser()
|
||||
self.browser = browser
|
||||
browser.delegate = self
|
||||
browser.searchForServices(ofType: "_googlecast._tcp.", inDomain: "local.")
|
||||
}
|
||||
|
||||
func stop() {
|
||||
browser?.stop()
|
||||
browser?.delegate = nil
|
||||
browser = nil
|
||||
for service in services.values { service.stop(); service.delegate = nil }
|
||||
services.removeAll()
|
||||
resolved.removeAll()
|
||||
devices.removeAll()
|
||||
}
|
||||
|
||||
private func key(_ service: NetService) -> String { service.domain + service.type + service.name }
|
||||
private func publish() {
|
||||
// Multiple advertised interfaces may resolve to the same receiver ID.
|
||||
var unique: [String: CastDevice] = [:]
|
||||
for device in resolved.values { unique[device.id] = device }
|
||||
devices = unique.values.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending }
|
||||
}
|
||||
|
||||
func netServiceBrowser(_ browser: NetServiceBrowser, didFind service: NetService, moreComing: Bool) {
|
||||
guard browser === self.browser, services.count < 128 else { return }
|
||||
let key = self.key(service)
|
||||
guard services[key] == nil else { return }
|
||||
services[key] = service
|
||||
service.delegate = self
|
||||
service.resolve(withTimeout: 5)
|
||||
}
|
||||
|
||||
func netServiceBrowser(_ browser: NetServiceBrowser, didRemove service: NetService, moreComing: Bool) {
|
||||
guard browser === self.browser else { return }
|
||||
let key = self.key(service)
|
||||
services.removeValue(forKey: key)?.stop()
|
||||
resolved.removeValue(forKey: key)
|
||||
publish()
|
||||
}
|
||||
|
||||
func netServiceBrowser(_ browser: NetServiceBrowser, didNotSearch errorDict: [String: NSNumber]) {
|
||||
guard browser === self.browser else { return }
|
||||
error = "Cast discovery failed. Check Local Network permission and your Wi-Fi connection."
|
||||
}
|
||||
|
||||
func netServiceDidResolveAddress(_ sender: NetService) {
|
||||
let key = self.key(sender)
|
||||
guard services[key] === sender, let host = sender.hostName,
|
||||
sender.port > 0, sender.port <= Int(UInt16.max) else { return }
|
||||
let txt = sender.txtRecordData().map(NetService.dictionary(fromTXTRecord:)) ?? [:]
|
||||
func text(_ key: String) -> String? {
|
||||
txt[key].flatMap { String(data: $0, encoding: .utf8) }.flatMap { $0.isEmpty ? nil : $0 }
|
||||
}
|
||||
resolved[key] = CastDevice(id: text("id") ?? key, name: text("fn") ?? sender.name,
|
||||
host: host, port: UInt16(sender.port))
|
||||
publish()
|
||||
}
|
||||
|
||||
func netService(_ sender: NetService, didNotResolve errorDict: [String: NSNumber]) {
|
||||
let key = self.key(sender)
|
||||
guard services[key] === sender else { return }
|
||||
services.removeValue(forKey: key)?.stop()
|
||||
}
|
||||
}
|
||||
|
||||
struct CastStatus: Sendable {
|
||||
let position: Double
|
||||
let duration: Double
|
||||
let playing: Bool
|
||||
let buffering: Bool
|
||||
let ended: Bool
|
||||
let volume: Double
|
||||
let seekable: Bool
|
||||
let subtitle: Int?
|
||||
let seekPending: Bool
|
||||
let playbackPending: Bool
|
||||
}
|
||||
|
||||
enum CastError: Error, LocalizedError, Sendable {
|
||||
case protocolError, disconnected, cancelled, timeout(String), rejected(String), invalidMedia, queueFull
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .protocolError: "The Cast receiver sent an invalid message."
|
||||
case .disconnected: "The Cast receiver disconnected."
|
||||
case .cancelled: "The Cast operation was cancelled."
|
||||
case .timeout(let operation): "The Cast receiver did not acknowledge \(operation) in time."
|
||||
case .rejected(let operation): "The Cast receiver rejected \(operation)."
|
||||
case .invalidMedia: "The media or subtitle selection cannot be cast."
|
||||
case .queueFull: "Too many Cast commands are waiting for a response."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cast v2's original (unchunked) envelope, from Open Screen's cast_channel.proto:
|
||||
// https://chromium.googlesource.com/openscreen/+/refs/heads/main/cast/common/channel/proto/cast_channel.proto
|
||||
// Media transport messages are limited to 64 KiB by https://developers.google.com/cast/docs/media/messages.
|
||||
enum CastWire {
|
||||
static let maximumFrame = 64 * 1024
|
||||
static let connection = "urn:x-cast:com.google.cast.tp.connection"
|
||||
static let heartbeat = "urn:x-cast:com.google.cast.tp.heartbeat"
|
||||
static let receiver = "urn:x-cast:com.google.cast.receiver"
|
||||
static let media = "urn:x-cast:com.google.cast.media"
|
||||
|
||||
struct Message: Sendable {
|
||||
let source: String
|
||||
let destination: String
|
||||
let namespace: String
|
||||
let payload: Data
|
||||
}
|
||||
|
||||
static func varint(_ value: UInt64, into data: inout Data) {
|
||||
var value = value
|
||||
repeat {
|
||||
let byte = UInt8(value & 127)
|
||||
value >>= 7
|
||||
data.append(byte | (value == 0 ? 0 : 128))
|
||||
} while value != 0
|
||||
}
|
||||
|
||||
static func frame(_ message: Message) throws -> Data {
|
||||
var body = Data([8, 0]) // protocol_version CASTV2_1_0
|
||||
for (field, text) in [(2, message.source), (3, message.destination), (4, message.namespace)] {
|
||||
let bytes = Data(text.utf8)
|
||||
varint(UInt64(field << 3 | 2), into: &body)
|
||||
varint(UInt64(bytes.count), into: &body)
|
||||
body.append(bytes)
|
||||
}
|
||||
body.append(contentsOf: [40, 0, 50]) // payload_type STRING; payload_utf8
|
||||
varint(UInt64(message.payload.count), into: &body)
|
||||
body.append(message.payload)
|
||||
guard body.count <= maximumFrame else { throw CastError.protocolError }
|
||||
let count = UInt32(body.count)
|
||||
var frame = Data([UInt8(count >> 24), UInt8((count >> 16) & 255),
|
||||
UInt8((count >> 8) & 255), UInt8(count & 255)])
|
||||
frame.append(body)
|
||||
return frame
|
||||
}
|
||||
|
||||
static func decode(_ data: Data) throws -> Message {
|
||||
guard !data.isEmpty, data.count <= maximumFrame else { throw CastError.protocolError }
|
||||
var offset = 0
|
||||
func integer() throws -> UInt64 {
|
||||
var value: UInt64 = 0
|
||||
for shift in stride(from: 0, through: 63, by: 7) {
|
||||
guard offset < data.count else { throw CastError.protocolError }
|
||||
let byte = data[data.startIndex + offset]
|
||||
offset += 1
|
||||
guard shift != 63 || byte <= 1 else { throw CastError.protocolError }
|
||||
value |= UInt64(byte & 127) << shift
|
||||
if byte & 128 == 0 { return value }
|
||||
}
|
||||
throw CastError.protocolError
|
||||
}
|
||||
var numbers: [Int: UInt64] = [:]
|
||||
var bytes: [Int: Data] = [:]
|
||||
while offset < data.count {
|
||||
let key = try integer()
|
||||
guard key >> 3 > 0, key >> 3 <= UInt64(Int32.max) else { throw CastError.protocolError }
|
||||
let field = Int(key >> 3)
|
||||
switch key & 7 {
|
||||
case 0:
|
||||
let value = try integer()
|
||||
if field == 1 || field == 5 || field == 8 { numbers[field] = value }
|
||||
case 1, 5:
|
||||
let size = key & 7 == 1 ? 8 : 4
|
||||
guard size <= data.count - offset else { throw CastError.protocolError }
|
||||
offset += size
|
||||
case 2:
|
||||
let size = try integer()
|
||||
guard size <= UInt64(data.count - offset) else { throw CastError.protocolError }
|
||||
let end = offset + Int(size)
|
||||
if [2, 3, 4, 6].contains(field) {
|
||||
bytes[field] = data.subdata(in: (data.startIndex + offset)..<(data.startIndex + end))
|
||||
}
|
||||
offset = end
|
||||
default: throw CastError.protocolError
|
||||
}
|
||||
}
|
||||
guard numbers[1] == 0, numbers[5] == 0, numbers[8, default: 0] == 0,
|
||||
let sourceData = bytes[2], let source = String(data: sourceData, encoding: .utf8), !source.isEmpty,
|
||||
let destinationData = bytes[3], let destination = String(data: destinationData, encoding: .utf8), !destination.isEmpty,
|
||||
let namespaceData = bytes[4], let namespace = String(data: namespaceData, encoding: .utf8), !namespace.isEmpty,
|
||||
let payload = bytes[6], String(data: payload, encoding: .utf8) != nil else {
|
||||
throw CastError.protocolError
|
||||
}
|
||||
return Message(source: source, destination: destination, namespace: namespace, payload: payload)
|
||||
}
|
||||
|
||||
struct Stream {
|
||||
private var buffer = Data()
|
||||
mutating func append(_ data: Data) throws -> [Message] {
|
||||
// Network reads are <= 16 KiB. Bound incomplete input even before its length is known.
|
||||
guard data.count <= maximumFrame, buffer.count + data.count <= maximumFrame * 2 + 4 else {
|
||||
throw CastError.protocolError
|
||||
}
|
||||
buffer.append(data)
|
||||
var messages: [Message] = []
|
||||
while buffer.count >= 4 {
|
||||
let i = buffer.startIndex
|
||||
let length = buffer[i..<i + 4].reduce(0) { ($0 << 8) | Int($1) }
|
||||
guard length > 0, length <= maximumFrame else { throw CastError.protocolError }
|
||||
guard buffer.count >= length + 4 else { break }
|
||||
messages.append(try decode(buffer.subdata(in: (i + 4)..<(i + 4 + length))))
|
||||
buffer.removeFirst(length + 4)
|
||||
}
|
||||
return messages
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class CastSession {
|
||||
var onStatus: ((CastStatus) -> Void)?
|
||||
var onDisconnect: ((String?) -> Void)?
|
||||
var onError: ((String) -> Void)?
|
||||
var localHost: String? {
|
||||
guard let endpoint = connection?.currentPath?.localEndpoint,
|
||||
case .hostPort(let host, _) = endpoint else { return nil }
|
||||
return String(describing: host)
|
||||
}
|
||||
|
||||
private struct Pending {
|
||||
let namespace: String
|
||||
let destination: String
|
||||
let operation: String
|
||||
let generation: UUID?
|
||||
let completion: ([String: Any]?, CastError?) -> Void
|
||||
let deadline: Task<Void, Never>
|
||||
}
|
||||
|
||||
private let makeConnection: (CastDevice) -> NWConnection
|
||||
private let requestTimeout: Duration
|
||||
private let loadTimeout: Duration
|
||||
private let heartbeatInterval: Duration
|
||||
private let sleep: @MainActor (Duration) async throws -> Void
|
||||
private var connection: NWConnection?
|
||||
private var connectionID = UUID()
|
||||
private let source = "terebi-" + UUID().uuidString
|
||||
private var stream = CastWire.Stream()
|
||||
private var ready: CheckedContinuation<Void, any Error>?
|
||||
private var readyDeadline: Task<Void, Never>?
|
||||
private var heartbeatTask: Task<Void, Never>?
|
||||
private var awaitingPong = false
|
||||
private var nextRequest = 0
|
||||
private var pending: [Int: Pending] = [:]
|
||||
private var writes: [Data] = []
|
||||
private var writing = false
|
||||
private var appTransport: String?
|
||||
private var appSession: String?
|
||||
private var mediaSession: Int?
|
||||
private var retiredSessions: Set<Int> = []
|
||||
private var retiredLoads: [String] = []
|
||||
private var contentID: String?
|
||||
private var mediaGeneration: UUID?
|
||||
private var tracks: [CastTextTrack] = []
|
||||
private var latestStatus: CastStatus?
|
||||
private var pendingSeek: Int?
|
||||
private var pendingPlayback: Int?
|
||||
var playbackPending: Bool { pendingPlayback != nil || replayTask != nil || loading }
|
||||
private var lastStatusPoll = 0
|
||||
private var newestMediaReply = 0
|
||||
private var loading = false
|
||||
private var loadedMedia: CastMediaDescription?
|
||||
private var loadedTitle = ""
|
||||
private var replayTask: Task<Void, Never>?
|
||||
private var replayDesiredPlaying = true
|
||||
private var receiverVolume = 1.0
|
||||
|
||||
init(requestTimeout: Duration = .seconds(8), loadTimeout: Duration = .seconds(25),
|
||||
heartbeatInterval: Duration = .seconds(5),
|
||||
sleep: @escaping @MainActor (Duration) async throws -> Void = { try await Task.sleep(for: $0) }) {
|
||||
makeConnection = { device in
|
||||
let tls = NWProtocolTLS.Options()
|
||||
sec_protocol_options_set_min_tls_protocol_version(tls.securityProtocolOptions, .TLSv12)
|
||||
// Cast receivers use self-signed TLS certificates, not public Web PKI. This is
|
||||
// encrypted local-network transport to the user's selected Bonjour endpoint,
|
||||
// NOT authenticated receiver identity. mDNS and this channel are LAN-trust;
|
||||
// never claim standard CA/hostname validation or send upstream credentials.
|
||||
// Cast device-auth (Google device roots) is intentionally not impersonated.
|
||||
sec_protocol_options_set_verify_block(tls.securityProtocolOptions, { @Sendable _, _, complete in
|
||||
complete(true)
|
||||
}, DispatchQueue.global(qos: .userInitiated))
|
||||
let parameters = NWParameters(tls: tls, tcp: NWProtocolTCP.Options())
|
||||
return NWConnection(host: NWEndpoint.Host(device.host),
|
||||
port: NWEndpoint.Port(rawValue: device.port)!, using: parameters)
|
||||
}
|
||||
self.requestTimeout = requestTimeout
|
||||
self.loadTimeout = loadTimeout
|
||||
self.heartbeatInterval = heartbeatInterval
|
||||
self.sleep = sleep
|
||||
}
|
||||
|
||||
|
||||
func connect(to device: CastDevice) async throws {
|
||||
disconnect(stopReceiver: false)
|
||||
guard device.port > 0, !device.host.isEmpty else { throw CastError.disconnected }
|
||||
let id = UUID()
|
||||
connectionID = id
|
||||
let socket = makeConnection(device)
|
||||
connection = socket
|
||||
stream = CastWire.Stream()
|
||||
try await withTaskCancellationHandler {
|
||||
try Task.checkCancellation()
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
ready = continuation
|
||||
readyDeadline = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
do { try await self.sleep(.seconds(12)) } catch { return }
|
||||
guard self.connectionID == id else { return }
|
||||
self.fail(.timeout("connection"))
|
||||
}
|
||||
socket.stateUpdateHandler = { [weak self] state in
|
||||
Task { @MainActor in
|
||||
guard let self, self.connectionID == id else { return }
|
||||
switch state {
|
||||
case .ready:
|
||||
self.receive(socket, id: id)
|
||||
self.beginHandshake()
|
||||
case .failed, .cancelled: self.fail(.disconnected)
|
||||
default: break
|
||||
}
|
||||
}
|
||||
}
|
||||
socket.start(queue: DispatchQueue.global(qos: .userInitiated))
|
||||
}
|
||||
} onCancel: {
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self, self.connectionID == id else { return }
|
||||
self.fail(.cancelled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func beginHandshake() {
|
||||
do {
|
||||
try send(["type": "CONNECT", "origin": [:], "userAgent": "Terebi"],
|
||||
namespace: CastWire.connection, destination: "receiver-0")
|
||||
startHeartbeat()
|
||||
try request(["type": "LAUNCH", "appId": "CC1AD845"], namespace: CastWire.receiver,
|
||||
destination: "receiver-0", operation: "receiver launch") { [weak self] json, error in
|
||||
guard let self else { return }
|
||||
if let error { self.fail(error); return }
|
||||
guard let status = json?["status"] as? [String: Any],
|
||||
let apps = status["applications"] as? [[String: Any]],
|
||||
let app = apps.first(where: { $0["appId"] as? String == "CC1AD845" }),
|
||||
let transport = app["transportId"] as? String, !transport.isEmpty,
|
||||
let session = app["sessionId"] as? String, !session.isEmpty else {
|
||||
self.fail(.rejected("receiver launch")); return
|
||||
}
|
||||
self.appTransport = transport
|
||||
self.appSession = session
|
||||
do {
|
||||
try self.send(["type": "CONNECT", "origin": [:]],
|
||||
namespace: CastWire.connection, destination: transport)
|
||||
} catch { self.fail(.protocolError); return }
|
||||
self.readyDeadline?.cancel()
|
||||
self.readyDeadline = nil
|
||||
let ready = self.ready
|
||||
self.ready = nil
|
||||
ready?.resume()
|
||||
}
|
||||
} catch { fail(error as? CastError ?? .protocolError) }
|
||||
}
|
||||
|
||||
func load(_ media: CastMediaDescription, title: String, position: Double,
|
||||
playing: Bool, subtitle: Int?) async throws {
|
||||
guard let transport = appTransport, connection != nil else { throw CastError.disconnected }
|
||||
guard position.isFinite, position >= 0, media.duration.isFinite,
|
||||
["http", "https"].contains(media.url.scheme?.lowercased() ?? ""),
|
||||
media.url.absoluteString.utf8.count <= 1024,
|
||||
subtitle == nil || media.tracks.indices.contains(subtitle!), media.tracks.count <= 128 else {
|
||||
throw CastError.invalidMedia
|
||||
}
|
||||
cancelMediaRequests()
|
||||
if let mediaSession {
|
||||
if retiredSessions.count >= 16 { retiredSessions.removeAll() }
|
||||
retiredSessions.insert(mediaSession)
|
||||
}
|
||||
if let mediaGeneration {
|
||||
if retiredLoads.count == 64 { retiredLoads.removeFirst() }
|
||||
retiredLoads.append(mediaGeneration.uuidString)
|
||||
}
|
||||
mediaSession = nil
|
||||
latestStatus = nil
|
||||
pendingSeek = nil
|
||||
pendingPlayback = nil
|
||||
newestMediaReply = 0
|
||||
let generation = UUID()
|
||||
mediaGeneration = generation
|
||||
contentID = media.url.absoluteString
|
||||
tracks = media.tracks
|
||||
loadedMedia = media
|
||||
loadedTitle = title
|
||||
loading = true
|
||||
var info: [String: Any] = [
|
||||
"contentId": media.url.absoluteString, "contentType": media.contentType,
|
||||
"streamType": media.live ? "LIVE" : "BUFFERED",
|
||||
"metadata": ["metadataType": 0, "title": String(title.prefix(1024))],
|
||||
"customData": ["terebiSession": generation.uuidString]
|
||||
]
|
||||
if media.duration > 0, !media.live { info["duration"] = media.duration }
|
||||
if !media.tracks.isEmpty {
|
||||
info["tracks"] = media.tracks.map { track -> [String: Any] in
|
||||
["trackId": track.id, "type": "TEXT", "subtype": "SUBTITLES",
|
||||
"trackContentId": track.url.absoluteString, "trackContentType": "text/vtt",
|
||||
"name": track.label, "language": track.language]
|
||||
}
|
||||
}
|
||||
var command: [String: Any] = ["type": "LOAD", "media": info, "autoplay": playing,
|
||||
"activeTrackIds": subtitle.map { [media.tracks[$0].id] } ?? []]
|
||||
if position > 0 || !media.live { command["currentTime"] = position }
|
||||
let requestID = allocateRequest()
|
||||
try await withTaskCancellationHandler {
|
||||
try Task.checkCancellation()
|
||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, any Error>) in
|
||||
do {
|
||||
try request(command, namespace: CastWire.media, destination: transport,
|
||||
operation: "media load", generation: generation, timeout: loadTimeout,
|
||||
id: requestID) { [weak self] _, error in
|
||||
if let self, self.mediaGeneration == generation { self.loading = false; self.publishSeekState() }
|
||||
if let error { continuation.resume(throwing: error) }
|
||||
else { continuation.resume() }
|
||||
}
|
||||
} catch { loading = false; continuation.resume(throwing: error) }
|
||||
}
|
||||
} onCancel: {
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self, self.mediaGeneration == generation else { return }
|
||||
// A cancelled LOAD may already be on the receiver. Tear down the channel
|
||||
// rather than letting its late acknowledgement acquire ownership.
|
||||
self.fail(.cancelled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func setPlaying(_ playing: Bool) -> Bool {
|
||||
if replayTask != nil { replayDesiredPlaying = playing; return true }
|
||||
guard playing, latestStatus?.ended == true, let loadedMedia else {
|
||||
return control(["type": playing ? "PLAY" : "PAUSE"])
|
||||
}
|
||||
let subtitle = latestStatus?.subtitle
|
||||
let title = loadedTitle
|
||||
replayDesiredPlaying = true
|
||||
let connectionID = self.connectionID
|
||||
replayTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
do {
|
||||
try await self.load(loadedMedia, title: title, position: 0, playing: true, subtitle: subtitle)
|
||||
guard self.connectionID == connectionID else { return }
|
||||
self.replayTask = nil
|
||||
self.publishSeekState()
|
||||
if !self.replayDesiredPlaying { _ = self.setPlaying(false) }
|
||||
} catch {
|
||||
guard self.connectionID == connectionID else { return }
|
||||
self.replayTask = nil
|
||||
self.publishSeekState()
|
||||
if self.connection != nil { self.fail(error as? CastError ?? .disconnected) }
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
func seek(_ seconds: Double) -> Bool {
|
||||
guard seconds.isFinite, seconds >= 0, latestStatus?.seekable == true else { return false }
|
||||
return control(["type": "SEEK", "currentTime": seconds], seeking: true)
|
||||
}
|
||||
func setVolume(_ volume: Double) -> Bool {
|
||||
guard volume.isFinite, (0...1).contains(volume), appTransport != nil else { return false }
|
||||
return control(["type": "SET_VOLUME", "volume": ["level": volume, "muted": volume == 0]], receiver: true)
|
||||
}
|
||||
func setSubtitle(_ index: Int?) -> Bool {
|
||||
guard index == nil || tracks.indices.contains(index!) else { return false }
|
||||
return control(["type": "EDIT_TRACKS_INFO", "activeTrackIds": index.map { [tracks[$0].id] } ?? []])
|
||||
}
|
||||
|
||||
private func control(_ command: [String: Any], seeking: Bool = false, receiver: Bool = false) -> Bool {
|
||||
guard !loading, let transport = appTransport, let mediaSession else { return false }
|
||||
var command = command
|
||||
if !receiver { command["mediaSessionId"] = mediaSession }
|
||||
let id = allocateRequest()
|
||||
let type = command["type"] as? String
|
||||
let changingPlayback = type == "PLAY" || type == "PAUSE"
|
||||
do {
|
||||
try request(command, namespace: receiver ? CastWire.receiver : CastWire.media,
|
||||
destination: receiver ? "receiver-0" : transport,
|
||||
operation: (command["type"] as? String ?? "command").lowercased(),
|
||||
generation: mediaGeneration, id: id) { [weak self] _, error in
|
||||
guard let self else { return }
|
||||
if case .cancelled? = error { return }
|
||||
if self.pendingSeek == id { self.pendingSeek = nil; self.publishSeekState() }
|
||||
if self.pendingPlayback == id { self.pendingPlayback = nil; self.publishSeekState() }
|
||||
if let error { self.onError?(error.localizedDescription) }
|
||||
}
|
||||
if seeking { pendingSeek = id; publishSeekState() }
|
||||
if changingPlayback { pendingPlayback = id; publishSeekState() }
|
||||
return true
|
||||
} catch { onError?(error.localizedDescription); return false }
|
||||
}
|
||||
|
||||
private func allocateRequest() -> Int { nextRequest += 1; return nextRequest }
|
||||
|
||||
@discardableResult
|
||||
private func request(_ command: [String: Any], namespace: String, destination: String,
|
||||
operation: String, generation: UUID? = nil, timeout: Duration? = nil,
|
||||
id suppliedID: Int? = nil,
|
||||
completion: @escaping ([String: Any]?, CastError?) -> Void) throws -> Int {
|
||||
guard pending.count < 32 else { throw CastError.queueFull }
|
||||
let id = suppliedID ?? allocateRequest()
|
||||
var command = command
|
||||
command["requestId"] = id
|
||||
let sleep = self.sleep
|
||||
let duration = timeout ?? requestTimeout
|
||||
let deadline = Task { [weak self] in
|
||||
do { try await sleep(duration) } catch { return }
|
||||
self?.complete(id, json: nil, error: .timeout(operation))
|
||||
}
|
||||
pending[id] = Pending(namespace: namespace, destination: destination, operation: operation,
|
||||
generation: generation, completion: completion, deadline: deadline)
|
||||
do { try send(command, namespace: namespace, destination: destination) }
|
||||
catch { pending.removeValue(forKey: id)?.deadline.cancel(); throw error }
|
||||
return id
|
||||
}
|
||||
|
||||
private func complete(_ id: Int, json: [String: Any]?, error: CastError?) {
|
||||
guard let item = pending.removeValue(forKey: id) else { return }
|
||||
item.deadline.cancel()
|
||||
item.completion(json, error)
|
||||
}
|
||||
|
||||
private func cancelMediaRequests() {
|
||||
let ids = pending.filter { $0.value.generation != nil }.map(\.key)
|
||||
for id in ids { complete(id, json: nil, error: .cancelled) }
|
||||
}
|
||||
|
||||
private func send(_ json: [String: Any], namespace: String, destination: String) throws {
|
||||
guard connection != nil else { throw CastError.disconnected }
|
||||
guard writes.count < 32 else { throw CastError.queueFull }
|
||||
let payload = try JSONSerialization.data(withJSONObject: json)
|
||||
writes.append(try CastWire.frame(.init(source: source, destination: destination,
|
||||
namespace: namespace, payload: payload)))
|
||||
flush()
|
||||
}
|
||||
|
||||
private func flush() {
|
||||
guard !writing, let socket = connection, !writes.isEmpty else { return }
|
||||
writing = true
|
||||
let data = writes.removeFirst()
|
||||
let id = connectionID
|
||||
socket.send(content: data, completion: .contentProcessed { [weak self] error in
|
||||
Task { @MainActor in
|
||||
guard let self, self.connectionID == id else { return }
|
||||
self.writing = false
|
||||
if error != nil { self.fail(.disconnected) } else { self.flush() }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private func receive(_ socket: NWConnection, id: UUID) {
|
||||
socket.receive(minimumIncompleteLength: 1, maximumLength: 16 * 1024) { [weak self] data, _, ended, error in
|
||||
Task { @MainActor in
|
||||
guard let self, self.connectionID == id else { return }
|
||||
do {
|
||||
if let data {
|
||||
for message in try self.stream.append(data) {
|
||||
self.handle(message)
|
||||
guard self.connectionID == id else { return }
|
||||
}
|
||||
}
|
||||
if ended || error != nil { self.fail(.disconnected) }
|
||||
else { self.receive(socket, id: id) }
|
||||
} catch { self.fail(.protocolError) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handle(_ message: CastWire.Message) {
|
||||
guard message.destination == source || message.destination == "*",
|
||||
let json = try? JSONSerialization.jsonObject(with: message.payload) as? [String: Any],
|
||||
let type = json["type"] as? String else { return }
|
||||
if message.namespace == CastWire.heartbeat, message.source == "receiver-0" {
|
||||
if type == "PONG" { awaitingPong = false }
|
||||
if type == "PING" {
|
||||
do { try send(["type": "PONG"], namespace: CastWire.heartbeat, destination: "receiver-0") }
|
||||
catch { fail(.disconnected) }
|
||||
}
|
||||
return
|
||||
}
|
||||
if message.namespace == CastWire.connection, type == "CLOSE",
|
||||
message.source == appTransport || message.source == "receiver-0" {
|
||||
fail(.disconnected); return
|
||||
}
|
||||
let id = json["requestId"] as? Int
|
||||
let item = id.flatMap { pending[$0] }
|
||||
let correlated = item?.namespace == message.namespace && item?.destination == message.source &&
|
||||
(item?.generation == nil || item?.generation == mediaGeneration)
|
||||
if ["INVALID_REQUEST", "INVALID_PLAYER_STATE", "LOAD_FAILED", "LOAD_CANCELLED", "LAUNCH_ERROR"].contains(type) {
|
||||
if correlated, let id, let item { complete(id, json: nil, error: .rejected(item.operation)) }
|
||||
return
|
||||
}
|
||||
if message.namespace == CastWire.receiver, message.source == "receiver-0", type == "RECEIVER_STATUS" {
|
||||
if let status = json["status"] as? [String: Any] {
|
||||
if let volume = status["volume"] as? [String: Any],
|
||||
let level = volume["level"] as? Double, level.isFinite {
|
||||
let muted = volume["muted"] as? Bool ?? false
|
||||
receiverVolume = muted ? 0 : min(1, max(0, level))
|
||||
if let latestStatus { publish(latestStatus, volume: receiverVolume) }
|
||||
}
|
||||
if let appSession, let appTransport {
|
||||
let apps = status["applications"] as? [[String: Any]] ?? []
|
||||
guard apps.contains(where: { $0["sessionId"] as? String == appSession &&
|
||||
$0["transportId"] as? String == appTransport }) else {
|
||||
fail(.rejected("receiver ownership changed")); return
|
||||
}
|
||||
}
|
||||
}
|
||||
if correlated, let id { complete(id, json: json, error: nil) }
|
||||
return
|
||||
}
|
||||
guard message.namespace == CastWire.media, message.source == appTransport,
|
||||
type == "MEDIA_STATUS", let statuses = json["status"] as? [[String: Any]] else { return }
|
||||
let olderReply = correlated && id.map { $0 < newestMediaReply } == true
|
||||
if olderReply, id != pendingSeek, id != pendingPlayback, let id {
|
||||
complete(id, json: json, error: nil)
|
||||
return
|
||||
}
|
||||
for status in statuses {
|
||||
guard let session = status["mediaSessionId"] as? Int else { continue }
|
||||
let media = status["media"] as? [String: Any]
|
||||
let reportedContent = media?["contentId"] as? String
|
||||
let customData = media?["customData"] as? [String: Any]
|
||||
if let nonce = customData?["terebiSession"] as? String, nonce != mediaGeneration?.uuidString {
|
||||
if retiredLoads.contains(nonce) { continue }
|
||||
if !loading { fail(.rejected("media ownership changed")); return }
|
||||
continue
|
||||
}
|
||||
if mediaSession == nil {
|
||||
guard loading, correlated, item?.operation == "media load", reportedContent == contentID,
|
||||
!retiredSessions.contains(session) else { continue }
|
||||
mediaSession = session
|
||||
}
|
||||
guard session == mediaSession else {
|
||||
if !retiredSessions.contains(session), !loading {
|
||||
fail(.rejected("media ownership changed")); return
|
||||
}
|
||||
continue
|
||||
}
|
||||
if let reportedContent, reportedContent != contentID {
|
||||
fail(.rejected("media ownership changed")); return
|
||||
}
|
||||
let state = status["playerState"] as? String ?? "IDLE"
|
||||
guard ["IDLE", "PLAYING", "PAUSED", "BUFFERING"].contains(state) else {
|
||||
fail(.protocolError); return
|
||||
}
|
||||
let idle = status["idleReason"] as? String
|
||||
if state == "IDLE", idle == "ERROR" {
|
||||
if correlated, let id { complete(id, json: nil, error: .rejected("media playback")) }
|
||||
fail(.rejected("media playback")); return
|
||||
}
|
||||
if state == "IDLE", idle == "INTERRUPTED" || idle == "CANCELLED" {
|
||||
fail(.rejected("media playback stopped")); return
|
||||
}
|
||||
let receivedPosition = status["currentTime"] as? Double ?? latestStatus?.position ?? 0
|
||||
let position = olderReply && id != pendingSeek ? latestStatus?.position ?? receivedPosition : receivedPosition
|
||||
let duration = media?["duration"] as? Double ?? latestStatus?.duration ?? loadedMedia?.duration ?? 0
|
||||
guard position.isFinite, duration.isFinite else { fail(.protocolError); return }
|
||||
let supported = status["supportedMediaCommands"] as? Int
|
||||
let active = status["activeTrackIds"] as? [Int]
|
||||
let subtitle = active.map { ids in tracks.firstIndex { ids.contains($0.id) } } ?? latestStatus?.subtitle
|
||||
let live = media?["streamType"] as? String == "LIVE" || loadedMedia?.live == true
|
||||
// A late SEEK still owns its position acknowledgement, but not newer playback intent.
|
||||
let previous = olderReply && id != pendingPlayback ? latestStatus : nil
|
||||
// An old status/poll cannot clear a newer seek. Only that seek's reply does.
|
||||
if correlated, id == pendingSeek { pendingSeek = nil }
|
||||
if correlated, id == pendingPlayback { pendingPlayback = nil }
|
||||
let next = CastStatus(position: max(0, position), duration: max(0, duration),
|
||||
playing: previous?.playing ?? (state == "PLAYING" || state == "BUFFERING"),
|
||||
buffering: previous?.buffering ?? (state == "BUFFERING"), ended: previous?.ended ?? (state == "IDLE" && idle == "FINISHED"),
|
||||
volume: receiverVolume,
|
||||
seekable: !live && (supported.map { $0 & 2 != 0 } ?? latestStatus?.seekable ?? false),
|
||||
subtitle: subtitle, seekPending: pendingSeek != nil, playbackPending: playbackPending)
|
||||
latestStatus = next
|
||||
if correlated, let id { newestMediaReply = max(newestMediaReply, id) }
|
||||
onStatus?(next)
|
||||
if correlated, let id { complete(id, json: json, error: nil) }
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
private func publish(_ status: CastStatus, volume: Double? = nil) {
|
||||
let next = CastStatus(position: status.position, duration: status.duration, playing: status.playing,
|
||||
buffering: status.buffering, ended: status.ended, volume: volume ?? status.volume,
|
||||
seekable: status.seekable, subtitle: status.subtitle, seekPending: pendingSeek != nil,
|
||||
playbackPending: playbackPending)
|
||||
latestStatus = next
|
||||
onStatus?(next)
|
||||
}
|
||||
private func publishSeekState() { if let latestStatus { publish(latestStatus) } }
|
||||
|
||||
private func startHeartbeat() {
|
||||
heartbeatTask?.cancel()
|
||||
let id = connectionID
|
||||
heartbeatTask = Task { [weak self] in
|
||||
while !Task.isCancelled {
|
||||
guard let self, self.connectionID == id else { return }
|
||||
do { try await self.sleep(self.heartbeatInterval) } catch { return }
|
||||
guard self.connectionID == id else { return }
|
||||
if self.awaitingPong { self.fail(.timeout("heartbeat")); return }
|
||||
self.awaitingPong = true
|
||||
do {
|
||||
try self.send(["type": "PING"], namespace: CastWire.heartbeat, destination: "receiver-0")
|
||||
// Receivers do not reliably broadcast clock progress. Poll without
|
||||
// extrapolating receiver time, allowing only one outstanding poll.
|
||||
if !self.loading, let transport = self.appTransport, let session = self.mediaSession,
|
||||
self.pending[self.lastStatusPoll] == nil {
|
||||
self.lastStatusPoll = try self.request(["type": "GET_STATUS", "mediaSessionId": session],
|
||||
namespace: CastWire.media, destination: transport, operation: "media status",
|
||||
generation: self.mediaGeneration) { [weak self] _, error in
|
||||
if case .cancelled? = error { return }
|
||||
if let error { self?.onError?(error.localizedDescription) }
|
||||
}
|
||||
}
|
||||
} catch { self.fail(error as? CastError ?? .disconnected); return }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func disconnect(stopReceiver: Bool = true) { teardown(error: nil, stopReceiver: stopReceiver) }
|
||||
private func fail(_ error: CastError) { teardown(error: error, stopReceiver: false) }
|
||||
|
||||
private func teardown(error: CastError?, stopReceiver: Bool) {
|
||||
let socket = connection
|
||||
let connected = socket != nil
|
||||
var farewell = Data()
|
||||
if connected {
|
||||
func append(_ json: [String: Any], namespace: String, destination: String) {
|
||||
if let payload = try? JSONSerialization.data(withJSONObject: json),
|
||||
let frame = try? CastWire.frame(.init(source: source, destination: destination,
|
||||
namespace: namespace, payload: payload)) { farewell.append(frame) }
|
||||
}
|
||||
// Stop only the media session we acquired, never STOP the application: another
|
||||
// sender may be using the Default Media Receiver by the time we disconnect.
|
||||
if stopReceiver, let mediaSession, let appTransport, !loading {
|
||||
append(["type": "STOP", "mediaSessionId": mediaSession, "requestId": allocateRequest()],
|
||||
namespace: CastWire.media, destination: appTransport)
|
||||
}
|
||||
if let appTransport { append(["type": "CLOSE"], namespace: CastWire.connection, destination: appTransport) }
|
||||
append(["type": "CLOSE"], namespace: CastWire.connection, destination: "receiver-0")
|
||||
}
|
||||
connectionID = UUID()
|
||||
connection = nil
|
||||
socket?.stateUpdateHandler = nil
|
||||
readyDeadline?.cancel(); readyDeadline = nil
|
||||
heartbeatTask?.cancel(); heartbeatTask = nil
|
||||
replayTask?.cancel(); replayTask = nil
|
||||
awaitingPong = false
|
||||
let ready = self.ready
|
||||
self.ready = nil
|
||||
let requests = pending.values
|
||||
pending.removeAll()
|
||||
writes.removeAll(); writing = false
|
||||
appTransport = nil; appSession = nil; mediaSession = nil
|
||||
retiredSessions.removeAll(); contentID = nil; mediaGeneration = nil
|
||||
retiredLoads.removeAll()
|
||||
tracks.removeAll(); latestStatus = nil; pendingSeek = nil; loading = false
|
||||
pendingPlayback = nil
|
||||
loadedMedia = nil; loadedTitle = ""
|
||||
receiverVolume = 1
|
||||
ready?.resume(throwing: error ?? CastError.cancelled)
|
||||
for request in requests {
|
||||
request.deadline.cancel()
|
||||
request.completion(nil, error ?? .cancelled)
|
||||
}
|
||||
if let socket {
|
||||
if !farewell.isEmpty, error == nil {
|
||||
socket.send(content: farewell, completion: .contentProcessed { _ in socket.cancel() })
|
||||
DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) { socket.cancel() }
|
||||
} else { socket.cancel() }
|
||||
}
|
||||
if connected { onDisconnect?(error?.localizedDescription) }
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,13 @@ struct WebVTTDocument: Sendable {
|
||||
}
|
||||
|
||||
static func load(url: String, headers: String) async throws -> WebVTTDocument {
|
||||
let data = try await loadData(url: url, headers: headers)
|
||||
// Providers sometimes disguise VTT resources as images: trust the explicit format and content.
|
||||
guard let text = String(data: data, encoding: .utf8) else { throw SubtitleError.content }
|
||||
return try parse(text)
|
||||
}
|
||||
|
||||
static func loadData(url: String, headers: String) async throws -> Data {
|
||||
let request = try request(url: url, headers: headers)
|
||||
let configuration = URLSessionConfiguration.ephemeral
|
||||
configuration.timeoutIntervalForRequest = 15
|
||||
@@ -51,10 +58,7 @@ struct WebVTTDocument: Sendable {
|
||||
if Task.isCancelled { throw CancellationError() }
|
||||
throw SubtitleError.transport
|
||||
}
|
||||
// Providers sometimes disguise VTT resources as images: trust the explicit format and content,
|
||||
// never the path extension or Content-Type.
|
||||
guard let text = String(data: data, encoding: .utf8) else { throw SubtitleError.content }
|
||||
return try parse(text)
|
||||
return data
|
||||
}
|
||||
|
||||
static func parse(_ text: String) throws -> WebVTTDocument {
|
||||
|
||||
@@ -190,6 +190,8 @@ struct PlayerCommands: Commands {
|
||||
.disabled(focusedModel == nil)
|
||||
}
|
||||
CommandMenu("Playback") {
|
||||
CastDeviceMenu(model: model, icon: false)
|
||||
Divider()
|
||||
Button(model.effectivePlaying ? "Pause" : "Play") { model.performMenuAction(playback) }
|
||||
.keyboardShortcut(model.keyBindings.shortcuts(for: playback).first?.keyboardShortcut)
|
||||
.disabled(!hasFocusedScene || !model.canPerform(playback))
|
||||
|
||||
+264
-12
@@ -28,6 +28,19 @@ final class PlayerModel: ObservableObject {
|
||||
@Published private(set) var fileIcon: NSImage?
|
||||
@Published private(set) var commandError: String?
|
||||
private var transition = PlaybackTransition()
|
||||
let castDiscovery = CastDiscovery()
|
||||
@Published private(set) var castDevice: CastDevice?
|
||||
@Published private(set) var castConnecting = false
|
||||
private var castActive = false
|
||||
private var castSession: CastSession?
|
||||
private var castServer: CastMediaServer?
|
||||
private var castTask: Task<Void, Never>?
|
||||
private var castRequest: UUID?
|
||||
private var castResumePlaying = false
|
||||
private var castLocalVolume = 1.0
|
||||
private var castLocalAudibleVolume = 1.0
|
||||
private var castIntent: Bool?
|
||||
private var currentMedia: MediaSource?
|
||||
var pendingRevision: UInt64? { transition.pendingRevision }
|
||||
var resolvingYouTube: Bool { transition.isResolving }
|
||||
var isLiveStream: Bool { transition.isLive }
|
||||
@@ -56,7 +69,7 @@ final class PlayerModel: ObservableObject {
|
||||
guard !isBusy, !isLiveStream, fileName != nil, state.status != PLAYER_ERROR else { return nil }
|
||||
return compactProgressText(position: state.position, duration: state.duration, format: compactTitleProgress)
|
||||
}
|
||||
var playbackIntent: Bool? { transition.playbackIntent }
|
||||
var playbackIntent: Bool? { castActive ? castIntent : transition.playbackIntent }
|
||||
@Published private(set) var fullScreenIntent: Bool?
|
||||
@Published private(set) var compactIntent: Bool?
|
||||
var effectivePlaying: Bool { playbackIntent ?? (state.status == PLAYER_PLAYING) }
|
||||
@@ -149,19 +162,23 @@ final class PlayerModel: ObservableObject {
|
||||
systemPlayback = SystemPlaybackControls(model: self)
|
||||
}
|
||||
|
||||
var isBusy: Bool { transition.isBusy(state.status) }
|
||||
var isBusy: Bool { castConnecting || (!castActive && transition.isBusy(state.status)) }
|
||||
var canTogglePlayback: Bool {
|
||||
handle != nil && !isBusy &&
|
||||
(state.status == PLAYER_PAUSED || state.status == PLAYER_PLAYING || state.status == PLAYER_ENDED)
|
||||
}
|
||||
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 canStepFrame: Bool { castDevice == nil && canTogglePlayback && state.has_video && state.can_seek && !isSeeking }
|
||||
var canChangeYouTubeQuality: Bool {
|
||||
canTogglePlayback && !youtubeHeights.isEmpty && !isSeeking && !transition.seekPending && !state.seek_pending
|
||||
castDevice == nil && canTogglePlayback && !youtubeHeights.isEmpty && !isSeeking && !transition.seekPending && !state.seek_pending
|
||||
}
|
||||
var canOpen: Bool { handle != nil }
|
||||
var canClose: Bool { handle != nil && (isBusy || state.status != PLAYER_EMPTY || fileName != nil) }
|
||||
var canSetVolume: Bool { handle != nil && !isBusy && state.has_audio && state.status != PLAYER_ERROR }
|
||||
var canCast: Bool {
|
||||
castDevice == nil && currentMedia != nil && canTogglePlayback && !isSeeking && !state.seek_pending &&
|
||||
!transition.seekPending && transition.playbackIntent == nil && !castConnecting
|
||||
}
|
||||
var duration: Double { state.duration.isFinite ? max(0, state.duration) : 0 }
|
||||
var bufferedRange: ClosedRange<Double>? {
|
||||
guard fileName != nil, canSeek, !transition.seekPending, !state.seek_pending,
|
||||
@@ -180,6 +197,175 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
func cast(to device: CastDevice) {
|
||||
guard canCast, let source = currentMedia, let handle else { return }
|
||||
cancelYouTubeResolution()
|
||||
let request = UUID()
|
||||
let session = CastSession()
|
||||
let server = CastMediaServer()
|
||||
let localSnapshot = state
|
||||
let playing = effectivePlaying
|
||||
castResumePlaying = playing
|
||||
castLocalVolume = volume
|
||||
castLocalAudibleVolume = lastAudibleVolume
|
||||
castRequest = request
|
||||
castDevice = device
|
||||
castConnecting = true
|
||||
castSession = session
|
||||
castServer = server
|
||||
commandError = nil
|
||||
revealControls()
|
||||
var initialStatus: CastStatus?
|
||||
session.onStatus = { [weak self] status in
|
||||
guard let self, self.castRequest == request else { return }
|
||||
initialStatus = status
|
||||
if self.castActive { self.receiveCastStatus(status) }
|
||||
}
|
||||
session.onDisconnect = { [weak self] message in
|
||||
guard let self, self.castRequest == request else { return }
|
||||
self.stopCasting()
|
||||
self.commandError = message
|
||||
}
|
||||
session.onError = { [weak self] message in
|
||||
guard let self, self.castRequest == request else { return }
|
||||
if self.castSession?.playbackPending != true { self.castIntent = nil }
|
||||
self.commandError = message
|
||||
self.revealControls()
|
||||
}
|
||||
castTask = Task { [weak self] in
|
||||
do {
|
||||
try await session.connect(to: device)
|
||||
try Task.checkCancellation()
|
||||
guard player_pause(handle) else {
|
||||
throw NSError(domain: "TerebiCast", code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Could not pause local playback for casting."])
|
||||
}
|
||||
let deadline = ContinuousClock.now.advanced(by: .seconds(5))
|
||||
while player_get_state(handle).status == PLAYER_PLAYING {
|
||||
guard ContinuousClock.now < deadline else {
|
||||
throw NSError(domain: "TerebiCast", code: 2,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Local playback did not acknowledge the Cast handoff."])
|
||||
}
|
||||
try await Task.sleep(for: .milliseconds(20))
|
||||
}
|
||||
try Task.checkCancellation()
|
||||
guard let self, self.castRequest == request else { return }
|
||||
let snapshot = player_get_state(handle)
|
||||
guard snapshot.status == PLAYER_PAUSED || snapshot.status == PLAYER_ENDED else {
|
||||
throw NSError(domain: "TerebiCast", code: 3,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Local playback is not ready to cast."])
|
||||
}
|
||||
let position = snapshot.can_seek && !source.live && snapshot.status != PLAYER_ENDED ? snapshot.position : 0
|
||||
let media = try await server.start(input: self.castInput(source, duration: snapshot.duration),
|
||||
receiverHost: device.host)
|
||||
try Task.checkCancellation()
|
||||
try await session.load(media, title: self.fileName ?? "terebi", position: position,
|
||||
playing: playing, subtitle: self.selectedSubtitle)
|
||||
try Task.checkCancellation()
|
||||
guard self.castRequest == request else { return }
|
||||
self.castActive = true
|
||||
self.castConnecting = false
|
||||
self.transition.acceptCast()
|
||||
self.state = localSnapshot
|
||||
self.state.buffered_start = 0
|
||||
self.state.buffered_end = 0
|
||||
self.subtitleTask?.cancel()
|
||||
self.subtitleText = ""
|
||||
self.castTask = nil
|
||||
if let initialStatus { self.receiveCastStatus(initialStatus) }
|
||||
self.systemPlayback?.update()
|
||||
} catch {
|
||||
guard let self, self.castRequest == request else { return }
|
||||
self.stopCasting()
|
||||
if !(error is CancellationError) { self.commandError = error.localizedDescription }
|
||||
self.revealControls()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stopCasting(resume: Bool = true) {
|
||||
guard castDevice != nil else { return }
|
||||
saveProgress()
|
||||
let source = currentMedia
|
||||
let wasActive = castActive
|
||||
let position = state.can_seek && !isLiveStream && state.status != PLAYER_ENDED ? state.position : 0
|
||||
let playing = wasActive ? effectivePlaying : castResumePlaying
|
||||
if wasActive, let handle {
|
||||
transition.suspendHistory(revision: player_get_state(handle).revision)
|
||||
}
|
||||
castRequest = nil
|
||||
castTask?.cancel()
|
||||
castTask = nil
|
||||
castDevice = nil
|
||||
castConnecting = false
|
||||
castActive = false
|
||||
castIntent = nil
|
||||
let session = castSession
|
||||
castSession = nil
|
||||
session?.disconnect()
|
||||
castServer?.stop()
|
||||
castServer = nil
|
||||
volume = castLocalVolume
|
||||
lastAudibleVolume = castLocalAudibleVolume
|
||||
if let handle { _ = player_set_volume(handle, Float(volume)) }
|
||||
if resume, let source {
|
||||
if wasActive {
|
||||
openMedia(source, change: .restore(position: position, playing: playing))
|
||||
} else if let handle {
|
||||
_ = playing ? player_play(handle) : player_pause(handle)
|
||||
}
|
||||
}
|
||||
systemPlayback?.update()
|
||||
revealControls()
|
||||
}
|
||||
|
||||
private func castInput(_ source: MediaSource, duration: Double) -> CastMediaInput {
|
||||
switch source {
|
||||
case .file(let url):
|
||||
return CastMediaInput(videoURL: url.path, videoHeaders: "", audioURL: nil, audioHeaders: nil,
|
||||
local: true, restricted: false, live: false, duration: duration, subtitles: [])
|
||||
case .youtube(let video, _):
|
||||
return CastMediaInput(videoURL: video.video.url, videoHeaders: video.video.headers,
|
||||
audioURL: video.audio?.url, audioHeaders: video.audio?.headers,
|
||||
local: false, restricted: false, live: video.isLive,
|
||||
duration: duration, subtitles: [])
|
||||
case .resolved(let resolved):
|
||||
return CastMediaInput(videoURL: resolved.url, videoHeaders: resolved.headers,
|
||||
audioURL: nil, audioHeaders: nil, local: false, restricted: true,
|
||||
live: false, duration: duration, subtitles: resolved.subtitles)
|
||||
}
|
||||
}
|
||||
|
||||
private func receiveCastStatus(_ status: CastStatus) {
|
||||
guard castActive else { return }
|
||||
state.status = status.ended ? PLAYER_ENDED : (status.playing ? PLAYER_PLAYING : PLAYER_PAUSED)
|
||||
state.position = status.position
|
||||
state.duration = status.duration > 0 ? status.duration : state.duration
|
||||
state.buffering = status.buffering
|
||||
state.can_seek = status.seekable && !isLiveStream
|
||||
state.seek_pending = status.seekPending
|
||||
state.buffered_start = 0
|
||||
state.buffered_end = 0
|
||||
state.revision &+= 1
|
||||
volume = status.volume
|
||||
if volume > 0 { lastAudibleVolume = volume }
|
||||
selectedSubtitle = status.subtitle
|
||||
if !status.playbackPending { castIntent = nil }
|
||||
if !isSeeking && !status.seekPending { seekPosition = state.position }
|
||||
if let handle { _ = player_report_remote_state(handle, &state) }
|
||||
saveProgress()
|
||||
systemPlayback?.update()
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func seekCast(to position: Double) -> Bool {
|
||||
let accepted = castSession?.seek(position) == true
|
||||
if accepted { state.seek_pending = true; seekPosition = position }
|
||||
commandError = accepted ? nil : "Could not seek Cast playback."
|
||||
revealControls()
|
||||
return accepted
|
||||
}
|
||||
|
||||
func openPanel() {
|
||||
guard canOpen else { return }
|
||||
defer { window?.makeFirstResponder(nil) }
|
||||
@@ -236,6 +422,10 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
|
||||
func selectSubtitle(_ index: Int?) {
|
||||
if castActive {
|
||||
if castSession?.setSubtitle(index) != true { commandError = "Could not change Cast subtitles." }
|
||||
return
|
||||
}
|
||||
subtitleTask?.cancel()
|
||||
subtitleTask = nil
|
||||
subtitleDocument = nil
|
||||
@@ -258,6 +448,7 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
|
||||
private func updateSubtitle() {
|
||||
if castDevice != nil { subtitleText = ""; return }
|
||||
subtitleText = state.seek_pending || transition.seekPending || isBusy ||
|
||||
state.status == PLAYER_EMPTY || state.status == PLAYER_ERROR ? "" :
|
||||
subtitleDocument?.text(at: state.position) ?? ""
|
||||
@@ -292,6 +483,7 @@ final class PlayerModel: ObservableObject {
|
||||
|
||||
private func resolveYouTube(_ url: URL, height: Int?, change: MediaChange = .open) {
|
||||
guard let handle else { return }
|
||||
if castDevice != nil { stopCasting(resume: false) }
|
||||
cancelYouTubeResolution()
|
||||
if !change.automatic { automaticQuality = AutomaticQuality() }
|
||||
if !change.automatic && !player_pause(handle) {
|
||||
@@ -351,9 +543,10 @@ final class PlayerModel: ObservableObject {
|
||||
|
||||
private func openMedia(_ source: MediaSource, change: MediaChange = .open) {
|
||||
guard let handle else { return }
|
||||
if castDevice != nil { stopCasting(resume: false) }
|
||||
saveProgress()
|
||||
let revision = player_get_state(handle).revision
|
||||
let savedPosition = change.isQuality ? change.position :
|
||||
let savedPosition = change.preservesPosition ? change.position :
|
||||
(source.live ? nil : playbackProgress.position(for: source.url))
|
||||
let accepted: Bool
|
||||
switch source {
|
||||
@@ -407,6 +600,7 @@ final class PlayerModel: ObservableObject {
|
||||
return
|
||||
}
|
||||
transition.acceptOpen(revision: revision, source: source, change: change, savedPosition: savedPosition)
|
||||
currentMedia = source
|
||||
if change.automatic { automaticQuality.resetHealth() } else { automaticQuality = AutomaticQuality() }
|
||||
switch source {
|
||||
case .file(let url):
|
||||
@@ -462,6 +656,14 @@ final class PlayerModel: ObservableObject {
|
||||
|
||||
@discardableResult
|
||||
func setPlayback(playing: Bool) -> Bool {
|
||||
if castActive {
|
||||
guard canTogglePlayback else { return false }
|
||||
let accepted = castSession?.setPlaying(playing) == true
|
||||
if accepted { castIntent = playing; objectWillChange.send() }
|
||||
commandError = accepted ? nil : "Could not change Cast playback."
|
||||
revealControls()
|
||||
return accepted
|
||||
}
|
||||
guard let handle, canTogglePlayback else { return false }
|
||||
if !playing && transition.resolvingAutomaticQuality { cancelYouTubeResolution() }
|
||||
if !playing { automaticQuality.resetHealth() }
|
||||
@@ -484,6 +686,10 @@ final class PlayerModel: ObservableObject {
|
||||
automaticQuality.resetHealth()
|
||||
isSeeking = false
|
||||
guard let handle, canSeek else { return }
|
||||
if castActive {
|
||||
seekCast(to: min(max(0, seekPosition), duration))
|
||||
return
|
||||
}
|
||||
let position = min(max(0, seekPosition), duration)
|
||||
let accepted = transition.seek(position, handle: handle)
|
||||
commandError = accepted ? nil : "Could not queue the seek. Please try again."
|
||||
@@ -493,6 +699,7 @@ final class PlayerModel: ObservableObject {
|
||||
func closeFile() {
|
||||
guard let handle, canClose else { return }
|
||||
saveProgress()
|
||||
if castDevice != nil { stopCasting(resume: false) }
|
||||
cancelYouTubeResolution()
|
||||
automaticQuality = AutomaticQuality()
|
||||
guard player_close(handle) else {
|
||||
@@ -500,6 +707,7 @@ final class PlayerModel: ObservableObject {
|
||||
return
|
||||
}
|
||||
transition.closeMedia()
|
||||
currentMedia = nil
|
||||
selectSubtitle(nil)
|
||||
subtitleTracks = []
|
||||
compactIntent = nil
|
||||
@@ -524,8 +732,8 @@ final class PlayerModel: ObservableObject {
|
||||
guard canSeek, let handle, seconds.isFinite else { return false }
|
||||
if transition.resolvingAutomaticQuality { cancelYouTubeResolution() }
|
||||
automaticQuality.resetHealth()
|
||||
let target = min(max(0, (transition.seekPending ? seekPosition : state.position) + seconds), duration)
|
||||
let accepted = transition.seek(target, handle: handle)
|
||||
let target = min(max(0, ((transition.seekPending || state.seek_pending) ? seekPosition : state.position) + seconds), duration)
|
||||
let accepted = castActive ? seekCast(to: target) : transition.seek(target, handle: handle)
|
||||
if accepted { seekPosition = target }
|
||||
commandError = accepted ? nil : "Could not queue the seek. Please try again."
|
||||
if !accepted { revealControls() }
|
||||
@@ -542,6 +750,14 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
|
||||
func setVolume(_ value: Double) {
|
||||
if castActive {
|
||||
if value.isFinite, (0...1).contains(value), castSession?.setVolume(value) == true {
|
||||
volume = value
|
||||
if volume > 0 { lastAudibleVolume = volume }
|
||||
}
|
||||
revealControls()
|
||||
return
|
||||
}
|
||||
guard let handle else { return }
|
||||
if player_set_volume(handle, Float(value)) {
|
||||
volume = Double(player_get_volume(handle))
|
||||
@@ -757,7 +973,7 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
|
||||
private var shouldHideControls: Bool {
|
||||
((canTogglePlayback && fileName != nil && (state.has_video || !pointerInsideWindow)) ||
|
||||
castDevice == nil && ((canTogglePlayback && fileName != nil && (state.has_video || !pointerInsideWindow)) ||
|
||||
(!pointerInsideWindow && isBusy)) &&
|
||||
!isSeeking && !transition.seekPending && !controlsDragging &&
|
||||
(!pointerInsideWindow || (!controlsHovered && !titleHovered)) &&
|
||||
@@ -796,7 +1012,13 @@ final class PlayerModel: ObservableObject {
|
||||
|
||||
private func saveProgress(_ snapshot: PlayerState? = nil, force: Bool = true) {
|
||||
guard let handle else { return }
|
||||
transition.saveProgress(snapshot, handle: handle, force: force, history: playbackProgress)
|
||||
if castActive {
|
||||
guard let url = currentMedia?.url, !isLiveStream, state.has_video, state.can_seek,
|
||||
!state.seek_pending, state.duration > 0, state.position.isFinite else { return }
|
||||
playbackProgress.save(state.status == PLAYER_ENDED ? nil : state.position, for: url)
|
||||
} else {
|
||||
transition.saveProgress(snapshot, handle: handle, force: force, history: playbackProgress)
|
||||
}
|
||||
}
|
||||
|
||||
private func poll() {
|
||||
@@ -806,7 +1028,7 @@ final class PlayerModel: ObservableObject {
|
||||
showReportingError(reportingErrorMessage)
|
||||
}
|
||||
}
|
||||
applyState(player_get_state(handle))
|
||||
if !castActive { applyState(player_get_state(handle)) }
|
||||
}
|
||||
|
||||
func applyState(_ next: PlayerState) {
|
||||
@@ -836,6 +1058,7 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
|
||||
private func updateAutomaticQuality() {
|
||||
guard castDevice == nil else { return }
|
||||
guard automaticYouTubeQuality else { return }
|
||||
let reserve = bufferedRange.map { max(0, $0.upperBound - state.position) }
|
||||
let sample = AutomaticQuality.Sample(
|
||||
@@ -865,6 +1088,8 @@ final class PlayerModel: ObservableObject {
|
||||
|
||||
func shutdown() {
|
||||
saveProgress()
|
||||
if castDevice != nil { stopCasting(resume: false) }
|
||||
castDiscovery.stop()
|
||||
cancelYouTubeResolution()
|
||||
selectSubtitle(nil)
|
||||
systemPlayback?.shutdown()
|
||||
@@ -887,6 +1112,7 @@ final class PlayerModel: ObservableObject {
|
||||
private enum MediaChange {
|
||||
case open
|
||||
case quality(position: Double?, playing: Bool, automatic: Bool)
|
||||
case restore(position: Double, playing: Bool)
|
||||
|
||||
var automatic: Bool {
|
||||
if case .quality(_, _, let automatic) = self { return automatic }
|
||||
@@ -894,16 +1120,22 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
var playing: Bool {
|
||||
if case .quality(_, let playing, _) = self { return playing }
|
||||
if case .restore(_, let playing) = self { return playing }
|
||||
return true
|
||||
}
|
||||
var position: Double? {
|
||||
if case .quality(let position, _, _) = self { return position }
|
||||
if case .restore(let position, _) = self { return position }
|
||||
return nil
|
||||
}
|
||||
var isQuality: Bool {
|
||||
if case .quality = self { return true }
|
||||
return false
|
||||
}
|
||||
var preservesPosition: Bool {
|
||||
if case .restore = self { return true }
|
||||
return isQuality
|
||||
}
|
||||
}
|
||||
|
||||
private enum MediaSource {
|
||||
@@ -1067,6 +1299,15 @@ final class PlayerModel: ObservableObject {
|
||||
return accepted
|
||||
}
|
||||
|
||||
mutating func acceptCast() {
|
||||
playback = nil
|
||||
pendingRevision = nil
|
||||
restoration = nil
|
||||
seekPending = false
|
||||
}
|
||||
mutating func suspendHistory(revision: UInt64) {
|
||||
pendingRevision = revision
|
||||
}
|
||||
mutating func closeMedia() {
|
||||
playback = nil
|
||||
isLive = false
|
||||
@@ -1539,6 +1780,7 @@ private struct PlayerContent: View {
|
||||
|
||||
private var titleControls: some View {
|
||||
HStack(spacing: 4) {
|
||||
CastDeviceMenu(model: model)
|
||||
iconButton(model.isOnTop ? "pin.fill" : "pin",
|
||||
model.isOnTop ? "Stop Keeping Window on Top" : "Keep Window on Top", action: model.toggleOnTop)
|
||||
.imageScale(.small)
|
||||
@@ -1610,6 +1852,7 @@ private struct PlayerContent: View {
|
||||
iconButton("pip.exit", "Exit Compact Mode", action: model.toggleCompact)
|
||||
.disabled(!model.canToggleCompact)
|
||||
actionsMenu
|
||||
CastDeviceMenu(model: model)
|
||||
}
|
||||
if showsBufferReadout { bufferReadout }
|
||||
}
|
||||
@@ -1725,7 +1968,7 @@ private struct PlayerContent: View {
|
||||
if let view = model.videoView {
|
||||
VideoSurface(view: view)
|
||||
.accessibilityLabel("Video")
|
||||
.opacity(model.fileName == nil ? 0 : 1)
|
||||
.opacity(model.fileName == nil || model.castDevice != nil ? 0 : 1)
|
||||
.gesture(
|
||||
TapGesture(count: 2).onEnded { model.toggleFullScreen() }
|
||||
.exclusively(before: TapGesture().onEnded { model.hideControls() })
|
||||
@@ -1775,7 +2018,8 @@ private struct PlayerContent: View {
|
||||
} else if model.isBusy {
|
||||
VStack(spacing: 12) {
|
||||
ProgressView().controlSize(.large)
|
||||
Text(model.resolvingYouTube ? "Resolving YouTube…" : "Opening…")
|
||||
Text(model.castConnecting ? "Connecting to \(model.castDevice?.name ?? "Cast device")…" :
|
||||
(model.resolvingYouTube ? "Resolving YouTube…" : "Opening…"))
|
||||
.font(.callout).foregroundStyle(Color(SunsetPalette.secondaryText))
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
@@ -1865,6 +2109,14 @@ private struct PlayerContent: View {
|
||||
.background(PlayerGlass().clipShape(RoundedRectangle(cornerRadius: 12)).allowsHitTesting(false))
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.padding(.top, 32)
|
||||
} else if let device = model.castDevice {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "tv.badge.wifi").font(.system(size: 48))
|
||||
Text("Casting to \(device.name)").font(.headline)
|
||||
Button("Play on This Mac") { model.stopCasting() }
|
||||
}
|
||||
.foregroundStyle(Color(SunsetPalette.text))
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else if !model.state.has_video && model.state.has_audio {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "waveform").font(.system(size: 64, weight: .ultraLight))
|
||||
|
||||
@@ -0,0 +1,758 @@
|
||||
#include "cast_media.h"
|
||||
#include "network_policy.hpp"
|
||||
extern "C" {
|
||||
#include <libavformat/avformat.h>
|
||||
#include <libavcodec/avcodec.h>
|
||||
#include <libavutil/audio_fifo.h>
|
||||
#include <libavutil/opt.h>
|
||||
#include <libswresample/swresample.h>
|
||||
#include <libswscale/swscale.h>
|
||||
}
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <atomic>
|
||||
#include <charconv>
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <condition_variable>
|
||||
#include <cstdarg>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <deque>
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
#include <netdb.h>
|
||||
#include <poll.h>
|
||||
#include <sys/socket.h>
|
||||
#include <unistd.h>
|
||||
#include <fcntl.h>
|
||||
#include <limits>
|
||||
|
||||
namespace {
|
||||
using Clock = std::chrono::steady_clock;
|
||||
constexpr double segmentSeconds = 6;
|
||||
constexpr size_t segmentLimit = 32 * 1024 * 1024;
|
||||
constexpr size_t windowSegments = 8;
|
||||
void check(int value) { if (value < 0) throw std::runtime_error("Cast media operation failed (private details redacted)."); }
|
||||
void require(bool value) { if (!value) throw std::runtime_error("Cast media is unavailable or unsupported (private details redacted)."); }
|
||||
void privateLog(void *, int level, const char *, va_list) {
|
||||
if (level > AV_LOG_WARNING || level > av_log_get_level()) return;
|
||||
std::fputs(level <= AV_LOG_ERROR ? "FFmpeg cast error (private details redacted)\n" :
|
||||
"FFmpeg cast warning (private details redacted)\n", stderr);
|
||||
}
|
||||
struct FrameDelete { void operator()(AVFrame *p) const { av_frame_free(&p); } };
|
||||
struct PacketDelete { void operator()(AVPacket *p) const { av_packet_free(&p); } };
|
||||
using Frame = std::unique_ptr<AVFrame, FrameDelete>;
|
||||
using Packet = std::unique_ptr<AVPacket, PacketDelete>;
|
||||
Frame frame() { auto p = Frame(av_frame_alloc()); require(bool(p)); return p; }
|
||||
Packet packet() { auto p = Packet(av_packet_alloc()); require(bool(p)); return p; }
|
||||
int64_t aacBoundary(double seconds) { return int64_t(std::ceil(seconds * 48000 / 1024)) * 1024; }
|
||||
double aacPreroll(double seconds) { return double(std::max<int64_t>(0, aacBoundary(seconds) - 12 * 1024)) / 48000; }
|
||||
bool hasIDR(const AVPacket &packet, const AVCodecParameters &codec) {
|
||||
const auto *bytes = packet.data;
|
||||
const size_t size = size_t(std::max(0, packet.size));
|
||||
if (codec.extradata_size >= 5 && codec.extradata[0] == 1) {
|
||||
const size_t lengthSize = (codec.extradata[4] & 3) + 1;
|
||||
for (size_t at = 0; at + lengthSize <= size;) {
|
||||
uint32_t length = 0;
|
||||
for (size_t i = 0; i < lengthSize; ++i) length = (length << 8) | bytes[at++];
|
||||
if (!length || length > size - at) return false;
|
||||
if ((bytes[at] & 31) == 5) return true;
|
||||
at += length;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
for (size_t at = 0; at + 3 < size; ++at) {
|
||||
if (bytes[at] == 0 && bytes[at + 1] == 0 && bytes[at + 2] == 1 &&
|
||||
(bytes[at + 3] & 31) == 5) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
struct Source {
|
||||
std::string video, videoHeaders, audio, audioHeaders;
|
||||
bool local, restricted, live;
|
||||
double duration;
|
||||
};
|
||||
struct Input {
|
||||
AVFormatContext *format = nullptr;
|
||||
std::atomic<bool> &cancel;
|
||||
std::string origin, headers;
|
||||
std::atomic<int64_t> deadline{0};
|
||||
int64_t operationDeadline = std::numeric_limits<int64_t>::max();
|
||||
bool network, restricted, live;
|
||||
Input(std::atomic<bool> &stop, const std::string &url, const std::string &fields, bool local, bool restricted, bool live) :
|
||||
cancel(stop), headers(fields), network(!local), restricted(restricted), live(live) {
|
||||
origin = local ? "" : playback::networkOrigin(url.c_str());
|
||||
format = avformat_alloc_context(); require(format != nullptr);
|
||||
format->interrupt_callback = {interrupt, this};
|
||||
if (network) { format->opaque = this; format->io_open = openResource; }
|
||||
AVDictionary *options = nullptr;
|
||||
auto set = [&](const char *key, const char *value) {
|
||||
int result = av_dict_set(&options, key, value, 0);
|
||||
if (result < 0) { av_dict_free(&options); avformat_close_input(&format); check(result); }
|
||||
};
|
||||
set("protocol_whitelist", local ? "file" : "http,https,tcp,tls,crypto");
|
||||
set("rw_timeout", live ? "15000000" : "5000000");
|
||||
set("probesize", "5242880");
|
||||
set("analyzeduration", "5000000");
|
||||
if (network) {
|
||||
if (!headers.empty()) set("headers", headers.c_str());
|
||||
set("max_redirects", "0");
|
||||
set("http_persistent", "0");
|
||||
set("http_multiple", "0");
|
||||
set("extension_picky", "0");
|
||||
set("request_size", "10485760");
|
||||
}
|
||||
arm();
|
||||
int result = avformat_open_input(&format, url.c_str(), nullptr, &options);
|
||||
av_dict_free(&options);
|
||||
if (result >= 0) { arm(); result = avformat_find_stream_info(format, nullptr); }
|
||||
if (result < 0) { avformat_close_input(&format); check(result); }
|
||||
}
|
||||
~Input() { avformat_close_input(&format); }
|
||||
void arm() {
|
||||
const auto ioDeadline = std::chrono::duration_cast<std::chrono::nanoseconds>((Clock::now() + std::chrono::seconds(15)).time_since_epoch()).count();
|
||||
deadline = std::min<int64_t>(ioDeadline, operationDeadline);
|
||||
}
|
||||
static int interrupt(void *opaque) {
|
||||
auto &in = *static_cast<Input *>(opaque);
|
||||
return in.cancel || std::chrono::duration_cast<std::chrono::nanoseconds>(Clock::now().time_since_epoch()).count() > in.deadline;
|
||||
}
|
||||
static int openResource(AVFormatContext *context, AVIOContext **io, const char *url, int flags, AVDictionary **options) noexcept {
|
||||
try {
|
||||
auto &in = *static_cast<Input *>(context->opaque);
|
||||
const char *resource = std::strncmp(url, "crypto+", 7) == 0 || std::strncmp(url, "crypto:", 7) == 0 ? url + 7 : url;
|
||||
const auto resourceOrigin = playback::networkOrigin(resource);
|
||||
if (resourceOrigin.empty() || (in.restricted && resourceOrigin != in.origin)) return AVERROR(EACCES);
|
||||
int result = av_dict_set(options, "protocol_whitelist", "http,https,tcp,tls,crypto", 0);
|
||||
// Public YouTube HLS may cross CDN origins; never carry private headers across them.
|
||||
if (result >= 0) result = av_dict_set(options, "headers", resourceOrigin == in.origin && !in.headers.empty() ? in.headers.c_str() : nullptr, 0);
|
||||
if (result >= 0) result = av_dict_set(options, "max_redirects", "0", 0);
|
||||
if (result >= 0) result = av_dict_set(options, "rw_timeout", in.live ? "15000000" : "5000000", 0);
|
||||
if (result < 0) return result;
|
||||
return avio_open2(io, url, flags, &context->interrupt_callback, options);
|
||||
} catch (...) { return AVERROR(EACCES); }
|
||||
}
|
||||
double offset() const { return format->start_time == AV_NOPTS_VALUE ? 0 : double(format->start_time) / AV_TIME_BASE; }
|
||||
double duration() const { return format->duration == AV_NOPTS_VALUE ? 0 : double(format->duration) / AV_TIME_BASE; }
|
||||
void seek(double seconds) {
|
||||
arm(); check(av_seek_frame(format, -1, int64_t((seconds + offset()) * AV_TIME_BASE), AVSEEK_FLAG_BACKWARD));
|
||||
}
|
||||
};
|
||||
struct Track {
|
||||
std::unique_ptr<Input> input;
|
||||
AVStream *stream = nullptr;
|
||||
AVCodecContext *decoder = nullptr;
|
||||
Packet pending;
|
||||
Frame decoded;
|
||||
bool eof = false, draining = false, remux = false;
|
||||
double nextTime = 0;
|
||||
Track(std::unique_ptr<Input> in, AVMediaType type, double start, bool live) : input(std::move(in)) {
|
||||
int index = av_find_best_stream(input->format, type, -1, -1, nullptr, 0);
|
||||
if (index < 0) return;
|
||||
stream = input->format->streams[index];
|
||||
remux = stream->codecpar->codec_id == (type == AVMEDIA_TYPE_VIDEO ? AV_CODEC_ID_H264 : AV_CODEC_ID_AAC);
|
||||
if (type == AVMEDIA_TYPE_VIDEO) {
|
||||
const int profile = stream->codecpar->profile & ~AV_PROFILE_H264_CONSTRAINED;
|
||||
auto rate = av_guess_frame_rate(input->format, stream, nullptr);
|
||||
remux = remux && (profile == AV_PROFILE_H264_BASELINE || profile == AV_PROFILE_H264_MAIN || profile == AV_PROFILE_H264_HIGH) &&
|
||||
stream->codecpar->level > 0 && stream->codecpar->level <= 41 &&
|
||||
stream->codecpar->width <= 1920 && stream->codecpar->height <= 1080 &&
|
||||
rate.num > 0 && av_q2d(rate) <= 30.001;
|
||||
} else {
|
||||
remux = remux && stream->codecpar->ch_layout.nb_channels <= 2 &&
|
||||
(stream->codecpar->profile == AV_PROFILE_AAC_LOW || stream->codecpar->profile == AV_PROFILE_AAC_HE || stream->codecpar->profile == AV_PROFILE_AAC_HE_V2);
|
||||
}
|
||||
const double seekTarget = type == AVMEDIA_TYPE_AUDIO && !remux ?
|
||||
std::max(0.0, aacPreroll(start) - double(std::max(0, stream->codecpar->seek_preroll)) /
|
||||
std::max(1, stream->codecpar->sample_rate)) : start;
|
||||
if (!live) input->seek(seekTarget);
|
||||
nextPacket();
|
||||
// Every VOD segment must start at its advertised time and on an IDR. If the
|
||||
// source GOP doesn't align, decode preroll and use VideoToolbox, not overlap.
|
||||
if (type == AVMEDIA_TYPE_VIDEO)
|
||||
remux = remux && !live && stream->codecpar->video_delay == 0 && pending &&
|
||||
(pending->flags & AV_PKT_FLAG_KEY) && hasIDR(*pending, *stream->codecpar) &&
|
||||
std::abs(packetTime() - start) < .001;
|
||||
if (!live && type == AVMEDIA_TYPE_VIDEO && pending && (pending->flags & AV_PKT_FLAG_KEY) &&
|
||||
!hasIDR(*pending, *stream->codecpar) && stream->codecpar->codec_id == AV_CODEC_ID_H264) {
|
||||
// ponytail: rare intra-refresh seeks decode from the beginning; index real IDRs if this becomes slow.
|
||||
input->seek(0);
|
||||
nextPacket();
|
||||
}
|
||||
if (!remux) {
|
||||
const auto *codec = avcodec_find_decoder(stream->codecpar->codec_id); require(codec != nullptr);
|
||||
decoder = avcodec_alloc_context3(codec); require(decoder != nullptr);
|
||||
try {
|
||||
require(stream->codecpar->width >= 0 && stream->codecpar->height >= 0 &&
|
||||
int64_t(stream->codecpar->width) * stream->codecpar->height <= 4096LL * 2160);
|
||||
check(avcodec_parameters_to_context(decoder, stream->codecpar));
|
||||
decoder->pkt_timebase = stream->time_base;
|
||||
decoder->thread_count = 2;
|
||||
decoder->max_pixels = 4096LL * 2160;
|
||||
check(avcodec_open2(decoder, codec, nullptr));
|
||||
} catch (...) { avcodec_free_context(&decoder); throw; }
|
||||
}
|
||||
}
|
||||
~Track() { avcodec_free_context(&decoder); }
|
||||
double packetTime() const {
|
||||
if (!pending) return INFINITY;
|
||||
auto pts = pending->pts != AV_NOPTS_VALUE ? pending->pts : pending->dts;
|
||||
return pts == AV_NOPTS_VALUE ? nextTime : pts * av_q2d(stream->time_base) - input->offset();
|
||||
}
|
||||
void nextPacket() {
|
||||
pending.reset();
|
||||
if (eof || !stream) return;
|
||||
auto p = packet();
|
||||
input->arm();
|
||||
for (;;) {
|
||||
require(!Input::interrupt(input.get()));
|
||||
int result = av_read_frame(input->format, p.get());
|
||||
if (result == AVERROR_EOF) { eof = true; return; }
|
||||
check(result);
|
||||
require(p->size >= 0 && size_t(p->size) <= segmentLimit);
|
||||
if (p->stream_index == stream->index) { pending = std::move(p); return; }
|
||||
av_packet_unref(p.get());
|
||||
}
|
||||
}
|
||||
double frameTime() const {
|
||||
if (!decoded) return INFINITY;
|
||||
auto pts = decoded->best_effort_timestamp;
|
||||
return pts == AV_NOPTS_VALUE ? nextTime : pts * av_q2d(stream->time_base) - input->offset();
|
||||
}
|
||||
void nextFrame() {
|
||||
decoded.reset(); auto f = frame();
|
||||
for (;;) {
|
||||
require(!Input::interrupt(input.get()));
|
||||
int result = avcodec_receive_frame(decoder, f.get());
|
||||
if (result == 0) {
|
||||
decoded = std::move(f);
|
||||
const double step = stream->codecpar->codec_type == AVMEDIA_TYPE_AUDIO ? double(decoded->nb_samples) / decoded->sample_rate : 1.0 / 30;
|
||||
nextTime = frameTime() + step;
|
||||
return;
|
||||
}
|
||||
if (result == AVERROR_EOF) return;
|
||||
if (result != AVERROR(EAGAIN)) check(result);
|
||||
if (!pending) {
|
||||
if (draining) return;
|
||||
check(avcodec_send_packet(decoder, nullptr)); draining = true;
|
||||
} else {
|
||||
check(avcodec_send_packet(decoder, pending.get())); nextPacket();
|
||||
}
|
||||
}
|
||||
}
|
||||
double time() { if (remux) return packetTime(); if (!decoded) nextFrame(); return frameTime(); }
|
||||
};
|
||||
struct Segment { int64_t id; double duration; std::vector<uint8_t> bytes; };
|
||||
struct Mux {
|
||||
AVFormatContext *format = nullptr;
|
||||
AVIOContext *io = nullptr;
|
||||
std::vector<uint8_t> bytes;
|
||||
Mux() {
|
||||
check(avformat_alloc_output_context2(&format, nullptr, "mpegts", nullptr));
|
||||
auto *buffer = static_cast<uint8_t *>(av_malloc(32768));
|
||||
if (!buffer) { avformat_free_context(format); format = nullptr; throw std::bad_alloc(); }
|
||||
io = avio_alloc_context(buffer, 32768, 1, this, nullptr, writeBytes, nullptr);
|
||||
if (!io) { av_free(buffer); avformat_free_context(format); format = nullptr; throw std::bad_alloc(); }
|
||||
format->pb = io; format->flags |= AVFMT_FLAG_CUSTOM_IO;
|
||||
}
|
||||
~Mux() { if (io) { av_freep(&io->buffer); avio_context_free(&io); } avformat_free_context(format); }
|
||||
static int writeBytes(void *opaque, const uint8_t *data, int count) {
|
||||
auto &self = *static_cast<Mux *>(opaque);
|
||||
if (count < 0 || self.bytes.size() + size_t(count) > segmentLimit) return AVERROR(ENOSPC);
|
||||
try { self.bytes.insert(self.bytes.end(), data, data + count); return count; } catch (...) { return AVERROR(ENOMEM); }
|
||||
}
|
||||
};
|
||||
struct OutputTrack {
|
||||
Track &source;
|
||||
Mux *mux;
|
||||
AVStream *stream = nullptr;
|
||||
AVCodecContext *encoder = nullptr;
|
||||
SwsContext *scale = nullptr;
|
||||
SwrContext *resample = nullptr;
|
||||
AVAudioFifo *fifo = nullptr;
|
||||
int64_t audioPTS = AV_NOPTS_VALUE;
|
||||
bool firstVideo = true;
|
||||
double lastVideoTime = -INFINITY;
|
||||
bool continuous;
|
||||
int64_t audioKeepFrom = AV_NOPTS_VALUE, audioKeepUntil = INT64_MAX;
|
||||
OutputTrack(Track &track, Mux &output, bool live) : source(track), mux(&output), continuous(live) {
|
||||
try {
|
||||
if (!source.stream) return;
|
||||
stream = avformat_new_stream(mux->format, nullptr); require(stream != nullptr);
|
||||
if (source.remux) {
|
||||
check(avcodec_parameters_copy(stream->codecpar, source.stream->codecpar));
|
||||
stream->time_base = source.stream->time_base; stream->codecpar->codec_tag = 0;
|
||||
return;
|
||||
}
|
||||
bool video = source.stream->codecpar->codec_type == AVMEDIA_TYPE_VIDEO;
|
||||
const auto *codec = video ? avcodec_find_encoder_by_name("h264_videotoolbox") : avcodec_find_encoder(AV_CODEC_ID_AAC);
|
||||
require(codec != nullptr); encoder = avcodec_alloc_context3(codec); require(encoder != nullptr);
|
||||
if (video) {
|
||||
require(source.decoder->width > 0 && source.decoder->height > 0 &&
|
||||
int64_t(source.decoder->width) * source.decoder->height <= 4096LL * 2160);
|
||||
auto sar = av_guess_sample_aspect_ratio(source.input->format, source.stream, nullptr);
|
||||
if (sar.num <= 0 || sar.den <= 0) sar = {1, 1};
|
||||
const double displayWidth = source.decoder->width * av_q2d(sar);
|
||||
const double ratio = std::min({1.0, 1920.0 / displayWidth, 1080.0 / source.decoder->height});
|
||||
encoder->width = int(displayWidth * ratio) & ~1;
|
||||
encoder->height = int(source.decoder->height * ratio) & ~1;
|
||||
require(encoder->width > 0 && encoder->height > 0);
|
||||
// VideoToolbox can omit SAR in its H264 bitstream; encode square pixels instead.
|
||||
encoder->sample_aspect_ratio = {1, 1};
|
||||
encoder->pix_fmt = AV_PIX_FMT_NV12; encoder->time_base = {1, 90000};
|
||||
encoder->framerate = av_guess_frame_rate(source.input->format, source.stream, nullptr);
|
||||
if (encoder->framerate.num <= 0 || av_q2d(encoder->framerate) > 30) encoder->framerate = {30, 1};
|
||||
encoder->bit_rate = 6000000; encoder->gop_size = 180; encoder->max_b_frames = 0;
|
||||
check(av_opt_set(encoder->priv_data, "profile", "main", 0));
|
||||
check(av_opt_set(encoder->priv_data, "level", "4.1", 0));
|
||||
// VideoToolbox may use its platform software fallback, never a host executable.
|
||||
av_opt_set(encoder->priv_data, "allow_sw", "1", 0);
|
||||
av_opt_set(encoder->priv_data, "realtime", "1", 0);
|
||||
} else {
|
||||
encoder->sample_rate = 48000; encoder->sample_fmt = AV_SAMPLE_FMT_FLTP;
|
||||
av_channel_layout_default(&encoder->ch_layout, 2); encoder->time_base = {1, 48000}; encoder->bit_rate = 192000;
|
||||
fifo = av_audio_fifo_alloc(encoder->sample_fmt, 2, 4096); require(fifo != nullptr);
|
||||
}
|
||||
check(avcodec_open2(encoder, codec, nullptr));
|
||||
stream->time_base = encoder->time_base; check(avcodec_parameters_from_context(stream->codecpar, encoder));
|
||||
} catch (...) { avcodec_free_context(&encoder); av_audio_fifo_free(fifo); fifo = nullptr; throw; }
|
||||
}
|
||||
~OutputTrack() { avcodec_free_context(&encoder); sws_freeContext(scale); swr_free(&resample); av_audio_fifo_free(fifo); }
|
||||
void beginSegment(Mux &output) {
|
||||
mux = &output;
|
||||
stream = avformat_new_stream(mux->format, nullptr); require(stream != nullptr);
|
||||
if (source.remux) {
|
||||
check(avcodec_parameters_copy(stream->codecpar, source.stream->codecpar));
|
||||
stream->time_base = source.stream->time_base; stream->codecpar->codec_tag = 0;
|
||||
} else {
|
||||
stream->time_base = encoder->time_base;
|
||||
check(avcodec_parameters_from_context(stream->codecpar, encoder));
|
||||
}
|
||||
firstVideo = true;
|
||||
}
|
||||
void writePacket(AVPacket *packet) {
|
||||
// A shared, positive TS clock origin prevents per-segment AAC priming shifts.
|
||||
const auto offset = av_rescale_q(1, AVRational{1, 1}, stream->time_base);
|
||||
if (packet->pts != AV_NOPTS_VALUE) packet->pts += offset;
|
||||
if (packet->dts != AV_NOPTS_VALUE) packet->dts += offset;
|
||||
packet->stream_index = stream->index;
|
||||
check(av_interleaved_write_frame(mux->format, packet));
|
||||
}
|
||||
void encoded(AVFrame *f) {
|
||||
check(avcodec_send_frame(encoder, f)); auto p = packet();
|
||||
for (;;) {
|
||||
int result = avcodec_receive_packet(encoder, p.get());
|
||||
if (result == AVERROR(EAGAIN) || result == AVERROR_EOF) break;
|
||||
check(result);
|
||||
if (fifo && !continuous && audioKeepFrom != AV_NOPTS_VALUE &&
|
||||
(p->pts < audioKeepFrom || p->pts >= audioKeepUntil)) {
|
||||
av_packet_unref(p.get());
|
||||
continue;
|
||||
}
|
||||
av_packet_rescale_ts(p.get(), encoder->time_base, stream->time_base);
|
||||
writePacket(p.get()); av_packet_unref(p.get());
|
||||
}
|
||||
}
|
||||
void audioFrames(bool finish, double end = INFINITY) {
|
||||
const int count = encoder->frame_size;
|
||||
while ((av_audio_fifo_size(fifo) >= count || (finish && av_audio_fifo_size(fifo) > 0)) &&
|
||||
(audioPTS == AV_NOPTS_VALUE || double(audioPTS) / 48000 < end)) {
|
||||
auto f = frame(); f->format = encoder->sample_fmt; f->sample_rate = encoder->sample_rate;
|
||||
check(av_channel_layout_copy(&f->ch_layout, &encoder->ch_layout)); f->nb_samples = count;
|
||||
check(av_frame_get_buffer(f.get(), 0));
|
||||
int available = std::min(count, av_audio_fifo_size(fifo));
|
||||
require(av_audio_fifo_read(fifo, reinterpret_cast<void **>(f->data), available) == available);
|
||||
if (available < count) av_samples_set_silence(f->data, available, count - available, 2, encoder->sample_fmt);
|
||||
f->pts = audioPTS; audioPTS += count; encoded(f.get());
|
||||
}
|
||||
}
|
||||
void consume(double start, double end) {
|
||||
if (source.remux) {
|
||||
double time = source.packetTime();
|
||||
if (time >= start - .001 && time < end) {
|
||||
auto *p = source.pending.get();
|
||||
int64_t offset = int64_t(source.input->offset() / av_q2d(source.stream->time_base));
|
||||
if (p->pts != AV_NOPTS_VALUE) p->pts -= offset;
|
||||
if (p->dts != AV_NOPTS_VALUE) p->dts -= offset;
|
||||
av_packet_rescale_ts(p, source.stream->time_base, stream->time_base); p->stream_index = stream->index;
|
||||
writePacket(p);
|
||||
}
|
||||
source.nextPacket(); return;
|
||||
}
|
||||
auto *f = source.decoded.get(); double time = source.frameTime();
|
||||
if (source.stream->codecpar->codec_type == AVMEDIA_TYPE_VIDEO) {
|
||||
if (time >= start - .001 && time < end && time - lastVideoTime >= 1.0 / 30 - .00001) {
|
||||
require(f->width > 0 && f->height > 0 && int64_t(f->width) * f->height <= 4096LL * 2160);
|
||||
auto out = frame(); out->format = encoder->pix_fmt; out->width = encoder->width; out->height = encoder->height;
|
||||
out->sample_aspect_ratio = encoder->sample_aspect_ratio;
|
||||
check(av_frame_get_buffer(out.get(), 32));
|
||||
scale = sws_getCachedContext(scale, f->width, f->height, AVPixelFormat(f->format), out->width, out->height, encoder->pix_fmt, SWS_BILINEAR, nullptr, nullptr, nullptr);
|
||||
require(scale != nullptr); check(sws_scale(scale, f->data, f->linesize, 0, f->height, out->data, out->linesize));
|
||||
out->pts = int64_t(std::llround(std::max(start, time) * 90000));
|
||||
out->pict_type = firstVideo ? AV_PICTURE_TYPE_I : AV_PICTURE_TYPE_NONE; firstVideo = false; encoded(out.get());
|
||||
lastVideoTime = time;
|
||||
}
|
||||
} else {
|
||||
require(f->sample_rate > 0 && f->nb_samples >= 0 && f->nb_samples <= 65536 && f->ch_layout.nb_channels <= 32);
|
||||
const double begin = continuous ? start : aacPreroll(start);
|
||||
const double limit = continuous ? end : double(aacBoundary(end)) / 48000;
|
||||
if (!continuous) {
|
||||
audioKeepFrom = aacBoundary(start);
|
||||
audioKeepUntil = int64_t(std::llround(end * 48000));
|
||||
}
|
||||
if (time + double(f->nb_samples) / f->sample_rate > begin && time < limit) {
|
||||
if (!resample) {
|
||||
check(swr_alloc_set_opts2(&resample, &encoder->ch_layout, encoder->sample_fmt, 48000, &f->ch_layout, AVSampleFormat(f->format), f->sample_rate, 0, nullptr));
|
||||
check(swr_init(resample));
|
||||
}
|
||||
int capacity = int(av_rescale_rnd(swr_get_delay(resample, f->sample_rate) + f->nb_samples, 48000, f->sample_rate, AV_ROUND_UP));
|
||||
require(capacity > 0 && capacity <= 131072);
|
||||
auto out = frame(); out->format = encoder->sample_fmt; out->sample_rate = 48000;
|
||||
check(av_channel_layout_copy(&out->ch_layout, &encoder->ch_layout)); out->nb_samples = capacity; check(av_frame_get_buffer(out.get(), 0));
|
||||
int count = swr_convert(resample, out->data, capacity, const_cast<const uint8_t **>(f->extended_data), f->nb_samples); check(count);
|
||||
int skip = continuous && audioPTS != AV_NOPTS_VALUE ? 0 :
|
||||
std::clamp(int(std::llround((begin - time) * 48000)), 0, count);
|
||||
int take = continuous ? count - skip :
|
||||
std::clamp(int(std::llround((limit - time) * 48000)) - skip, 0, count - skip);
|
||||
if (audioPTS == AV_NOPTS_VALUE) audioPTS = int64_t(std::llround(std::max(begin, time) * 48000));
|
||||
uint8_t *data[2] = {out->data[0] + skip * 4, out->data[1] + skip * 4};
|
||||
require(av_audio_fifo_size(fifo) + take <= 131072);
|
||||
require(av_audio_fifo_write(fifo, reinterpret_cast<void **>(data), take) == take); audioFrames(false, end);
|
||||
}
|
||||
}
|
||||
source.nextFrame();
|
||||
}
|
||||
void finish(double end) {
|
||||
if (!encoder) return;
|
||||
if (continuous) {
|
||||
if (fifo) audioFrames(false, end);
|
||||
} else {
|
||||
if (fifo) audioFrames(true);
|
||||
encoded(nullptr);
|
||||
}
|
||||
}
|
||||
};
|
||||
struct Pipeline {
|
||||
std::unique_ptr<Track> video, audio;
|
||||
std::unique_ptr<OutputTrack> videoOutput, audioOutput;
|
||||
bool live;
|
||||
std::atomic<bool> &cancel;
|
||||
double duration = 0;
|
||||
Pipeline(const Source &source, std::atomic<bool> &stop, double start) : live(source.live), cancel(stop) {
|
||||
auto primary = std::make_unique<Input>(stop, source.video, source.videoHeaders, source.local, source.restricted, source.live);
|
||||
duration = std::max(source.duration, primary->duration());
|
||||
bool hasVideo = av_find_best_stream(primary->format, AVMEDIA_TYPE_VIDEO, -1, -1, nullptr, 0) >= 0;
|
||||
bool hasAudio = av_find_best_stream(primary->format, AVMEDIA_TYPE_AUDIO, -1, -1, nullptr, 0) >= 0;
|
||||
if (hasVideo) video = std::make_unique<Track>(std::move(primary), AVMEDIA_TYPE_VIDEO, start, source.live);
|
||||
if (!source.audio.empty()) {
|
||||
audio = std::make_unique<Track>(std::make_unique<Input>(stop, source.audio, source.audioHeaders, false, source.restricted, source.live), AVMEDIA_TYPE_AUDIO, start, source.live);
|
||||
require(audio->stream != nullptr);
|
||||
} else if (hasAudio) {
|
||||
if (!primary) primary = std::make_unique<Input>(stop, source.video, source.videoHeaders, source.local, source.restricted, source.live);
|
||||
audio = std::make_unique<Track>(std::move(primary), AVMEDIA_TYPE_AUDIO, start, source.live);
|
||||
}
|
||||
require(video || audio);
|
||||
if (!source.live) require(std::isfinite(duration) && duration > 0 && duration <= 86400 * 14);
|
||||
}
|
||||
Segment segment(int64_t id, double start, double end) {
|
||||
Mux mux;
|
||||
if (video) {
|
||||
videoOutput.reset();
|
||||
videoOutput = std::make_unique<OutputTrack>(*video, mux, false);
|
||||
}
|
||||
if (audio) {
|
||||
if (audioOutput) audioOutput->beginSegment(mux);
|
||||
else audioOutput = std::make_unique<OutputTrack>(*audio, mux, live);
|
||||
}
|
||||
AVDictionary *options = nullptr;
|
||||
av_dict_set(&options, "mpegts_flags", "resend_headers", 0);
|
||||
av_dict_set(&options, "mpegts_copyts", "1", 0);
|
||||
av_dict_set(&options, "muxdelay", "0", 0);
|
||||
int result = avformat_write_header(mux.format, &options); av_dict_free(&options); check(result);
|
||||
const auto deadline = Clock::now() + std::chrono::seconds(45);
|
||||
const auto limit = std::chrono::duration_cast<std::chrono::nanoseconds>(deadline.time_since_epoch()).count();
|
||||
if (video) video->input->operationDeadline = limit;
|
||||
if (audio) audio->input->operationDeadline = limit;
|
||||
const double audioEnd = audio && !audio->remux && !live ? double(aacBoundary(end)) / 48000 : end;
|
||||
for (;;) {
|
||||
require(!cancel && Clock::now() < deadline);
|
||||
double vt = video ? video->time() : INFINITY, at = audio ? audio->time() : INFINITY;
|
||||
if (vt >= end) vt = INFINITY;
|
||||
if (at >= audioEnd) at = INFINITY;
|
||||
if (!std::isfinite(vt) && !std::isfinite(at)) break;
|
||||
if (vt <= at) videoOutput->consume(start, end); else audioOutput->consume(start, end);
|
||||
}
|
||||
if (videoOutput) videoOutput->finish(end);
|
||||
if (audioOutput) audioOutput->finish(end);
|
||||
check(av_write_trailer(mux.format)); avio_flush(mux.io);
|
||||
require(mux.io->error >= 0 && !mux.bytes.empty());
|
||||
return {id, end - start, std::move(mux.bytes)};
|
||||
}
|
||||
};
|
||||
bool number(std::string_view text, int64_t &value) {
|
||||
if (text.empty()) return false;
|
||||
auto result = std::from_chars(text.data(), text.data() + text.size(), value);
|
||||
return result.ec == std::errc() && result.ptr == text.data() + text.size() && value >= 0;
|
||||
}
|
||||
}
|
||||
|
||||
struct CastMedia {
|
||||
Source source;
|
||||
std::atomic<bool> cancelled{false};
|
||||
std::mutex lock;
|
||||
std::condition_variable wake;
|
||||
int listener = -1;
|
||||
std::vector<int> clients;
|
||||
std::array<std::thread, 2> servers;
|
||||
std::thread producer;
|
||||
std::string base, token;
|
||||
double duration = 0;
|
||||
std::deque<std::shared_ptr<const Segment>> segments;
|
||||
std::map<int, std::shared_ptr<const std::vector<uint8_t>>> subtitles;
|
||||
#ifdef CAST_MEDIA_CHECK
|
||||
double testSeconds = 6;
|
||||
#endif
|
||||
bool liveFailed = false;
|
||||
explicit CastMedia(Source input) : source(std::move(input)) {}
|
||||
~CastMedia() {
|
||||
cancel();
|
||||
if (producer.joinable()) producer.join();
|
||||
for (auto &worker : servers) if (worker.joinable()) worker.join();
|
||||
if (listener >= 0) close(listener);
|
||||
}
|
||||
void cancel() {
|
||||
cancelled = true; wake.notify_all();
|
||||
std::lock_guard guard(lock);
|
||||
if (listener >= 0) shutdown(listener, SHUT_RDWR);
|
||||
for (int socket : clients) shutdown(socket, SHUT_RDWR);
|
||||
}
|
||||
void bindReceiver(const char *host) {
|
||||
require(host && std::strlen(host) <= 1024);
|
||||
addrinfo hints{}; hints.ai_socktype = SOCK_DGRAM; hints.ai_family = AF_INET;
|
||||
addrinfo *addresses = nullptr;
|
||||
if (getaddrinfo(host, "8009", &hints, &addresses) != 0) {
|
||||
hints.ai_family = AF_UNSPEC;
|
||||
require(getaddrinfo(host, "8009", &hints, &addresses) == 0);
|
||||
}
|
||||
std::unique_ptr<addrinfo, decltype(&freeaddrinfo)> owner(addresses, freeaddrinfo);
|
||||
sockaddr_storage local{}; socklen_t length = 0; int family = 0;
|
||||
for (auto *address = addresses; address; address = address->ai_next) {
|
||||
int route = socket(address->ai_family, SOCK_DGRAM, 0);
|
||||
if (route < 0) continue;
|
||||
length = sizeof(local);
|
||||
bool found = connect(route, address->ai_addr, address->ai_addrlen) == 0 && getsockname(route, reinterpret_cast<sockaddr *>(&local), &length) == 0;
|
||||
close(route);
|
||||
if (found) { family = address->ai_family; break; }
|
||||
}
|
||||
require(family != 0 && !cancelled);
|
||||
char name[NI_MAXHOST]; require(getnameinfo(reinterpret_cast<sockaddr *>(&local), length, name, sizeof(name), nullptr, 0, NI_NUMERICHOST) == 0);
|
||||
if (family == AF_INET) reinterpret_cast<sockaddr_in *>(&local)->sin_port = 0;
|
||||
else reinterpret_cast<sockaddr_in6 *>(&local)->sin6_port = 0;
|
||||
int server = socket(family, SOCK_STREAM, 0); require(server >= 0);
|
||||
if (fcntl(server, F_SETFL, O_NONBLOCK) < 0) { close(server); require(false); }
|
||||
int noSIGPIPE = 1; setsockopt(server, SOL_SOCKET, SO_NOSIGPIPE, &noSIGPIPE, sizeof(noSIGPIPE));
|
||||
if (::bind(server, reinterpret_cast<sockaddr *>(&local), length) != 0 || listen(server, 8) != 0 || getsockname(server, reinterpret_cast<sockaddr *>(&local), &length) != 0) { close(server); require(false); }
|
||||
unsigned port = ntohs(family == AF_INET ? reinterpret_cast<sockaddr_in *>(&local)->sin_port : reinterpret_cast<sockaddr_in6 *>(&local)->sin6_port);
|
||||
std::array<unsigned char, 32> random{}; arc4random_buf(random.data(), random.size());
|
||||
for (auto byte : random) { char hex[3]; std::snprintf(hex, sizeof(hex), "%02x", byte); token += hex; }
|
||||
std::string hostname = name;
|
||||
// URLComponents expects escaped IPv6 scope identifiers.
|
||||
auto scope = hostname.find('%'); if (scope != std::string::npos) hostname.replace(scope, 1, "%25");
|
||||
base = "http://" + (family == AF_INET6 ? "[" + hostname + "]" : hostname) + ":" + std::to_string(port) + "/" + token + "/";
|
||||
std::lock_guard guard(lock); listener = server;
|
||||
if (cancelled) shutdown(listener, SHUT_RDWR);
|
||||
}
|
||||
void produce(std::unique_ptr<Pipeline> pipeline) {
|
||||
try {
|
||||
double first = std::min(pipeline->video ? pipeline->video->time() : INFINITY, pipeline->audio ? pipeline->audio->time() : INFINITY);
|
||||
require(std::isfinite(first));
|
||||
#ifndef CAST_MEDIA_CHECK
|
||||
auto origin = Clock::now();
|
||||
#endif
|
||||
for (int64_t id = 0; !cancelled; ++id) {
|
||||
auto segment = pipeline->segment(id, first + id * segmentSeconds, first + (id + 1) * segmentSeconds);
|
||||
{ std::lock_guard guard(lock); segments.push_back(std::make_shared<const Segment>(std::move(segment))); while (segments.size() > windowSegments) segments.pop_front(); }
|
||||
wake.notify_all();
|
||||
std::unique_lock guard(lock);
|
||||
#ifdef CAST_MEDIA_CHECK
|
||||
wake.wait(guard, [&] { return cancelled || testSeconds >= (id + 1) * segmentSeconds; });
|
||||
#else
|
||||
wake.wait_until(guard, origin + std::chrono::milliseconds(int64_t((id + 1) * segmentSeconds * 1000)), [&] { return bool(cancelled); });
|
||||
#endif
|
||||
}
|
||||
} catch (...) { std::lock_guard guard(lock); liveFailed = true; wake.notify_all(); }
|
||||
}
|
||||
std::vector<uint8_t> playlist() {
|
||||
std::string text = "#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:6\n";
|
||||
int64_t first = 0, count = int64_t(std::ceil(duration / segmentSeconds));
|
||||
std::lock_guard guard(lock);
|
||||
if (source.live) { require(!segments.empty() && !liveFailed); first = segments.front()->id; count = int64_t(segments.size()); }
|
||||
else text += "#EXT-X-PLAYLIST-TYPE:VOD\n";
|
||||
text += "#EXT-X-MEDIA-SEQUENCE:" + std::to_string(first) + "\n";
|
||||
if (source.live) text += "#EXT-X-DISCONTINUITY-SEQUENCE:" + std::to_string(first) + "\n";
|
||||
for (int64_t id = first; id < first + count; ++id) {
|
||||
char value[64]; double length = source.live ? segments[size_t(id - first)]->duration : std::min(segmentSeconds, duration - id * segmentSeconds);
|
||||
std::snprintf(value, sizeof(value), "#EXTINF:%.6f,\n", length);
|
||||
// Each independently generated segment resets codec/mux state.
|
||||
if (id > first) text += "#EXT-X-DISCONTINUITY\n";
|
||||
text += value; text += "segment/" + std::to_string(id) + ".ts\n";
|
||||
}
|
||||
if (!source.live) text += "#EXT-X-ENDLIST\n";
|
||||
return {text.begin(), text.end()};
|
||||
}
|
||||
void sendAll(int socket, const uint8_t *bytes, size_t size) {
|
||||
const auto deadline = Clock::now() + std::chrono::seconds(15);
|
||||
while (size) {
|
||||
require(!cancelled && Clock::now() < deadline);
|
||||
pollfd descriptor{socket, POLLOUT, 0}; require(poll(&descriptor, 1, 1000) >= 0);
|
||||
if (!(descriptor.revents & POLLOUT)) continue;
|
||||
ssize_t count = send(socket, bytes, std::min(size, size_t(65536)), 0); require(count > 0);
|
||||
bytes += count; size -= size_t(count);
|
||||
}
|
||||
}
|
||||
void response(int socket, int status, const char *type, const std::vector<uint8_t> &bytes, bool head, std::string_view range = {}) {
|
||||
size_t begin = 0, end = bytes.size(); std::string extra;
|
||||
if (!range.empty() && status == 200) {
|
||||
int64_t low = 0, high = 0; auto split = range.find('-');
|
||||
if (!range.starts_with("bytes=") || split == std::string_view::npos || range.find(',') != std::string_view::npos) status = 416;
|
||||
else if (split == 6) {
|
||||
if (!number(range.substr(7), high) || high == 0 || bytes.empty()) status = 416;
|
||||
else begin = bytes.size() - std::min(bytes.size(), size_t(high));
|
||||
} else if (!number(range.substr(6, split - 6), low) || size_t(low) >= bytes.size()) status = 416;
|
||||
else {
|
||||
begin = size_t(low);
|
||||
if (split + 1 < range.size()) {
|
||||
if (!number(range.substr(split + 1), high) || high < low) status = 416;
|
||||
else end = std::min(bytes.size(), size_t(high) + 1);
|
||||
}
|
||||
}
|
||||
if (status != 416) { status = 206; extra = "Content-Range: bytes " + std::to_string(begin) + "-" + std::to_string(end - 1) + "/" + std::to_string(bytes.size()) + "\r\n"; }
|
||||
else { extra = "Content-Range: bytes */" + std::to_string(bytes.size()) + "\r\n"; begin = end = 0; }
|
||||
}
|
||||
std::string header = "HTTP/1.1 " + std::to_string(status) + (status == 200 ? " OK" : status == 206 ? " Partial Content" : " Error") + "\r\nConnection: close\r\nAccess-Control-Allow-Origin: *\r\nAccess-Control-Allow-Methods: GET, HEAD, OPTIONS\r\nAccess-Control-Allow-Headers: Range\r\nAccess-Control-Expose-Headers: Content-Length, Content-Range, Accept-Ranges\r\nCache-Control: no-store\r\nAccept-Ranges: bytes\r\nContent-Type: " + type + "\r\n" + extra + "Content-Length: " + std::to_string(end - begin) + "\r\n\r\n";
|
||||
sendAll(socket, reinterpret_cast<const uint8_t *>(header.data()), header.size());
|
||||
if (!head && end > begin) sendAll(socket, bytes.data() + begin, end - begin);
|
||||
}
|
||||
void serve(int socket) {
|
||||
std::string request; std::array<char, 2048> buffer{};
|
||||
auto deadline = Clock::now() + std::chrono::seconds(5);
|
||||
while (request.find("\r\n\r\n") == std::string::npos) {
|
||||
require(!cancelled && request.size() < 16384 && Clock::now() < deadline);
|
||||
pollfd descriptor{socket, POLLIN, 0}; require(poll(&descriptor, 1, 500) >= 0);
|
||||
if (!(descriptor.revents & POLLIN)) continue;
|
||||
ssize_t size = recv(socket, buffer.data(), std::min(buffer.size(), 16384 - request.size()), 0); require(size > 0); request.append(buffer.data(), size_t(size));
|
||||
}
|
||||
auto first = request.find(' '), second = request.find(' ', first == std::string::npos ? 0 : first + 1);
|
||||
require(first != std::string::npos && second != std::string::npos && second < request.find("\r\n"));
|
||||
auto method = request.substr(0, first), path = request.substr(first + 1, second - first - 1);
|
||||
bool head = method == "HEAD";
|
||||
if (!path.starts_with("/" + token + "/")) { response(socket, 404, "text/plain", {}, head); return; }
|
||||
if (method == "OPTIONS") { response(socket, 204, "text/plain", {}, true); return; }
|
||||
if (method != "GET" && !head) { response(socket, 405, "text/plain", {}, true); return; }
|
||||
std::string range;
|
||||
size_t cursor = request.find("\r\n") + 2;
|
||||
while (cursor < request.size()) {
|
||||
auto next = request.find("\r\n", cursor); if (next == cursor || next == std::string::npos) break;
|
||||
auto line = request.substr(cursor, next - cursor); auto colon = line.find(':'); require(colon != std::string::npos);
|
||||
auto name = line.substr(0, colon); for (char &c : name) if (c >= 'A' && c <= 'Z') c += 'a' - 'A';
|
||||
if (name == "range") { require(range.empty()); auto begin = line.find_first_not_of(' ', colon + 1); require(begin != std::string::npos); range = line.substr(begin); }
|
||||
cursor = next + 2;
|
||||
}
|
||||
path.erase(0, token.size() + 2);
|
||||
std::vector<uint8_t> data; const char *type = "video/mp2t"; int status = 200;
|
||||
std::shared_ptr<const std::vector<uint8_t>> retained;
|
||||
if (path == "index.m3u8") { data = playlist(); type = "application/vnd.apple.mpegurl"; }
|
||||
else if (path.starts_with("subtitle/") && path.ends_with(".vtt")) {
|
||||
int64_t id = 0;
|
||||
if (!number(std::string_view(path).substr(9, path.size() - 13), id) || id > 32) status = 404;
|
||||
else { std::lock_guard guard(lock); auto found = subtitles.find(int(id)); if (found == subtitles.end()) status = 404; else retained = found->second; }
|
||||
type = "text/vtt; charset=utf-8";
|
||||
} else if (path.starts_with("segment/") && path.ends_with(".ts")) {
|
||||
int64_t id = 0;
|
||||
if (!number(std::string_view(path).substr(8, path.size() - 11), id)) status = 404;
|
||||
else if (source.live) {
|
||||
std::lock_guard guard(lock); auto found = std::find_if(segments.begin(), segments.end(), [&](const auto &segment) { return segment->id == id; });
|
||||
if (found == segments.end()) status = 404; else retained = std::shared_ptr<const std::vector<uint8_t>>(*found, &(*found)->bytes);
|
||||
} else if (double(id) * segmentSeconds >= duration) status = 404;
|
||||
else {
|
||||
Pipeline pipeline(source, cancelled, id * segmentSeconds);
|
||||
data = pipeline.segment(id, id * segmentSeconds, std::min(duration, (id + 1) * segmentSeconds)).bytes;
|
||||
}
|
||||
} else status = 404;
|
||||
response(socket, status, type, retained ? *retained : data, head, range);
|
||||
}
|
||||
void server() {
|
||||
while (!cancelled) {
|
||||
pollfd descriptor{listener, POLLIN, 0}; if (poll(&descriptor, 1, 500) <= 0) continue;
|
||||
if (!(descriptor.revents & POLLIN)) continue;
|
||||
int socket = accept(listener, nullptr, nullptr); if (socket < 0) continue;
|
||||
int one = 1; setsockopt(socket, SOL_SOCKET, SO_NOSIGPIPE, &one, sizeof(one));
|
||||
timeval timeout{1, 0}; setsockopt(socket, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof(timeout)); setsockopt(socket, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout));
|
||||
{ std::lock_guard guard(lock); clients.push_back(socket); if (cancelled) shutdown(socket, SHUT_RDWR); }
|
||||
try { serve(socket); } catch (...) { try { response(socket, 503, "text/plain", {}, true); } catch (...) {} }
|
||||
{ std::lock_guard guard(lock); clients.erase(std::remove(clients.begin(), clients.end(), socket), clients.end()); close(socket); }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
extern "C" CastMedia *cast_media_create(const CastMediaSource *input, char error[512]) {
|
||||
try {
|
||||
require(input && input->video_url && std::strlen(input->video_url) <= 65536);
|
||||
require(playback::validHeaders(input->video_headers) && playback::validHeaders(input->audio_headers));
|
||||
require(std::isfinite(input->duration) && input->duration >= 0);
|
||||
std::string video = input->video_url;
|
||||
if (input->local) require(!video.empty() && video.front() == '/' && video.find('\0') == std::string::npos);
|
||||
else require(playback::validNetworkURL(video.c_str()));
|
||||
if (input->audio_url && *input->audio_url) require(playback::validNetworkURL(input->audio_url));
|
||||
av_log_set_callback(privateLog);
|
||||
// Bound demuxer-controlled allocations before probing or packet reads allocate them.
|
||||
av_max_alloc(128 * 1024 * 1024);
|
||||
return new CastMedia({video, input->video_headers ? input->video_headers : "", input->audio_url ? input->audio_url : "", input->audio_headers ? input->audio_headers : "", input->local, input->restricted, input->live, input->duration});
|
||||
} catch (...) { if (error) std::snprintf(error, 512, "Could not prepare cast media (private details redacted)."); return nullptr; }
|
||||
}
|
||||
extern "C" bool cast_media_start(CastMedia *media, const char *host, char url[512], double *duration, char error[512]) {
|
||||
try {
|
||||
require(media && host && url && duration && !media->cancelled);
|
||||
auto pipeline = std::make_unique<Pipeline>(media->source, media->cancelled, 0);
|
||||
media->duration = pipeline->duration;
|
||||
media->bindReceiver(host);
|
||||
if (media->source.live) {
|
||||
media->producer = std::thread([media, pipe = std::move(pipeline)]() mutable { media->produce(std::move(pipe)); });
|
||||
std::unique_lock guard(media->lock);
|
||||
require(media->wake.wait_for(guard, std::chrono::seconds(50), [&] { return media->segments.size() >= 2 || media->liveFailed || media->cancelled; }));
|
||||
require(media->segments.size() >= 2 && !media->liveFailed && !media->cancelled);
|
||||
}
|
||||
for (auto &worker : media->servers) worker = std::thread([media] { media->server(); });
|
||||
require(media->base.size() + 11 < 512); std::snprintf(url, 512, "%sindex.m3u8", media->base.c_str()); *duration = media->source.live ? 0 : media->duration;
|
||||
return true;
|
||||
} catch (...) { if (media) media->cancel(); if (error) std::snprintf(error, 512, "Could not start cast delivery (private details redacted)."); return false; }
|
||||
}
|
||||
extern "C" bool cast_media_subtitle(CastMedia *media, int id, const void *bytes, size_t size, char url[512]) {
|
||||
try {
|
||||
require(media && !media->cancelled && id > 0 && id <= 32 && bytes && size > 0 && size <= 4 * 1024 * 1024 && url);
|
||||
std::lock_guard guard(media->lock);
|
||||
auto *data = static_cast<const uint8_t *>(bytes); media->subtitles[id] = std::make_shared<const std::vector<uint8_t>>(data, data + size);
|
||||
require(media->base.size() + 32 < 512); std::snprintf(url, 512, "%ssubtitle/%d.vtt", media->base.c_str(), id); return true;
|
||||
} catch (...) { return false; }
|
||||
}
|
||||
extern "C" void cast_media_cancel(CastMedia *media) { if (media) media->cancel(); }
|
||||
extern "C" void cast_media_destroy(CastMedia *media) { delete media; }
|
||||
#ifdef CAST_MEDIA_CHECK
|
||||
extern "C" bool cast_media_check_advance(CastMedia *media, double seconds) {
|
||||
if (!media || !media->source.live || !std::isfinite(seconds) || seconds < 6 || seconds > 3600) return false;
|
||||
std::unique_lock guard(media->lock);
|
||||
media->testSeconds = seconds;
|
||||
media->wake.notify_all();
|
||||
const auto target = int64_t(seconds / segmentSeconds);
|
||||
media->wake.wait(guard, [&] { return media->cancelled || media->liveFailed ||
|
||||
(!media->segments.empty() && media->segments.back()->id >= target); });
|
||||
return !media->cancelled && !media->liveFailed;
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,34 @@
|
||||
#pragma once
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
typedef struct CastMedia CastMedia;
|
||||
typedef struct CastMediaSource {
|
||||
const char *video_url;
|
||||
const char *video_headers;
|
||||
const char *audio_url;
|
||||
const char *audio_headers;
|
||||
bool local;
|
||||
bool restricted;
|
||||
bool live;
|
||||
double duration;
|
||||
} CastMediaSource;
|
||||
|
||||
// create copies input. start/probe/destroy may block; call off the UI thread.
|
||||
CastMedia *cast_media_create(const CastMediaSource *source, char error[512]);
|
||||
bool cast_media_start(CastMedia *media, const char *receiver_host, char url[512], double *duration, char error[512]);
|
||||
// Copies a validated UTF-8 WebVTT resource, at most 4 MiB/track and 32 tracks.
|
||||
bool cast_media_subtitle(CastMedia *media, int identifier, const void *bytes, size_t size, char url[512]);
|
||||
#ifdef CAST_MEDIA_CHECK
|
||||
// Dedicated check build only: fixed monotonic clock; waits for publication.
|
||||
bool cast_media_check_advance(CastMedia *media, double seconds);
|
||||
#endif
|
||||
// Nonblocking cancellation; destroy subsequently joins all workers and closes sockets.
|
||||
void cast_media_cancel(CastMedia *media);
|
||||
void cast_media_destroy(CastMedia *media);
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,88 @@
|
||||
#pragma once
|
||||
#include <array>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <arpa/inet.h>
|
||||
|
||||
namespace playback {
|
||||
inline 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;
|
||||
}
|
||||
inline 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;
|
||||
}
|
||||
inline std::string networkOrigin(const char *text) {
|
||||
if (!validNetworkURL(text)) return {};
|
||||
std::string_view url(text);
|
||||
const auto start = url.starts_with("https://") ? 8u : 7u;
|
||||
std::string authority(url.substr(start, url.find_first_of("/?#", start) - start));
|
||||
for (char &value : authority)
|
||||
if (value >= 'A' && value <= 'Z') value += 'a' - 'A';
|
||||
const auto colon = authority.front() == '[' ? authority.find(':', authority.find(']')) : authority.find(':');
|
||||
if (colon == std::string::npos) authority += start == 8 ? ":443" : ":80";
|
||||
else authority.replace(colon + 1, std::string::npos, std::to_string(std::stoul(authority.substr(colon + 1))));
|
||||
return std::string(url.substr(0, start)) + authority;
|
||||
}
|
||||
}
|
||||
+43
-88
@@ -33,6 +33,7 @@ extern "C" {
|
||||
#include <arpa/inet.h>
|
||||
#include <vector>
|
||||
#include "network_input.hpp"
|
||||
#include "network_policy.hpp"
|
||||
|
||||
namespace {
|
||||
constexpr int sampleRate = 48000;
|
||||
@@ -73,7 +74,7 @@ struct Track {
|
||||
uint64_t timestampOccurrence = 0;
|
||||
AVStream *stream() const { return format->streams[index]; }
|
||||
};
|
||||
enum class Action { Open, Play, Pause, Seek, Step, Close };
|
||||
enum class Action { Open, Play, Pause, Seek, Step, Close, Remote };
|
||||
struct Command {
|
||||
Action action;
|
||||
std::string path;
|
||||
@@ -89,6 +90,7 @@ struct Command {
|
||||
bool live = false;
|
||||
std::string mediaURL{};
|
||||
double initialPosition = 0;
|
||||
PlayerState remoteState{};
|
||||
};
|
||||
|
||||
void copyError(char *destination, const char *message) noexcept {
|
||||
@@ -100,90 +102,9 @@ int64_t presentationTimestamp(const AVFrame *frame) {
|
||||
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;
|
||||
}
|
||||
|
||||
std::string networkOrigin(const char *text) {
|
||||
if (!validNetworkURL(text)) return {};
|
||||
std::string_view url(text);
|
||||
const auto start = url.starts_with("https://") ? 8u : 7u;
|
||||
std::string authority(url.substr(start, url.find_first_of("/?#", start) - start));
|
||||
for (char &value : authority)
|
||||
if (value >= 'A' && value <= 'Z') value += 'a' - 'A';
|
||||
const auto colon = authority.front() == '[' ? authority.find(':', authority.find(']')) : authority.find(':');
|
||||
if (colon == std::string::npos) authority += start == 8 ? ":443" : ":80";
|
||||
else {
|
||||
// Canonicalize explicit default ports and leading zeroes.
|
||||
authority.replace(colon + 1, std::string::npos, std::to_string(std::stoul(authority.substr(colon + 1))));
|
||||
}
|
||||
return std::string(url.substr(0, start)) + authority;
|
||||
}
|
||||
using playback::validNetworkURL;
|
||||
using playback::validHeaders;
|
||||
using playback::networkOrigin;
|
||||
|
||||
int resolvedIOOpen(AVFormatContext *format, AVIOContext **pb, const char *url,
|
||||
int flags, AVDictionary **options) noexcept {
|
||||
@@ -275,6 +196,7 @@ struct Player {
|
||||
bool previewShown = false;
|
||||
std::array<char, 512> error{};
|
||||
std::unique_ptr<playback::ProgressReporter> reporter;
|
||||
bool remoteReporting = false;
|
||||
std::thread worker;
|
||||
|
||||
Player() : worker([this] { run(); }) {}
|
||||
@@ -436,7 +358,7 @@ struct Player {
|
||||
}
|
||||
std::memcpy(state.error, error.data(), error.size());
|
||||
++state.revision;
|
||||
if (reporter && reportingInputsHealthy()) reporter->observe(state);
|
||||
if (reporter && !remoteReporting && reportingInputsHealthy()) reporter->observe(state);
|
||||
}
|
||||
|
||||
bool reportingInputsHealthy() const {
|
||||
@@ -445,7 +367,7 @@ struct Player {
|
||||
}
|
||||
|
||||
void reportBoundary() {
|
||||
if (!reporter || !reportingInputsHealthy()) return;
|
||||
if (!reporter || remoteReporting || !reportingInputsHealthy()) return;
|
||||
// This is the old worker-owned timeline, even if a new open has already
|
||||
// replaced the public state with OPENING. Never sample a queued seek target.
|
||||
PlayerState snapshot{};
|
||||
@@ -624,6 +546,7 @@ struct Player {
|
||||
|
||||
void open(const Command &command) {
|
||||
reportBoundary();
|
||||
remoteReporting = false;
|
||||
if (reporter) reporter->begin(command.mediaURL, command.live);
|
||||
release();
|
||||
network = command.network;
|
||||
@@ -977,7 +900,9 @@ struct Player {
|
||||
}
|
||||
|
||||
void execute(const Command &command) {
|
||||
if (command.epoch != requestedEpoch.load(std::memory_order_relaxed)) return;
|
||||
// Receiver facts precede the next open/close even when it supersedes decoder I/O.
|
||||
if (command.action == Action::Remote ? command.epoch != activeEpoch :
|
||||
command.epoch != requestedEpoch.load(std::memory_order_relaxed)) return;
|
||||
activeEpoch = command.epoch;
|
||||
if (reporter && (command.action == Action::Seek || command.action == Action::Step)) {
|
||||
// Local seeks can finish without a pending publication. Preserve
|
||||
@@ -991,9 +916,18 @@ struct Player {
|
||||
case Action::Pause: pause(); break;
|
||||
case Action::Seek: seek(command.seconds, status == PLAYER_PLAYING); break;
|
||||
case Action::Step: step(command.direction); break;
|
||||
case Action::Remote:
|
||||
if (!remoteReporting) {
|
||||
reportBoundary();
|
||||
release();
|
||||
}
|
||||
remoteReporting = true;
|
||||
if (reporter) reporter->observe(command.remoteState);
|
||||
return;
|
||||
case Action::Close:
|
||||
reportBoundary();
|
||||
if (reporter) reporter->closeMedia();
|
||||
remoteReporting = false;
|
||||
release();
|
||||
break;
|
||||
}
|
||||
@@ -1418,6 +1352,15 @@ struct Player {
|
||||
}
|
||||
}
|
||||
try {
|
||||
if (reporter) {
|
||||
std::lock_guard<std::mutex> lock(mutex);
|
||||
for (const auto &command : commands) {
|
||||
if (command.action == Action::Remote && command.epoch == activeEpoch) {
|
||||
reporter->observe(command.remoteState);
|
||||
remoteReporting = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
reportBoundary();
|
||||
if (reporter) reporter->closeMedia();
|
||||
release();
|
||||
@@ -1520,6 +1463,18 @@ bool player_step_frame(Player *player, int direction) {
|
||||
bool player_close(Player *player) {
|
||||
return submit(player, [=] { return player->enqueue({Action::Close, {}}); });
|
||||
}
|
||||
bool player_report_remote_state(Player *player, const PlayerState *state) {
|
||||
if (!state || !std::isfinite(state->position) || state->position < 0 ||
|
||||
!std::isfinite(state->duration) || state->duration < 0 ||
|
||||
(state->status != PLAYER_PAUSED && state->status != PLAYER_PLAYING &&
|
||||
state->status != PLAYER_ENDED)) return false;
|
||||
const PlayerState snapshot = *state;
|
||||
return submit(player, [=] {
|
||||
Command command{Action::Remote, {}};
|
||||
command.remoteState = snapshot;
|
||||
return player->enqueue(std::move(command));
|
||||
});
|
||||
}
|
||||
bool player_set_volume(Player *player, float normalized0to1) {
|
||||
if (!std::isfinite(normalized0to1) || normalized0to1 < 0 || normalized0to1 > 1) return false;
|
||||
return submit(player, [=] {
|
||||
|
||||
@@ -71,6 +71,9 @@ bool player_seek(Player *player, double seconds);
|
||||
// Pauses playback and selects the adjacent decoded presentation frame.
|
||||
bool player_step_frame(Player *player, int direction);
|
||||
bool player_close(Player *player);
|
||||
// Receiver-confirmed Cast facts replace decoder facts in progress reporting until
|
||||
// the next open/close. The local decoder must be paused before submitting them.
|
||||
bool player_report_remote_state(Player *player, const PlayerState *state);
|
||||
// Synchronous, thread-safe gain; survives open, seek, and close. Zero mutes.
|
||||
// Rejects null handles and non-finite/out-of-range values without changing gain.
|
||||
bool player_set_volume(Player *player, float normalized0to1);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#ifndef PERSONAL_PLAYER_MACOS_H
|
||||
#define PERSONAL_PLAYER_MACOS_H
|
||||
#include "player.h"
|
||||
#include "cast_media.h"
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
Reference in New Issue
Block a user