feat: add Chromecast playback support
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user