refactor: concentrate Cast ownership and preserve intent on disconnect

This commit is contained in:
2026-10-16 21:06:53 +02:00
parent f1fbcf30b5
commit 367afdfb07
8 changed files with 600 additions and 205 deletions
+3 -2
View File
@@ -238,6 +238,7 @@ enum CastWire {
final class CastSession {
var onStatus: ((CastStatus) -> Void)?
var onDisconnect: ((String?) -> Void)?
// Recoverable command errors; terminal channel failures belong to onDisconnect.
var onError: ((String) -> Void)?
var localHost: String? {
guard let endpoint = connection?.currentPath?.localEndpoint,
@@ -499,7 +500,7 @@ final class CastSession {
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 }
guard let self, self.connection != nil else { return }
if self.pendingVolume?.request == id { self.pendingVolume = nil; self.publishSeekState() }
if case .cancelled? = error { return }
if self.pendingSeek == id { self.pendingSeek = nil; self.publishSeekState() }
@@ -760,7 +761,7 @@ final class CastSession {
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) }
if let self, self.connection != nil, let error { self.onError?(error.localizedDescription) }
}
}
} catch { self.fail(error as? CastError ?? .disconnected); return }
+307 -176
View File
@@ -29,17 +29,9 @@ final class PlayerModel: ObservableObject {
@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 lazy var casting = CastPlaybackTransition { [weak self] event in self?.receiveCast(event) }
var castDevice: CastDevice? { casting.device }
var castConnecting: Bool { casting.connecting }
private var currentMedia: MediaSource?
var pendingRevision: UInt64? { transition.pendingRevision }
var resolvingYouTube: Bool { transition.isResolving }
@@ -69,7 +61,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? { castActive ? castIntent : transition.playbackIntent }
var playbackIntent: Bool? { casting.active ? casting.playbackIntent : transition.playbackIntent }
@Published private(set) var fullScreenIntent: Bool?
@Published private(set) var compactIntent: Bool?
var effectivePlaying: Bool { playbackIntent ?? (state.status == PLAYER_PLAYING) }
@@ -162,7 +154,7 @@ final class PlayerModel: ObservableObject {
systemPlayback = SystemPlaybackControls(model: self)
}
var isBusy: Bool { castConnecting || (!castActive && transition.isBusy(state.status)) }
var isBusy: Bool { castConnecting || (!casting.active && transition.isBusy(state.status)) }
var canTogglePlayback: Bool {
handle != nil && !isBusy &&
(state.status == PLAYER_PAUSED || state.status == PLAYER_PLAYING || state.status == PLAYER_ENDED)
@@ -200,167 +192,58 @@ 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
casting.start(to: device, source: source, title: fileName ?? "terebi", subtitle: selectedSubtitle,
handle: handle, snapshot: state, playing: effectivePlaying,
volume: volume, audibleVolume: lastAudibleVolume)
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)
casting.stop(resume: resume)
}
private func receiveCast(_ event: CastPlaybackTransition.Event) {
objectWillChange.send()
switch event {
case .changed:
break
case .activated:
transition.acceptCast()
subtitleTask?.cancel()
subtitleText = ""
case .status(let projection):
state = projection.state
volume = projection.volume
if volume > 0 { lastAudibleVolume = volume }
selectedSubtitle = projection.subtitle
if !isSeeking && !state.seek_pending { seekPosition = state.position }
if let handle { _ = player_report_remote_state(handle, &state) }
saveProgress()
case .stopped(let result):
if let checkpoint = result.checkpoint {
saveCastProgress(checkpoint, source: result.source)
} else {
saveProgress()
}
if let revision = result.suspendedRevision { transition.suspendHistory(revision: revision) }
volume = result.volume
lastAudibleVolume = result.audibleVolume
if let restore = result.restore {
openMedia(result.source, change: .restore(position: restore.position, playing: restore.playing))
}
revealControls()
case .error(let message):
commandError = message
revealControls()
}
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 }
let accepted = casting.seek(position)
if accepted { seekPosition = position }
commandError = accepted ? nil : "Could not seek Cast playback."
revealControls()
return accepted
@@ -422,8 +305,8 @@ final class PlayerModel: ObservableObject {
}
func selectSubtitle(_ index: Int?) {
if castActive {
if castSession?.setSubtitle(index) != true { commandError = "Could not change Cast subtitles." }
if casting.active {
if !casting.setSubtitle(index) { commandError = "Could not change Cast subtitles." }
return
}
subtitleTask?.cancel()
@@ -656,10 +539,9 @@ final class PlayerModel: ObservableObject {
@discardableResult
func setPlayback(playing: Bool) -> Bool {
if castActive {
if casting.active {
guard canTogglePlayback else { return false }
let accepted = castSession?.setPlaying(playing) == true
if accepted { castIntent = playing; objectWillChange.send() }
let accepted = casting.setPlaying(playing)
commandError = accepted ? nil : "Could not change Cast playback."
revealControls()
return accepted
@@ -686,7 +568,7 @@ final class PlayerModel: ObservableObject {
automaticQuality.resetHealth()
isSeeking = false
guard let handle, canSeek else { return }
if castActive {
if casting.active {
seekCast(to: min(max(0, seekPosition), duration))
return
}
@@ -733,7 +615,7 @@ final class PlayerModel: ObservableObject {
if transition.resolvingAutomaticQuality { cancelYouTubeResolution() }
automaticQuality.resetHealth()
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)
let accepted = casting.active ? 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() }
@@ -750,8 +632,8 @@ final class PlayerModel: ObservableObject {
}
func setVolume(_ value: Double) {
if castActive {
if value.isFinite, (0...1).contains(value), castSession?.setVolume(value) == true {
if casting.active {
if casting.setVolume(value) {
volume = value
if volume > 0 { lastAudibleVolume = volume }
}
@@ -1012,15 +894,19 @@ final class PlayerModel: ObservableObject {
private func saveProgress(_ snapshot: PlayerState? = nil, force: Bool = true) {
guard let handle else { return }
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)
if let checkpoint = casting.checkpoint {
saveCastProgress(checkpoint.state, source: checkpoint.source)
} else {
transition.saveProgress(snapshot, handle: handle, force: force, history: playbackProgress)
}
}
private func saveCastProgress(_ state: PlayerState, source: MediaSource) {
guard !source.live, 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: source.url)
}
private func poll() {
guard let handle else { return }
if reportingEnabled && !reportingErrorShown {
@@ -1028,7 +914,7 @@ final class PlayerModel: ObservableObject {
showReportingError(reportingErrorMessage)
}
}
if !castActive { applyState(player_get_state(handle)) }
if !casting.active { applyState(player_get_state(handle)) }
}
func applyState(_ next: PlayerState) {
@@ -1109,6 +995,251 @@ final class PlayerModel: ObservableObject {
player_destroy(player)
}
@MainActor
private final class CastPlaybackTransition {
struct Projection {
var state: PlayerState
var volume: Double
var subtitle: Int?
}
struct Return {
let source: MediaSource
let checkpoint: PlayerState?
let suspendedRevision: UInt64?
let volume: Double
let audibleVolume: Double
let restore: (position: Double, playing: Bool)?
}
enum Event {
case changed
case activated
case status(Projection)
case stopped(Return)
case error(String)
}
private enum HandoffError: LocalizedError {
case pause, acknowledgement, readiness
var errorDescription: String? {
switch self {
case .pause: "Could not pause local playback for casting."
case .acknowledgement: "Local playback did not acknowledge the Cast handoff."
case .readiness: "Local playback is not ready to cast."
}
}
}
@MainActor
private final class Operation {
let device: CastDevice
let source: MediaSource
let title: String
let subtitle: Int?
let handle: OpaquePointer
let snapshot: PlayerState
let playing: Bool
let volume: Double
let audibleVolume: Double
let session = CastSession()
let server = CastMediaServer()
var task: Task<Void, Never>?
var initialStatus: CastStatus?
var projection: Projection?
var intent: Bool?
init(device: CastDevice, source: MediaSource, title: String, subtitle: Int?,
handle: OpaquePointer, snapshot: PlayerState, playing: Bool,
volume: Double, audibleVolume: Double) {
self.device = device
self.source = source
self.title = title
self.subtitle = subtitle
self.handle = handle
self.snapshot = snapshot
self.playing = playing
self.volume = volume
self.audibleVolume = audibleVolume
}
}
private var operation: Operation?
private let observe: (Event) -> Void
init(observe: @escaping (Event) -> Void) { self.observe = observe }
var device: CastDevice? { operation?.device }
var connecting: Bool { operation != nil && !active }
var active: Bool { operation?.projection != nil }
var playbackIntent: Bool? { operation?.intent }
var checkpoint: (source: MediaSource, state: PlayerState)? {
guard let operation, let projection = operation.projection else { return nil }
return (operation.source, projection.state)
}
func start(to device: CastDevice, source: MediaSource, title: String, subtitle: Int?,
handle: OpaquePointer, snapshot: PlayerState, playing: Bool,
volume: Double, audibleVolume: Double) {
guard operation == nil else { return }
let operation = Operation(device: device, source: source, title: title, subtitle: subtitle,
handle: handle, snapshot: snapshot, playing: playing,
volume: volume, audibleVolume: audibleVolume)
self.operation = operation
operation.session.onStatus = { [weak self, weak operation] status in
guard let self, let operation, self.operation === operation else { return }
if operation.projection == nil { operation.initialStatus = status }
else { self.receive(status, operation: operation) }
}
operation.session.onDisconnect = { [weak self, weak operation] message in
guard let self, let operation, self.operation === operation else { return }
self.stop()
if let message { self.observe(.error(message)) }
}
operation.session.onError = { [weak self, weak operation] message in
guard let self, let operation, self.operation === operation else { return }
if !operation.session.playbackPending { operation.intent = nil }
self.observe(.error(message))
}
observe(.changed)
operation.task = Task { [weak self] in await self?.connect(operation) }
}
private func connect(_ operation: Operation) async {
do {
try check(operation)
try await operation.session.connect(to: operation.device)
try check(operation)
guard player_pause(operation.handle) else { throw HandoffError.pause }
let deadline = ContinuousClock.now.advanced(by: .seconds(5))
while player_get_state(operation.handle).status == PLAYER_PLAYING {
guard ContinuousClock.now < deadline else { throw HandoffError.acknowledgement }
try await Task.sleep(for: .milliseconds(20))
try check(operation)
}
let snapshot = player_get_state(operation.handle)
guard snapshot.status == PLAYER_PAUSED || snapshot.status == PLAYER_ENDED else {
throw HandoffError.readiness
}
let position = snapshot.can_seek && !operation.source.live && snapshot.status != PLAYER_ENDED ?
snapshot.position : 0
let media = try await operation.server.start(input: input(operation.source, duration: snapshot.duration),
receiverHost: operation.device.host)
try check(operation)
try await operation.session.load(media, title: operation.title, position: position,
playing: operation.playing, subtitle: operation.subtitle)
try check(operation)
var state = operation.snapshot
state.buffered_start = 0
state.buffered_end = 0
operation.projection = Projection(state: state, volume: operation.volume, subtitle: operation.subtitle)
operation.task = nil
observe(.activated)
if let status = operation.initialStatus {
operation.initialStatus = nil
receive(status, operation: operation)
} else if let projection = operation.projection {
observe(.status(projection))
}
} catch {
guard self.operation === operation else { return }
stop()
if !(error is CancellationError) { observe(.error(error.localizedDescription)) }
}
}
private func check(_ operation: Operation) throws {
try Task.checkCancellation()
guard self.operation === operation else { throw CancellationError() }
}
func stop(resume: Bool = true) {
guard let operation else { return }
let projection = operation.projection
let playing = operation.intent ?? projection.map { $0.state.status == PLAYER_PLAYING } ?? operation.playing
let position = projection.map {
$0.state.can_seek && !operation.source.live && $0.state.status != PLAYER_ENDED ? $0.state.position : 0
} ?? 0
let revision = projection == nil ? nil : player_get_state(operation.handle).revision
// Invalidate before teardown callbacks or any suspended task resumes. All native
// accesses are main-actor synchronous, with check() after every suspension:
// shutdown can destroy the player immediately after stop(resume: false).
self.operation = nil
operation.task?.cancel()
operation.task = nil
operation.session.onStatus = nil
operation.session.onDisconnect = nil
operation.session.onError = nil
operation.session.disconnect()
operation.server.stop()
_ = player_set_volume(operation.handle, Float(operation.volume))
if resume && projection == nil {
_ = operation.playing ? player_play(operation.handle) : player_pause(operation.handle)
}
observe(.stopped(Return(source: operation.source, checkpoint: projection?.state,
suspendedRevision: revision, volume: operation.volume,
audibleVolume: operation.audibleVolume,
restore: resume && projection != nil ? (position, playing) : nil)))
}
func setPlaying(_ playing: Bool) -> Bool {
guard let operation, operation.projection != nil, operation.session.setPlaying(playing) else { return false }
operation.intent = playing
observe(.changed)
return true
}
func seek(_ position: Double) -> Bool {
guard let operation, operation.projection != nil else { return false }
return operation.session.seek(position)
}
func setVolume(_ volume: Double) -> Bool {
guard let operation, operation.projection != nil else { return false }
return operation.session.setVolume(volume)
}
func setSubtitle(_ index: Int?) -> Bool {
guard let operation, operation.projection != nil else { return false }
return operation.session.setSubtitle(index)
}
private func receive(_ status: CastStatus, operation: Operation) {
guard var projection = operation.projection else { return }
projection.state.status = status.ended ? PLAYER_ENDED : (status.playing ? PLAYER_PLAYING : PLAYER_PAUSED)
projection.state.position = status.position
if status.duration > 0 { projection.state.duration = status.duration }
projection.state.buffering = status.buffering
projection.state.can_seek = status.seekable && !operation.source.live
projection.state.seek_pending = status.seekPending
projection.state.buffered_start = 0
projection.state.buffered_end = 0
projection.state.revision &+= 1
projection.volume = status.volume
projection.subtitle = status.subtitle
if !status.playbackPending { operation.intent = nil }
operation.projection = projection
observe(.status(projection))
}
private func input(_ 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 enum MediaChange {
case open
case quality(position: Double?, playing: Bool, automatic: Bool)