fix: separate Cast launch readiness, preserve volume intent and reuse seek inputs

This commit is contained in:
2026-10-13 21:40:19 +02:00
parent 697158aa02
commit b9e63cd760
8 changed files with 365 additions and 55 deletions
+49 -29
View File
@@ -271,6 +271,7 @@ final class CastSession {
private var pending: [Int: Pending] = [:]
private var writes: [Data] = []
private var writing = false
private var launchRequest: Int?
private var appTransport: String?
private var appSession: String?
private var mediaSession: Int?
@@ -291,6 +292,8 @@ final class CastSession {
private var replayTask: Task<Void, Never>?
private var replayDesiredPlaying = true
private var receiverVolume = 1.0
private var pendingVolume: (request: Int, level: Double)?
private var latestVolumeRequest = 0
init(requestTimeout: Duration = .seconds(8), loadTimeout: Duration = .seconds(25),
heartbeatInterval: Duration = .seconds(5),
@@ -359,31 +362,17 @@ final class CastSession {
private func beginHandshake() {
do {
// TLS establishment and a cold receiver application have separate deadlines.
readyDeadline?.cancel()
readyDeadline = nil
try send(["type": "CONNECT", "origin": [:], "userAgent": "Terebi"],
namespace: CastWire.connection, destination: "receiver-0")
startHeartbeat()
let id = allocateRequest()
launchRequest = id
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()
destination: "receiver-0", operation: "receiver launch", timeout: .seconds(30), id: id) { [weak self] _, error in
if let error { self?.fail(error) }
}
} catch { fail(error as? CastError ?? .protocolError) }
}
@@ -511,6 +500,7 @@ final class CastSession {
operation: (command["type"] as? String ?? "command").lowercased(),
generation: mediaGeneration, id: id) { [weak self] _, error in
guard let self 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() }
if self.pendingPlayback == id { self.pendingPlayback = nil; self.publishSeekState() }
@@ -518,6 +508,11 @@ final class CastSession {
}
if seeking { pendingSeek = id; publishSeekState() }
if changingPlayback { pendingPlayback = id; publishSeekState() }
if type == "SET_VOLUME", let level = (command["volume"] as? [String: Any])?["level"] as? Double {
latestVolumeRequest = id
pendingVolume = (id, level)
publishSeekState()
}
return true
} catch { onError?(error.localizedDescription); return false }
}
@@ -624,21 +619,45 @@ final class CastSession {
}
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 olderVolumeReply = id.map { $0 > 0 && $0 < latestVolumeRequest } ?? false
let acknowledgesVolume = correlated && id == pendingVolume?.request
if !olderVolumeReply, pendingVolume == nil || acknowledgesVolume,
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 acknowledgesVolume { pendingVolume = nil }
if let latestStatus { publish(latestStatus) }
}
// Receiver readiness can be broadcast before/without the LAUNCH reply.
// Only the requested Default Media Receiver on receiver-0 can satisfy it.
if let launchRequest, 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 {
appTransport = transport
appSession = session
do {
try send(["type": "CONNECT", "origin": [:]], namespace: CastWire.connection, destination: transport)
} catch { fail(error as? CastError ?? .protocolError); return }
self.launchRequest = nil
complete(launchRequest, json: json, error: nil)
let ready = self.ready
self.ready = nil
ready?.resume()
}
if let appSession, let appTransport {
let apps = status["applications"] as? [[String: Any]] ?? []
guard apps.contains(where: { $0["sessionId"] as? String == appSession &&
guard apps.contains(where: { $0["appId"] as? String == "CC1AD845" &&
$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) }
if correlated, let id, id != launchRequest, id != pendingVolume?.request {
complete(id, json: json, error: nil)
}
return
}
guard message.namespace == CastWire.media, message.source == appTransport,
@@ -700,7 +719,7 @@ final class CastSession {
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,
volume: pendingVolume?.level ?? receiverVolume,
seekable: !live && (supported.map { $0 & 2 != 0 } ?? latestStatus?.seekable ?? false),
subtitle: subtitle, seekPending: pendingSeek != nil, playbackPending: playbackPending)
latestStatus = next
@@ -711,9 +730,9 @@ final class CastSession {
}
}
private func publish(_ status: CastStatus, volume: Double? = nil) {
private func publish(_ status: CastStatus) {
let next = CastStatus(position: status.position, duration: status.duration, playing: status.playing,
buffering: status.buffering, ended: status.ended, volume: volume ?? status.volume,
buffering: status.buffering, ended: status.ended, volume: pendingVolume?.level ?? receiverVolume,
seekable: status.seekable, subtitle: status.subtitle, seekPending: pendingSeek != nil,
playbackPending: playbackPending)
latestStatus = next
@@ -783,13 +802,14 @@ final class CastSession {
let requests = pending.values
pending.removeAll()
writes.removeAll(); writing = false
appTransport = nil; appSession = nil; mediaSession = nil
appTransport = nil; appSession = nil; mediaSession = nil; launchRequest = nil
retiredSessions.removeAll(); contentID = nil; mediaGeneration = nil
retiredLoads.removeAll()
tracks.removeAll(); latestStatus = nil; pendingSeek = nil; loading = false
pendingPlayback = nil
loadedMedia = nil; loadedTitle = ""
receiverVolume = 1
pendingVolume = nil; latestVolumeRequest = 0
ready?.resume(throwing: error ?? CastError.cancelled)
for request in requests {
request.deadline.cancel()
+21 -1
View File
@@ -1469,7 +1469,7 @@ private struct PlaybackSlider: NSViewRepresentable {
context.coordinator.parent = self
slider.minValue = range.lowerBound
slider.maxValue = range.upperBound
slider.doubleValue = value
slider.setModelValue(value)
slider.isEnabled = enabled
slider.editingChanged = onEditingChanged
if let cell = slider.cell as? Cell, cell.bufferedRange != bufferedRange {
@@ -1493,6 +1493,21 @@ private struct PlaybackSlider: NSViewRepresentable {
final class Slider: NSSlider {
var editingChanged: (Bool) -> Void = { _ in }
private(set) var tracking = false
func setModelValue(_ value: Double) { if !tracking { doubleValue = value } }
#if PLAYER_CHECK
static func feedbackCheck() -> Bool {
let slider = Slider()
slider.minValue = 0
slider.maxValue = 1
slider.doubleValue = 0.8
slider.tracking = true
slider.setModelValue(0.79)
let heldUserValue = slider.doubleValue == 0.8
slider.tracking = false
slider.setModelValue(0.79)
return heldUserValue && slider.doubleValue == 0.79
}
#endif
override func mouseDown(with event: NSEvent) {
guard isEnabled else { return }
tracking = true
@@ -1538,6 +1553,11 @@ private struct PlaybackSlider: NSViewRepresentable {
}
}
#if PLAYER_CHECK
@MainActor
func castVolumeSliderFeedbackCheck() -> Bool { PlaybackSlider.Slider.feedbackCheck() }
#endif
@MainActor
private struct VideoSurface: NSViewRepresentable {
let view: NSView