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
+4 -1
View File
@@ -7,6 +7,9 @@ Terebi plays local media, YouTube videos and resolved Miruro episodes. These ter
**Playback transition**:
A requested change of media, playback state, position, or quality that may still be awaiting completion. Acceptance of a request does not mean playback has reached the requested state.
**Cast playback transition**:
A playback transition that transfers playback between this Mac and a Cast receiver. Connection alone does not transfer playback; the destination must acknowledge the handoff, and interruption must preserve the latest playback intent and acknowledged position.
**Playback intent**:
The user's latest requested playing or paused state, which can differ from the state playback has acknowledged.
@@ -14,7 +17,7 @@ The user's latest requested playing or paused state, which can differ from the s
The playback position confirmed by the player after any pending seek or restoration has completed. A seek target or slider preview is not an acknowledged position.
**Restoration**:
Resuming a video's saved position, or preserving position and playing or paused state while changing its quality.
Resuming a video's saved position, or preserving position and playing or paused state while changing its quality or returning from a Cast receiver.
**Media identity**:
The identity of the video being played, independent of its selected quality or extracted stream address. For a YouTube video, this is its canonical video webpage URL. For a Miruro episode, this is its opaque catalog media ID and episode number, independent of provider and expiring source URL.
+2 -2
View File
@@ -108,8 +108,8 @@ struct AutomaticQualityPlaybackChecks {
model.youtubeHeight == 720 && model.canTogglePlayback && model.state.status == PLAYER_PLAYING &&
!model.state.seek_pending && (model.bufferedRange.map { $0.upperBound - model.state.position >= 20 } ?? false)
}
require(model.state.position >= position && model.state.position < position + 3,
"automatic quality reopening preserves current playback progress")
require(model.state.position >= position,
"automatic quality reopening does not reset acknowledged progress")
polled(model)
clock.now = 2063
until(model, "sustained health automatically restores actual 1080p media") {
+152
View File
@@ -17,6 +17,153 @@ struct CastPlaybackCheck {
require(predicate(), message)
}
static func interrupted(_ model: PlayerModel, source: URL, port: UInt16,
scenario: String, gates: URL, defaults: UserDefaults) async throws {
func signal(_ name: String) throws {
try Data().write(to: gates.appendingPathComponent(name), options: .atomic)
}
func awaitSignal(_ name: String) async throws {
try await until(model, "\(scenario): fixture signals \(name)", permitError: true) {
FileManager.default.fileExists(atPath: gates.appendingPathComponent(name).path)
}
}
let replacement = gates.appendingPathComponent("replacement.mp4")
try FileManager.default.copyItem(at: source, to: replacement)
let active = scenario.hasSuffix("-active")
let expectedError = scenario == "reject-load" || scenario == "disconnect-active"
model.setVolume(0)
model.open(source)
try await until(model, "interruption source plays") { model.state.status == PLAYER_PLAYING && model.canSeek }
require(model.setPlayback(playing: false), "initial local pause accepted")
try await until(model, "initial local pause acknowledged") {
model.state.status == PLAYER_PAUSED && model.playbackIntent == nil
}
require(model.seekRelative(9.25 - model.state.position), "initial local position accepted")
try await until(model, "initial local position acknowledged") {
!model.state.seek_pending && abs(model.state.position - 9.25) < 0.15
}
if !active {
require(model.setPlayback(playing: true), "local playback starts before pending handoff")
try await until(model, "local playback acknowledged before handoff") {
model.state.status == PLAYER_PLAYING && model.playbackIntent == nil
}
}
let beforeConnect = model.state.position
model.cast(to: CastDevice(id: "loopback", name: "Loopback TV", host: "127.0.0.1", port: port))
if active {
try await until(model, "active receiver acknowledges handoff") {
model.castDevice != nil && !model.castConnecting && model.canSeek && model.state.status == PLAYER_PAUSED
}
require(abs(model.state.position - 9.25) < 0.15, "active receiver starts at acknowledged local position")
require(model.volume == 1, "active receiver volume is visible before local rollback")
if scenario == "disconnect-active" {
require(model.setPlayback(playing: true), "initial receiver PLAY accepted")
try await until(model, "initial receiver PLAY acknowledged") {
model.state.status == PLAYER_PLAYING && model.playbackIntent == nil
}
}
try signal("active")
}
try await awaitSignal("held")
if !active {
require(model.castDevice != nil && model.castConnecting, "held handoff remains pending")
if scenario == "cancel-connect" {
try await until(model, "pending connection does not pause local playback") {
model.state.status == PLAYER_PLAYING && model.state.position > beforeConnect
}
} else {
try await until(model, "pending LOAD follows acknowledged local pause") { model.state.status == PLAYER_PAUSED }
}
}
if scenario == "reject-load" {
try signal("observed")
try await until(model, "LOAD rejection restores original local playback", permitError: true) {
model.castDevice == nil && !model.castConnecting && model.commandError != nil &&
model.state.status == PLAYER_PLAYING && model.canSeek
}
} else {
switch scenario {
case "cancel-connect", "cancel-load":
model.stopCasting()
case "replace-load", "replace-active":
model.open(replacement)
case "close-load", "close-active":
model.closeFile()
case "shutdown-load", "shutdown-active":
model.shutdown()
case "disconnect-active":
require(model.setPlayback(playing: false) && model.setPlayback(playing: true) &&
model.setPlayback(playing: false), "latest unacknowledged receiver PAUSE accepted")
require(model.playbackIntent == false && model.state.status == PLAYER_PLAYING,
"latest intent remains distinct from acknowledged receiver playback")
require(model.seekRelative(10), "receiver seek preview accepted before disconnect")
require(model.state.seek_pending, "receiver seek remains pending before disconnect")
default:
fatalError("Unknown interruption scenario: " + scenario)
}
try signal("interrupted")
}
try await awaitSignal("released")
if scenario.hasPrefix("shutdown-") {
require(model.castDevice == nil && !model.castConnecting && !model.canOpen && !model.canClose,
"shutdown releases Cast ownership and permanently disables the local player")
require(!model.setPlayback(playing: true) && !model.seekRelative(5) && model.volume == 0,
"late receiver traffic cannot resume a destroyed player or replace local volume")
print("PASS: " + scenario)
return
}
if scenario == "disconnect-active" {
try await until(model, "disconnect restores latest intent at acknowledged position", permitError: true) {
model.castDevice == nil && !model.castConnecting && !model.isBusy &&
model.state.status == PLAYER_PAUSED && !model.state.seek_pending && model.playbackIntent == nil &&
abs(model.state.position - 9.25) < 0.15
}
require(abs((PlaybackProgress(defaults: defaults).position(for: source) ?? 0) - 9.25) < 0.15,
"unacknowledged remote seek preview never replaces saved acknowledged position")
} else if scenario.hasPrefix("close-") {
try await until(model, "close acknowledges empty decoder after late receiver traffic") {
model.state.status == PLAYER_EMPTY && model.fileName == nil && model.castDevice == nil && !model.isBusy
}
require(model.playbackIntent == nil, "close does not leave an accidental resume intent")
model.open(replacement)
} else if scenario.hasPrefix("replace-") {
try await until(model, "replacement acknowledges its own local playback after late receiver traffic") {
model.castDevice == nil && !model.castConnecting && !model.isBusy &&
model.fileName == replacement.lastPathComponent && model.state.status == PLAYER_PLAYING && model.canSeek
}
} else {
try await until(model, "cancelled or rejected handoff leaves usable local playback", permitError: expectedError) {
model.castDevice == nil && !model.castConnecting && model.state.status == PLAYER_PLAYING && model.canSeek
}
}
require(model.volume == 0, "every interrupted handoff restores local volume")
if scenario != "disconnect-active" {
// A native acknowledgement is the barrier after released late receiver traffic;
// never sleep and infer that stale callbacks did nothing.
try await until(model, "local decoder is usable after interruption", permitError: expectedError) {
model.canSeek && !model.isBusy
}
require(model.setPlayback(playing: false), "post-interruption local pause accepted")
try await until(model, "post-interruption local pause acknowledged", permitError: expectedError) {
model.state.status == PLAYER_PAUSED && model.playbackIntent == nil
}
require(model.seekRelative(4.25 - model.state.position), "post-interruption local seek accepted")
try await until(model, "local seek acknowledges after obsolete receiver replies", permitError: expectedError) {
!model.state.seek_pending && abs(model.state.position - 4.25) < 0.15
}
require(model.castDevice == nil && !model.castConnecting && model.volume == 0,
"obsolete receiver callbacks cannot reclaim playback or volume")
if scenario.hasPrefix("replace-") || scenario.hasPrefix("close-") {
require(model.fileName == replacement.lastPathComponent, "interruption preserves replacement media identity")
}
}
model.closeFile()
try await until(model, "interrupted scenario closes all playback", permitError: expectedError) {
model.state.status == PLAYER_EMPTY && model.castDevice == nil && !model.castConnecting
}
print("PASS: " + scenario)
}
static func main() async throws {
_ = NSApplication.shared
NSApp.setActivationPolicy(.prohibited)
@@ -32,6 +179,11 @@ struct CastPlaybackCheck {
defer { defaults.removePersistentDomain(forName: suite) }
let model = PlayerModel(defaults: defaults)
defer { model.shutdown() }
if CommandLine.arguments.count == 5 {
try await interrupted(model, source: source, port: port, scenario: CommandLine.arguments[3],
gates: URL(fileURLWithPath: CommandLine.arguments[4]), defaults: defaults)
return
}
model.setVolume(0)
model.open(source)
try await until(model, "local source plays") { model.state.status == PLAYER_PLAYING && model.canSeek }
+123 -8
View File
@@ -87,6 +87,11 @@ private final class Receiver {
var rejectCommand = false
var holdSeek = false
var holdVolume = false
var holdLaunch = false
var holdLoad = false
var holdPlayback = false
private var heldLaunch: Int?
private var heldLoad: [String: Any]?
var ignoreHeartbeat = false
var verifyRelay = false
var relayVerified = false
@@ -138,6 +143,33 @@ private final class Receiver {
}
func stop() { socket?.cancel(); listener.cancel() }
func disconnect() { socket?.cancel() }
func releaseLaunch() throws {
guard let requestID = heldLaunch else { return }
heldLaunch = nil
try receiverStatus(requestID)
}
func releaseLoad(reject: Bool = false) async throws {
guard let command = heldLoad else { return }
heldLoad = nil
if reject {
try send(["type": "LOAD_FAILED", "requestId": command["requestId"] as? Int ?? 0], namespace: CastWire.media)
} else {
try await load(command)
}
}
private func load(_ json: [String: Any]) async throws {
media = json["media"] as! [String: Any]
session += 1
playing = json["autoplay"] as? Bool ?? true
position = json["currentTime"] as? Double ?? 0
activeTracks = json["activeTrackIds"] as? [Int] ?? []
if verifyRelay { try await fetchRelay() }
try status(requestID: json["requestId"] as? Int ?? 0)
}
private func receive(_ socket: NWConnection) {
socket.receive(minimumIncompleteLength: 1, maximumLength: 16384) { [weak self] data, _, ended, error in
@@ -219,20 +251,17 @@ private final class Receiver {
case "CLOSE": break
case "LAUNCH":
require(json["appId"] as? String == "CC1AD845", "Default Media Receiver, no registration")
if holdLaunch { heldLaunch = requestID; return }
if dropLaunch { return }
if rejectLaunch { try send(["type": "LAUNCH_ERROR", "requestId": requestID], namespace: CastWire.receiver) }
else { try receiverStatus(requestID) }
case "LOAD":
if holdLoad { heldLoad = json; return }
if rejectLoad { try send(["type": "LOAD_FAILED", "requestId": requestID], namespace: CastWire.media); return }
if dropLoad { return }
media = json["media"] as! [String: Any]
session += 1
playing = json["autoplay"] as? Bool ?? true
position = json["currentTime"] as? Double ?? 0
activeTracks = json["activeTrackIds"] as? [Int] ?? []
if verifyRelay { try await fetchRelay() }
try status(requestID: requestID)
try await load(json)
case "PLAY", "PAUSE", "SEEK", "GET_STATUS", "EDIT_TRACKS_INFO":
if holdPlayback, type == "PLAY" || type == "PAUSE" { return }
if dropCommands { return }
if rejectCommand {
try send(["type": "INVALID_PLAYER_STATE", "requestId": requestID], namespace: CastWire.media)
@@ -607,7 +636,7 @@ struct CastSessionCheck {
static func failures(identity: SecIdentity) async throws {
for mode in ["launch", "launch-timeout", "load", "deadline", "command", "rejection", "cancel", "frame",
"ownership", "media-error", "media-cancel", "media-interrupt", "heartbeat", "queue"] {
"ownership", "media-error", "media-cancel", "media-interrupt", "heartbeat", "queue", "pending-disconnect"] {
let peer = try Receiver(identity: identity)
try await peer.start()
let clock = CastCheckClock()
@@ -671,6 +700,15 @@ struct CastSessionCheck {
require(session.setPlaying(true), "Rejected command sent")
try await until("Command rejection reports recoverable error") { !errors.isEmpty }
require(!disconnected, "Command rejection is not channel failure")
case "pending-disconnect":
peer.dropCommands = true
require(session.setPlaying(true) && session.seek(12), "Playback and seek accepted before disconnect")
try await until("Pending controls reach receiver before disconnect") {
peer.commands.contains { $0.0 == "PLAY" } && peer.commands.contains { $0.0 == "SEEK" }
}
peer.disconnect()
try await until("Terminal failure is reported through disconnect") { disconnected }
require(errors.isEmpty, "Terminal failure does not masquerade as recoverable command rejection")
case "media-error": try peer.status(state: "IDLE", idle: "ERROR")
case "media-cancel": try peer.status(state: "IDLE", idle: "CANCELLED")
case "media-interrupt": try peer.status(state: "IDLE", idle: "INTERRUPTED")
@@ -724,6 +762,81 @@ struct CastSessionCheck {
controls[2].1["currentTime"] as? Double == 24.25, "Rapid skips accumulate from the pending target")
}
static func interruptedPlayback(identity: SecIdentity, executable: String, file: String) async throws {
for scenario in ["cancel-connect", "cancel-load", "reject-load", "replace-load", "close-load",
"replace-active", "close-active", "shutdown-load", "shutdown-active", "disconnect-active"] {
let peer = try Receiver(identity: identity)
let active = scenario.hasSuffix("-active")
peer.holdLaunch = scenario == "cancel-connect"
peer.holdLoad = !active && scenario != "cancel-connect"
peer.verifyRelay = active
try await peer.start()
defer { peer.stop() }
let gates = FileManager.default.temporaryDirectory.appendingPathComponent("terebi-cast-interruption-" + UUID().uuidString)
try FileManager.default.createDirectory(at: gates, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: gates) }
func exists(_ name: String) -> Bool {
FileManager.default.fileExists(atPath: gates.appendingPathComponent(name).path)
}
func signal(_ name: String) throws {
try Data().write(to: gates.appendingPathComponent(name), options: .atomic)
}
let process = Process()
process.executableURL = URL(fileURLWithPath: executable)
process.arguments = [file, String(peer.port), scenario, gates.path]
try process.run()
defer { if process.isRunning { process.terminate() } }
func awaitStage(_ message: String, _ predicate: () -> Bool) async throws {
try await until("\(scenario): \(message)", attempts: 18000) {
predicate() || !process.isRunning || peer.failure != nil
}
require(peer.failure == nil, peer.failure ?? "Receiver accepts interrupted playback")
require(process.isRunning, "\(scenario): child remains alive until fixture stage")
require(predicate(), "\(scenario): \(message)")
}
if active {
try await awaitStage("remote playback active") { exists("active") }
require(peer.relayVerified, "\(scenario): active receiver fetched real relay bytes")
if scenario == "disconnect-active" {
peer.holdPlayback = true
peer.holdSeek = true
}
} else {
let command = scenario == "cancel-connect" ? "LAUNCH" : "LOAD"
try await awaitStage("handoff reaches held \(command)") { peer.commands.contains { $0.0 == command } }
}
try signal("held")
if scenario == "reject-load" {
try await awaitStage("consumer observes paused pending LOAD") { exists("observed") }
try await peer.releaseLoad(reject: true)
} else {
try await awaitStage("consumer interrupts handoff") { exists("interrupted") }
if scenario == "disconnect-active" {
try await awaitStage("latest playback and seek reach receiver") {
peer.commands.filter { ["PLAY", "PAUSE"].contains($0.0) }.count == 4 &&
peer.commands.contains { $0.0 == "SEEK" }
}
peer.disconnect()
} else if scenario == "cancel-connect" {
try peer.releaseLaunch()
} else if !active {
// A cancelled LOAD can still produce a late receiver acknowledgement.
// Skip relay fetching: the sender has intentionally stopped that relay.
try await peer.releaseLoad()
} else {
try peer.status(state: "PLAYING")
try peer.receiverStatus(level: 0.9)
}
}
try signal("released")
try await until("\(scenario): subprocess completes", attempts: 18000) {
!process.isRunning || peer.failure != nil
}
require(peer.failure == nil, peer.failure ?? "Receiver accepts interrupted playback")
require(!process.isRunning && process.terminationStatus == 0, "\(scenario): consumer-visible restoration succeeds")
}
}
static func realPeer(_ arguments: [String]) async throws {
guard let port = UInt16(arguments[3]), let url = URL(string: arguments[4]) else {
fatalError("Usage: cast-session-check --peer HOST PORT MEDIA_URL CONTENT_TYPE")
@@ -769,6 +882,8 @@ struct CastSessionCheck {
if CommandLine.arguments.count == 4, CommandLine.arguments[1] == "--playback" {
try await playback(identity: identity, executable: CommandLine.arguments[2], file: CommandLine.arguments[3])
print("PASS: native TLS receiver fetches relay and acknowledges PlayerModel handoff/control/disconnect")
try await interruptedPlayback(identity: identity, executable: CommandLine.arguments[2], file: CommandLine.arguments[3])
print("PASS: interrupted Cast connect/LOAD, rejection, replacement/close, latest intent and acknowledged-position recovery")
} else {
try await behavior(identity: identity)
try await launchRegressions(identity: identity)
+6
View File
@@ -213,6 +213,10 @@ The Cast icon uses 15 points in the titlebar and 17 points in compact controls,
Terebi pauses local output before transferring the acknowledged position and play/pause intent to Google's Default Media Receiver. The existing controls, keyboard shortcuts and macOS media commands control remote playback, seeking, volume/mute and resolved-source subtitles. **Play on This Mac** restores receiver-confirmed position and intent; local volume is preserved independently. Connection/load failures return to local playback with an error. Opening another source ends casting and opens it locally. Frame stepping and YouTube quality changes are local-only; automatic quality is suspended while casting.
`PlayerModel.CastPlaybackTransition` owns the Cast lifecycle, receiver-state projection, pending playback intent, cancellation and return-to-local decision. `PlayerModel` applies its observations to presentation, acknowledged progress and existing local restoration. `CastSession` retains receiver-protocol ownership; `CastMediaServer` retains cancellation-safe delivery lifetime.
Recoverable command failures and terminal disconnection are separate observations: disconnecting with pending controls must not clear the latest playback intent before local restoration chooses its playing or paused state.
TLS connection establishment has a twelve-second deadline; cold receiver launch has a separate thirty-second deadline and accepts validated asynchronous readiness without waiting for the original launch acknowledgement. Volume controls retain the latest requested value until its acknowledgement, ignore older feedback, and do not reposition the native slider during a drag. External TV volume changes still update idle controls.
The Mac serves session-only capability URLs over HTTP. Keep it awake and connected, and allow incoming connections through its firewall. The relay exposes only the chosen media and subtitle resources, never arbitrary filesystem paths or a general URL proxy. Disconnect, replacement and quit cancel delivery and invalidate the listening endpoint.
@@ -229,6 +233,8 @@ The Cast channel accepts the selected receiver's self-signed TLS certificate: en
Authenticated multi-variant HLS regressions gate upstream responses and verify no unused-quality downloads or duplicate retained-media reads, nearby rewinds, distant cache misses, decoded A/V positions and AAC continuity, and cancellation while upstream remains blocked. Native network playback checks cover the shared reader's existing callers.
The player handoff checks also gate receiver launch and LOAD to exercise cancellation, LOAD rejection after local pause, replacement/close/shutdown during pending and active Cast, and late receiver replies. Disconnect recovery preserves the latest playback intent, acknowledged position, stable bookmarks and independent local volume even with an unacknowledged remote seek.
## Resume playback
Reopening a local video, YouTube video or resolved Miruro episode automatically resumes its saved position, including after quitting and relaunching the app. Local files use their resolved file paths; YouTube uses the canonical video webpage URL; Miruro uses its opaque media ID and episode number. Newly resolved addresses therefore share progress without saving CDN tokens as identity. The app does not automatically reopen the last video on launch.
+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)
+2 -15
View File
@@ -1,18 +1,3 @@
- [x] add miruro streaming support
- [x] investigate and fix audio when streaming from miruro - audio seems to crackle (not for youtube i think; only for miruros)
- [x] add chrome cast support — implemented; verify playback, captions and A/V sync on a physical Cast device
- [x] fix premature receiver-launch timeout: reports failure even though Chromecast subsequently connects and playback works
- [x] fix TV volume slider jitter: receiver updates appear to compete with local volume adjustments
- [x] investigate and reduce slow buffering after seeking in YouTube videos while casting, compared with local playback — reuse probed inputs and cache completed segments; confirm perceived improvement on TV
- [x] fix Miruro Cast upstream overfetch: select HLS streams and share bounded video/audio packet buffering — verify sustained playback on TV
- [x] styling/ui changes — implemented; verify layout and casting appearance in the app:
- move the captions button in the controls left of the compact mode icon
- place the Cast button between pin and settings in the top-right container; in compact mode, between exit compact mode and settings
- don't leave and empty spot where the captions were supposed to be if captions aren't available; buttons should be moved over in that case.
- the background is currently black in the player when casting (chrome cast), but we should follow the design language of the rest of the app.
- replace the current chrome cast icon with the apple tv casting icon.
- [ ] in the "choose what to play" screen, we should change the youtube button to general streaming
- we can figure out the stream and how to handle it from the url (i.e. if youtube url, handle as youtube stream; if miruro url, handle accordingly)
- [ ] evaluate playback optimization opportunities for YouTube and Miruro — measure benefits before changing behavior
- [ ] coalesce superseded, not-yet-started seeks in the player and Cast controls; preserve accumulated relative skips and play/pause ordering
- [ ] expand Cast H.264/AAC remux coverage with keyframe-aligned segments; preserve seek accuracy, timestamps and receiver compatibility
@@ -20,3 +5,5 @@
- [ ] evaluate VideoToolbox hardware video decoding for native and Cast playback; retain software fallback for unsupported formats
- [ ] benchmark cold start, cached/uncached seeks and sustained playback separately to prioritize the actual bottlenecks
- do not enable Miruro HTTP persistence without preserving origin/header checks, or increase caches/shorten Cast segments without measuring the memory/request/codec tradeoffs
- [ ] in the "choose what to play" screen, we should change the youtube button to general streaming
- we can figure out the stream and how to handle it from the url (i.e. if youtube url, handle as youtube stream; if miruro url, handle accordingly)