refactor: concentrate Cast ownership and preserve intent on disconnect
This commit is contained in:
+4
-1
@@ -7,6 +7,9 @@ Terebi plays local media, YouTube videos and resolved Miruro episodes. These ter
|
|||||||
**Playback transition**:
|
**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.
|
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**:
|
**Playback intent**:
|
||||||
The user's latest requested playing or paused state, which can differ from the state playback has acknowledged.
|
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.
|
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**:
|
**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**:
|
**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.
|
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.
|
||||||
|
|||||||
@@ -108,8 +108,8 @@ struct AutomaticQualityPlaybackChecks {
|
|||||||
model.youtubeHeight == 720 && model.canTogglePlayback && model.state.status == PLAYER_PLAYING &&
|
model.youtubeHeight == 720 && model.canTogglePlayback && model.state.status == PLAYER_PLAYING &&
|
||||||
!model.state.seek_pending && (model.bufferedRange.map { $0.upperBound - model.state.position >= 20 } ?? false)
|
!model.state.seek_pending && (model.bufferedRange.map { $0.upperBound - model.state.position >= 20 } ?? false)
|
||||||
}
|
}
|
||||||
require(model.state.position >= position && model.state.position < position + 3,
|
require(model.state.position >= position,
|
||||||
"automatic quality reopening preserves current playback progress")
|
"automatic quality reopening does not reset acknowledged progress")
|
||||||
polled(model)
|
polled(model)
|
||||||
clock.now = 2063
|
clock.now = 2063
|
||||||
until(model, "sustained health automatically restores actual 1080p media") {
|
until(model, "sustained health automatically restores actual 1080p media") {
|
||||||
|
|||||||
@@ -17,6 +17,153 @@ struct CastPlaybackCheck {
|
|||||||
require(predicate(), message)
|
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 {
|
static func main() async throws {
|
||||||
_ = NSApplication.shared
|
_ = NSApplication.shared
|
||||||
NSApp.setActivationPolicy(.prohibited)
|
NSApp.setActivationPolicy(.prohibited)
|
||||||
@@ -32,6 +179,11 @@ struct CastPlaybackCheck {
|
|||||||
defer { defaults.removePersistentDomain(forName: suite) }
|
defer { defaults.removePersistentDomain(forName: suite) }
|
||||||
let model = PlayerModel(defaults: defaults)
|
let model = PlayerModel(defaults: defaults)
|
||||||
defer { model.shutdown() }
|
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.setVolume(0)
|
||||||
model.open(source)
|
model.open(source)
|
||||||
try await until(model, "local source plays") { model.state.status == PLAYER_PLAYING && model.canSeek }
|
try await until(model, "local source plays") { model.state.status == PLAYER_PLAYING && model.canSeek }
|
||||||
|
|||||||
+123
-8
@@ -87,6 +87,11 @@ private final class Receiver {
|
|||||||
var rejectCommand = false
|
var rejectCommand = false
|
||||||
var holdSeek = false
|
var holdSeek = false
|
||||||
var holdVolume = 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 ignoreHeartbeat = false
|
||||||
var verifyRelay = false
|
var verifyRelay = false
|
||||||
var relayVerified = false
|
var relayVerified = false
|
||||||
@@ -138,6 +143,33 @@ private final class Receiver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func stop() { socket?.cancel(); listener.cancel() }
|
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) {
|
private func receive(_ socket: NWConnection) {
|
||||||
socket.receive(minimumIncompleteLength: 1, maximumLength: 16384) { [weak self] data, _, ended, error in
|
socket.receive(minimumIncompleteLength: 1, maximumLength: 16384) { [weak self] data, _, ended, error in
|
||||||
@@ -219,20 +251,17 @@ private final class Receiver {
|
|||||||
case "CLOSE": break
|
case "CLOSE": break
|
||||||
case "LAUNCH":
|
case "LAUNCH":
|
||||||
require(json["appId"] as? String == "CC1AD845", "Default Media Receiver, no registration")
|
require(json["appId"] as? String == "CC1AD845", "Default Media Receiver, no registration")
|
||||||
|
if holdLaunch { heldLaunch = requestID; return }
|
||||||
if dropLaunch { return }
|
if dropLaunch { return }
|
||||||
if rejectLaunch { try send(["type": "LAUNCH_ERROR", "requestId": requestID], namespace: CastWire.receiver) }
|
if rejectLaunch { try send(["type": "LAUNCH_ERROR", "requestId": requestID], namespace: CastWire.receiver) }
|
||||||
else { try receiverStatus(requestID) }
|
else { try receiverStatus(requestID) }
|
||||||
case "LOAD":
|
case "LOAD":
|
||||||
|
if holdLoad { heldLoad = json; return }
|
||||||
if rejectLoad { try send(["type": "LOAD_FAILED", "requestId": requestID], namespace: CastWire.media); return }
|
if rejectLoad { try send(["type": "LOAD_FAILED", "requestId": requestID], namespace: CastWire.media); return }
|
||||||
if dropLoad { return }
|
if dropLoad { return }
|
||||||
media = json["media"] as! [String: Any]
|
try await load(json)
|
||||||
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)
|
|
||||||
case "PLAY", "PAUSE", "SEEK", "GET_STATUS", "EDIT_TRACKS_INFO":
|
case "PLAY", "PAUSE", "SEEK", "GET_STATUS", "EDIT_TRACKS_INFO":
|
||||||
|
if holdPlayback, type == "PLAY" || type == "PAUSE" { return }
|
||||||
if dropCommands { return }
|
if dropCommands { return }
|
||||||
if rejectCommand {
|
if rejectCommand {
|
||||||
try send(["type": "INVALID_PLAYER_STATE", "requestId": requestID], namespace: CastWire.media)
|
try send(["type": "INVALID_PLAYER_STATE", "requestId": requestID], namespace: CastWire.media)
|
||||||
@@ -607,7 +636,7 @@ struct CastSessionCheck {
|
|||||||
|
|
||||||
static func failures(identity: SecIdentity) async throws {
|
static func failures(identity: SecIdentity) async throws {
|
||||||
for mode in ["launch", "launch-timeout", "load", "deadline", "command", "rejection", "cancel", "frame",
|
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)
|
let peer = try Receiver(identity: identity)
|
||||||
try await peer.start()
|
try await peer.start()
|
||||||
let clock = CastCheckClock()
|
let clock = CastCheckClock()
|
||||||
@@ -671,6 +700,15 @@ struct CastSessionCheck {
|
|||||||
require(session.setPlaying(true), "Rejected command sent")
|
require(session.setPlaying(true), "Rejected command sent")
|
||||||
try await until("Command rejection reports recoverable error") { !errors.isEmpty }
|
try await until("Command rejection reports recoverable error") { !errors.isEmpty }
|
||||||
require(!disconnected, "Command rejection is not channel failure")
|
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-error": try peer.status(state: "IDLE", idle: "ERROR")
|
||||||
case "media-cancel": try peer.status(state: "IDLE", idle: "CANCELLED")
|
case "media-cancel": try peer.status(state: "IDLE", idle: "CANCELLED")
|
||||||
case "media-interrupt": try peer.status(state: "IDLE", idle: "INTERRUPTED")
|
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")
|
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 {
|
static func realPeer(_ arguments: [String]) async throws {
|
||||||
guard let port = UInt16(arguments[3]), let url = URL(string: arguments[4]) else {
|
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")
|
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" {
|
if CommandLine.arguments.count == 4, CommandLine.arguments[1] == "--playback" {
|
||||||
try await playback(identity: identity, executable: CommandLine.arguments[2], file: CommandLine.arguments[3])
|
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")
|
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 {
|
} else {
|
||||||
try await behavior(identity: identity)
|
try await behavior(identity: identity)
|
||||||
try await launchRegressions(identity: identity)
|
try await launchRegressions(identity: identity)
|
||||||
|
|||||||
@@ -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.
|
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.
|
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.
|
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.
|
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
|
## 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.
|
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.
|
||||||
|
|||||||
@@ -238,6 +238,7 @@ enum CastWire {
|
|||||||
final class CastSession {
|
final class CastSession {
|
||||||
var onStatus: ((CastStatus) -> Void)?
|
var onStatus: ((CastStatus) -> Void)?
|
||||||
var onDisconnect: ((String?) -> Void)?
|
var onDisconnect: ((String?) -> Void)?
|
||||||
|
// Recoverable command errors; terminal channel failures belong to onDisconnect.
|
||||||
var onError: ((String) -> Void)?
|
var onError: ((String) -> Void)?
|
||||||
var localHost: String? {
|
var localHost: String? {
|
||||||
guard let endpoint = connection?.currentPath?.localEndpoint,
|
guard let endpoint = connection?.currentPath?.localEndpoint,
|
||||||
@@ -499,7 +500,7 @@ final class CastSession {
|
|||||||
destination: receiver ? "receiver-0" : transport,
|
destination: receiver ? "receiver-0" : transport,
|
||||||
operation: (command["type"] as? String ?? "command").lowercased(),
|
operation: (command["type"] as? String ?? "command").lowercased(),
|
||||||
generation: mediaGeneration, id: id) { [weak self] _, error in
|
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 self.pendingVolume?.request == id { self.pendingVolume = nil; self.publishSeekState() }
|
||||||
if case .cancelled? = error { return }
|
if case .cancelled? = error { return }
|
||||||
if self.pendingSeek == id { self.pendingSeek = nil; self.publishSeekState() }
|
if self.pendingSeek == id { self.pendingSeek = nil; self.publishSeekState() }
|
||||||
@@ -760,7 +761,7 @@ final class CastSession {
|
|||||||
namespace: CastWire.media, destination: transport, operation: "media status",
|
namespace: CastWire.media, destination: transport, operation: "media status",
|
||||||
generation: self.mediaGeneration) { [weak self] _, error in
|
generation: self.mediaGeneration) { [weak self] _, error in
|
||||||
if case .cancelled? = error { return }
|
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 }
|
} catch { self.fail(error as? CastError ?? .disconnected); return }
|
||||||
|
|||||||
+304
-173
@@ -29,17 +29,9 @@ final class PlayerModel: ObservableObject {
|
|||||||
@Published private(set) var commandError: String?
|
@Published private(set) var commandError: String?
|
||||||
private var transition = PlaybackTransition()
|
private var transition = PlaybackTransition()
|
||||||
let castDiscovery = CastDiscovery()
|
let castDiscovery = CastDiscovery()
|
||||||
@Published private(set) var castDevice: CastDevice?
|
private lazy var casting = CastPlaybackTransition { [weak self] event in self?.receiveCast(event) }
|
||||||
@Published private(set) var castConnecting = false
|
var castDevice: CastDevice? { casting.device }
|
||||||
private var castActive = false
|
var castConnecting: Bool { casting.connecting }
|
||||||
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?
|
private var currentMedia: MediaSource?
|
||||||
var pendingRevision: UInt64? { transition.pendingRevision }
|
var pendingRevision: UInt64? { transition.pendingRevision }
|
||||||
var resolvingYouTube: Bool { transition.isResolving }
|
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 }
|
guard !isBusy, !isLiveStream, fileName != nil, state.status != PLAYER_ERROR else { return nil }
|
||||||
return compactProgressText(position: state.position, duration: state.duration, format: compactTitleProgress)
|
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 fullScreenIntent: Bool?
|
||||||
@Published private(set) var compactIntent: Bool?
|
@Published private(set) var compactIntent: Bool?
|
||||||
var effectivePlaying: Bool { playbackIntent ?? (state.status == PLAYER_PLAYING) }
|
var effectivePlaying: Bool { playbackIntent ?? (state.status == PLAYER_PLAYING) }
|
||||||
@@ -162,7 +154,7 @@ final class PlayerModel: ObservableObject {
|
|||||||
systemPlayback = SystemPlaybackControls(model: self)
|
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 {
|
var canTogglePlayback: Bool {
|
||||||
handle != nil && !isBusy &&
|
handle != nil && !isBusy &&
|
||||||
(state.status == PLAYER_PAUSED || state.status == PLAYER_PLAYING || state.status == PLAYER_ENDED)
|
(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) {
|
func cast(to device: CastDevice) {
|
||||||
guard canCast, let source = currentMedia, let handle else { return }
|
guard canCast, let source = currentMedia, let handle else { return }
|
||||||
cancelYouTubeResolution()
|
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
|
commandError = nil
|
||||||
|
casting.start(to: device, source: source, title: fileName ?? "terebi", subtitle: selectedSubtitle,
|
||||||
|
handle: handle, snapshot: state, playing: effectivePlaying,
|
||||||
|
volume: volume, audibleVolume: lastAudibleVolume)
|
||||||
revealControls()
|
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) {
|
func stopCasting(resume: Bool = true) {
|
||||||
guard castDevice != nil else { return }
|
casting.stop(resume: resume)
|
||||||
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 {
|
private func receiveCast(_ event: CastPlaybackTransition.Event) {
|
||||||
switch source {
|
objectWillChange.send()
|
||||||
case .file(let url):
|
switch event {
|
||||||
return CastMediaInput(videoURL: url.path, videoHeaders: "", audioURL: nil, audioHeaders: nil,
|
case .changed:
|
||||||
local: true, restricted: false, live: false, duration: duration, subtitles: [])
|
break
|
||||||
case .youtube(let video, _):
|
case .activated:
|
||||||
return CastMediaInput(videoURL: video.video.url, videoHeaders: video.video.headers,
|
transition.acceptCast()
|
||||||
audioURL: video.audio?.url, audioHeaders: video.audio?.headers,
|
subtitleTask?.cancel()
|
||||||
local: false, restricted: false, live: video.isLive,
|
subtitleText = ""
|
||||||
duration: duration, subtitles: [])
|
case .status(let projection):
|
||||||
case .resolved(let resolved):
|
state = projection.state
|
||||||
return CastMediaInput(videoURL: resolved.url, videoHeaders: resolved.headers,
|
volume = projection.volume
|
||||||
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 }
|
if volume > 0 { lastAudibleVolume = volume }
|
||||||
selectedSubtitle = status.subtitle
|
selectedSubtitle = projection.subtitle
|
||||||
if !status.playbackPending { castIntent = nil }
|
if !isSeeking && !state.seek_pending { seekPosition = state.position }
|
||||||
if !isSeeking && !status.seekPending { seekPosition = state.position }
|
|
||||||
if let handle { _ = player_report_remote_state(handle, &state) }
|
if let handle { _ = player_report_remote_state(handle, &state) }
|
||||||
saveProgress()
|
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()
|
systemPlayback?.update()
|
||||||
}
|
}
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
private func seekCast(to position: Double) -> Bool {
|
private func seekCast(to position: Double) -> Bool {
|
||||||
let accepted = castSession?.seek(position) == true
|
let accepted = casting.seek(position)
|
||||||
if accepted { state.seek_pending = true; seekPosition = position }
|
if accepted { seekPosition = position }
|
||||||
commandError = accepted ? nil : "Could not seek Cast playback."
|
commandError = accepted ? nil : "Could not seek Cast playback."
|
||||||
revealControls()
|
revealControls()
|
||||||
return accepted
|
return accepted
|
||||||
@@ -422,8 +305,8 @@ final class PlayerModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func selectSubtitle(_ index: Int?) {
|
func selectSubtitle(_ index: Int?) {
|
||||||
if castActive {
|
if casting.active {
|
||||||
if castSession?.setSubtitle(index) != true { commandError = "Could not change Cast subtitles." }
|
if !casting.setSubtitle(index) { commandError = "Could not change Cast subtitles." }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
subtitleTask?.cancel()
|
subtitleTask?.cancel()
|
||||||
@@ -656,10 +539,9 @@ final class PlayerModel: ObservableObject {
|
|||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func setPlayback(playing: Bool) -> Bool {
|
func setPlayback(playing: Bool) -> Bool {
|
||||||
if castActive {
|
if casting.active {
|
||||||
guard canTogglePlayback else { return false }
|
guard canTogglePlayback else { return false }
|
||||||
let accepted = castSession?.setPlaying(playing) == true
|
let accepted = casting.setPlaying(playing)
|
||||||
if accepted { castIntent = playing; objectWillChange.send() }
|
|
||||||
commandError = accepted ? nil : "Could not change Cast playback."
|
commandError = accepted ? nil : "Could not change Cast playback."
|
||||||
revealControls()
|
revealControls()
|
||||||
return accepted
|
return accepted
|
||||||
@@ -686,7 +568,7 @@ final class PlayerModel: ObservableObject {
|
|||||||
automaticQuality.resetHealth()
|
automaticQuality.resetHealth()
|
||||||
isSeeking = false
|
isSeeking = false
|
||||||
guard let handle, canSeek else { return }
|
guard let handle, canSeek else { return }
|
||||||
if castActive {
|
if casting.active {
|
||||||
seekCast(to: min(max(0, seekPosition), duration))
|
seekCast(to: min(max(0, seekPosition), duration))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -733,7 +615,7 @@ final class PlayerModel: ObservableObject {
|
|||||||
if transition.resolvingAutomaticQuality { cancelYouTubeResolution() }
|
if transition.resolvingAutomaticQuality { cancelYouTubeResolution() }
|
||||||
automaticQuality.resetHealth()
|
automaticQuality.resetHealth()
|
||||||
let target = min(max(0, ((transition.seekPending || state.seek_pending) ? seekPosition : state.position) + seconds), duration)
|
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 }
|
if accepted { seekPosition = target }
|
||||||
commandError = accepted ? nil : "Could not queue the seek. Please try again."
|
commandError = accepted ? nil : "Could not queue the seek. Please try again."
|
||||||
if !accepted { revealControls() }
|
if !accepted { revealControls() }
|
||||||
@@ -750,8 +632,8 @@ final class PlayerModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func setVolume(_ value: Double) {
|
func setVolume(_ value: Double) {
|
||||||
if castActive {
|
if casting.active {
|
||||||
if value.isFinite, (0...1).contains(value), castSession?.setVolume(value) == true {
|
if casting.setVolume(value) {
|
||||||
volume = value
|
volume = value
|
||||||
if volume > 0 { lastAudibleVolume = volume }
|
if volume > 0 { lastAudibleVolume = volume }
|
||||||
}
|
}
|
||||||
@@ -1012,15 +894,19 @@ final class PlayerModel: ObservableObject {
|
|||||||
|
|
||||||
private func saveProgress(_ snapshot: PlayerState? = nil, force: Bool = true) {
|
private func saveProgress(_ snapshot: PlayerState? = nil, force: Bool = true) {
|
||||||
guard let handle else { return }
|
guard let handle else { return }
|
||||||
if castActive {
|
if let checkpoint = casting.checkpoint {
|
||||||
guard let url = currentMedia?.url, !isLiveStream, state.has_video, state.can_seek,
|
saveCastProgress(checkpoint.state, source: checkpoint.source)
|
||||||
!state.seek_pending, state.duration > 0, state.position.isFinite else { return }
|
|
||||||
playbackProgress.save(state.status == PLAYER_ENDED ? nil : state.position, for: url)
|
|
||||||
} else {
|
} else {
|
||||||
transition.saveProgress(snapshot, handle: handle, force: force, history: playbackProgress)
|
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() {
|
private func poll() {
|
||||||
guard let handle else { return }
|
guard let handle else { return }
|
||||||
if reportingEnabled && !reportingErrorShown {
|
if reportingEnabled && !reportingErrorShown {
|
||||||
@@ -1028,7 +914,7 @@ final class PlayerModel: ObservableObject {
|
|||||||
showReportingError(reportingErrorMessage)
|
showReportingError(reportingErrorMessage)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !castActive { applyState(player_get_state(handle)) }
|
if !casting.active { applyState(player_get_state(handle)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyState(_ next: PlayerState) {
|
func applyState(_ next: PlayerState) {
|
||||||
@@ -1109,6 +995,251 @@ final class PlayerModel: ObservableObject {
|
|||||||
player_destroy(player)
|
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 {
|
private enum MediaChange {
|
||||||
case open
|
case open
|
||||||
case quality(position: Double?, playing: Bool, automatic: Bool)
|
case quality(position: Double?, playing: Bool, automatic: Bool)
|
||||||
|
|||||||
@@ -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
|
- [ ] 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
|
- [ ] 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
|
- [ ] 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
|
- [ ] 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
|
- [ ] 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
|
- 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)
|
||||||
Reference in New Issue
Block a user