refactor: concentrate Cast ownership and preserve intent on disconnect
This commit is contained in:
+307
-176
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user