feat: add Chromecast playback support

This commit is contained in:
2026-10-13 17:31:37 +02:00
parent 74b4bfe6c1
commit bb777b0ecd
22 changed files with 3299 additions and 115 deletions
+48
View File
@@ -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")
}
}
+145
View File
@@ -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()
}
}
+806
View File
@@ -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) }
}
}
+8 -4
View File
@@ -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 {
+2
View File
@@ -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
View File
@@ -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))