feat: add Chromecast playback support
This commit is contained in:
+264
-12
@@ -28,6 +28,19 @@ final class PlayerModel: ObservableObject {
|
||||
@Published private(set) var fileIcon: NSImage?
|
||||
@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 var currentMedia: MediaSource?
|
||||
var pendingRevision: UInt64? { transition.pendingRevision }
|
||||
var resolvingYouTube: Bool { transition.isResolving }
|
||||
var isLiveStream: Bool { transition.isLive }
|
||||
@@ -56,7 +69,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? { transition.playbackIntent }
|
||||
var playbackIntent: Bool? { castActive ? castIntent : transition.playbackIntent }
|
||||
@Published private(set) var fullScreenIntent: Bool?
|
||||
@Published private(set) var compactIntent: Bool?
|
||||
var effectivePlaying: Bool { playbackIntent ?? (state.status == PLAYER_PLAYING) }
|
||||
@@ -149,19 +162,23 @@ final class PlayerModel: ObservableObject {
|
||||
systemPlayback = SystemPlaybackControls(model: self)
|
||||
}
|
||||
|
||||
var isBusy: Bool { transition.isBusy(state.status) }
|
||||
var isBusy: Bool { castConnecting || (!castActive && transition.isBusy(state.status)) }
|
||||
var canTogglePlayback: Bool {
|
||||
handle != nil && !isBusy &&
|
||||
(state.status == PLAYER_PAUSED || state.status == PLAYER_PLAYING || state.status == PLAYER_ENDED)
|
||||
}
|
||||
var canSeek: Bool { canTogglePlayback && state.can_seek && state.duration.isFinite && state.duration > 0 }
|
||||
var canStepFrame: Bool { canTogglePlayback && state.has_video && state.can_seek && !isSeeking }
|
||||
var canStepFrame: Bool { castDevice == nil && canTogglePlayback && state.has_video && state.can_seek && !isSeeking }
|
||||
var canChangeYouTubeQuality: Bool {
|
||||
canTogglePlayback && !youtubeHeights.isEmpty && !isSeeking && !transition.seekPending && !state.seek_pending
|
||||
castDevice == nil && canTogglePlayback && !youtubeHeights.isEmpty && !isSeeking && !transition.seekPending && !state.seek_pending
|
||||
}
|
||||
var canOpen: Bool { handle != nil }
|
||||
var canClose: Bool { handle != nil && (isBusy || state.status != PLAYER_EMPTY || fileName != nil) }
|
||||
var canSetVolume: Bool { handle != nil && !isBusy && state.has_audio && state.status != PLAYER_ERROR }
|
||||
var canCast: Bool {
|
||||
castDevice == nil && currentMedia != nil && canTogglePlayback && !isSeeking && !state.seek_pending &&
|
||||
!transition.seekPending && transition.playbackIntent == nil && !castConnecting
|
||||
}
|
||||
var duration: Double { state.duration.isFinite ? max(0, state.duration) : 0 }
|
||||
var bufferedRange: ClosedRange<Double>? {
|
||||
guard fileName != nil, canSeek, !transition.seekPending, !state.seek_pending,
|
||||
@@ -180,6 +197,175 @@ 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
|
||||
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)
|
||||
}
|
||||
}
|
||||
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 }
|
||||
commandError = accepted ? nil : "Could not seek Cast playback."
|
||||
revealControls()
|
||||
return accepted
|
||||
}
|
||||
|
||||
func openPanel() {
|
||||
guard canOpen else { return }
|
||||
defer { window?.makeFirstResponder(nil) }
|
||||
@@ -236,6 +422,10 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
|
||||
func selectSubtitle(_ index: Int?) {
|
||||
if castActive {
|
||||
if castSession?.setSubtitle(index) != true { commandError = "Could not change Cast subtitles." }
|
||||
return
|
||||
}
|
||||
subtitleTask?.cancel()
|
||||
subtitleTask = nil
|
||||
subtitleDocument = nil
|
||||
@@ -258,6 +448,7 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
|
||||
private func updateSubtitle() {
|
||||
if castDevice != nil { subtitleText = ""; return }
|
||||
subtitleText = state.seek_pending || transition.seekPending || isBusy ||
|
||||
state.status == PLAYER_EMPTY || state.status == PLAYER_ERROR ? "" :
|
||||
subtitleDocument?.text(at: state.position) ?? ""
|
||||
@@ -292,6 +483,7 @@ final class PlayerModel: ObservableObject {
|
||||
|
||||
private func resolveYouTube(_ url: URL, height: Int?, change: MediaChange = .open) {
|
||||
guard let handle else { return }
|
||||
if castDevice != nil { stopCasting(resume: false) }
|
||||
cancelYouTubeResolution()
|
||||
if !change.automatic { automaticQuality = AutomaticQuality() }
|
||||
if !change.automatic && !player_pause(handle) {
|
||||
@@ -351,9 +543,10 @@ final class PlayerModel: ObservableObject {
|
||||
|
||||
private func openMedia(_ source: MediaSource, change: MediaChange = .open) {
|
||||
guard let handle else { return }
|
||||
if castDevice != nil { stopCasting(resume: false) }
|
||||
saveProgress()
|
||||
let revision = player_get_state(handle).revision
|
||||
let savedPosition = change.isQuality ? change.position :
|
||||
let savedPosition = change.preservesPosition ? change.position :
|
||||
(source.live ? nil : playbackProgress.position(for: source.url))
|
||||
let accepted: Bool
|
||||
switch source {
|
||||
@@ -407,6 +600,7 @@ final class PlayerModel: ObservableObject {
|
||||
return
|
||||
}
|
||||
transition.acceptOpen(revision: revision, source: source, change: change, savedPosition: savedPosition)
|
||||
currentMedia = source
|
||||
if change.automatic { automaticQuality.resetHealth() } else { automaticQuality = AutomaticQuality() }
|
||||
switch source {
|
||||
case .file(let url):
|
||||
@@ -462,6 +656,14 @@ final class PlayerModel: ObservableObject {
|
||||
|
||||
@discardableResult
|
||||
func setPlayback(playing: Bool) -> Bool {
|
||||
if castActive {
|
||||
guard canTogglePlayback else { return false }
|
||||
let accepted = castSession?.setPlaying(playing) == true
|
||||
if accepted { castIntent = playing; objectWillChange.send() }
|
||||
commandError = accepted ? nil : "Could not change Cast playback."
|
||||
revealControls()
|
||||
return accepted
|
||||
}
|
||||
guard let handle, canTogglePlayback else { return false }
|
||||
if !playing && transition.resolvingAutomaticQuality { cancelYouTubeResolution() }
|
||||
if !playing { automaticQuality.resetHealth() }
|
||||
@@ -484,6 +686,10 @@ final class PlayerModel: ObservableObject {
|
||||
automaticQuality.resetHealth()
|
||||
isSeeking = false
|
||||
guard let handle, canSeek else { return }
|
||||
if castActive {
|
||||
seekCast(to: min(max(0, seekPosition), duration))
|
||||
return
|
||||
}
|
||||
let position = min(max(0, seekPosition), duration)
|
||||
let accepted = transition.seek(position, handle: handle)
|
||||
commandError = accepted ? nil : "Could not queue the seek. Please try again."
|
||||
@@ -493,6 +699,7 @@ final class PlayerModel: ObservableObject {
|
||||
func closeFile() {
|
||||
guard let handle, canClose else { return }
|
||||
saveProgress()
|
||||
if castDevice != nil { stopCasting(resume: false) }
|
||||
cancelYouTubeResolution()
|
||||
automaticQuality = AutomaticQuality()
|
||||
guard player_close(handle) else {
|
||||
@@ -500,6 +707,7 @@ final class PlayerModel: ObservableObject {
|
||||
return
|
||||
}
|
||||
transition.closeMedia()
|
||||
currentMedia = nil
|
||||
selectSubtitle(nil)
|
||||
subtitleTracks = []
|
||||
compactIntent = nil
|
||||
@@ -524,8 +732,8 @@ final class PlayerModel: ObservableObject {
|
||||
guard canSeek, let handle, seconds.isFinite else { return false }
|
||||
if transition.resolvingAutomaticQuality { cancelYouTubeResolution() }
|
||||
automaticQuality.resetHealth()
|
||||
let target = min(max(0, (transition.seekPending ? seekPosition : state.position) + seconds), duration)
|
||||
let accepted = transition.seek(target, handle: handle)
|
||||
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)
|
||||
if accepted { seekPosition = target }
|
||||
commandError = accepted ? nil : "Could not queue the seek. Please try again."
|
||||
if !accepted { revealControls() }
|
||||
@@ -542,6 +750,14 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
|
||||
func setVolume(_ value: Double) {
|
||||
if castActive {
|
||||
if value.isFinite, (0...1).contains(value), castSession?.setVolume(value) == true {
|
||||
volume = value
|
||||
if volume > 0 { lastAudibleVolume = volume }
|
||||
}
|
||||
revealControls()
|
||||
return
|
||||
}
|
||||
guard let handle else { return }
|
||||
if player_set_volume(handle, Float(value)) {
|
||||
volume = Double(player_get_volume(handle))
|
||||
@@ -757,7 +973,7 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
|
||||
private var shouldHideControls: Bool {
|
||||
((canTogglePlayback && fileName != nil && (state.has_video || !pointerInsideWindow)) ||
|
||||
castDevice == nil && ((canTogglePlayback && fileName != nil && (state.has_video || !pointerInsideWindow)) ||
|
||||
(!pointerInsideWindow && isBusy)) &&
|
||||
!isSeeking && !transition.seekPending && !controlsDragging &&
|
||||
(!pointerInsideWindow || (!controlsHovered && !titleHovered)) &&
|
||||
@@ -796,7 +1012,13 @@ final class PlayerModel: ObservableObject {
|
||||
|
||||
private func saveProgress(_ snapshot: PlayerState? = nil, force: Bool = true) {
|
||||
guard let handle else { return }
|
||||
transition.saveProgress(snapshot, handle: handle, force: force, history: playbackProgress)
|
||||
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)
|
||||
} else {
|
||||
transition.saveProgress(snapshot, handle: handle, force: force, history: playbackProgress)
|
||||
}
|
||||
}
|
||||
|
||||
private func poll() {
|
||||
@@ -806,7 +1028,7 @@ final class PlayerModel: ObservableObject {
|
||||
showReportingError(reportingErrorMessage)
|
||||
}
|
||||
}
|
||||
applyState(player_get_state(handle))
|
||||
if !castActive { applyState(player_get_state(handle)) }
|
||||
}
|
||||
|
||||
func applyState(_ next: PlayerState) {
|
||||
@@ -836,6 +1058,7 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
|
||||
private func updateAutomaticQuality() {
|
||||
guard castDevice == nil else { return }
|
||||
guard automaticYouTubeQuality else { return }
|
||||
let reserve = bufferedRange.map { max(0, $0.upperBound - state.position) }
|
||||
let sample = AutomaticQuality.Sample(
|
||||
@@ -865,6 +1088,8 @@ final class PlayerModel: ObservableObject {
|
||||
|
||||
func shutdown() {
|
||||
saveProgress()
|
||||
if castDevice != nil { stopCasting(resume: false) }
|
||||
castDiscovery.stop()
|
||||
cancelYouTubeResolution()
|
||||
selectSubtitle(nil)
|
||||
systemPlayback?.shutdown()
|
||||
@@ -887,6 +1112,7 @@ final class PlayerModel: ObservableObject {
|
||||
private enum MediaChange {
|
||||
case open
|
||||
case quality(position: Double?, playing: Bool, automatic: Bool)
|
||||
case restore(position: Double, playing: Bool)
|
||||
|
||||
var automatic: Bool {
|
||||
if case .quality(_, _, let automatic) = self { return automatic }
|
||||
@@ -894,16 +1120,22 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
var playing: Bool {
|
||||
if case .quality(_, let playing, _) = self { return playing }
|
||||
if case .restore(_, let playing) = self { return playing }
|
||||
return true
|
||||
}
|
||||
var position: Double? {
|
||||
if case .quality(let position, _, _) = self { return position }
|
||||
if case .restore(let position, _) = self { return position }
|
||||
return nil
|
||||
}
|
||||
var isQuality: Bool {
|
||||
if case .quality = self { return true }
|
||||
return false
|
||||
}
|
||||
var preservesPosition: Bool {
|
||||
if case .restore = self { return true }
|
||||
return isQuality
|
||||
}
|
||||
}
|
||||
|
||||
private enum MediaSource {
|
||||
@@ -1067,6 +1299,15 @@ final class PlayerModel: ObservableObject {
|
||||
return accepted
|
||||
}
|
||||
|
||||
mutating func acceptCast() {
|
||||
playback = nil
|
||||
pendingRevision = nil
|
||||
restoration = nil
|
||||
seekPending = false
|
||||
}
|
||||
mutating func suspendHistory(revision: UInt64) {
|
||||
pendingRevision = revision
|
||||
}
|
||||
mutating func closeMedia() {
|
||||
playback = nil
|
||||
isLive = false
|
||||
@@ -1539,6 +1780,7 @@ private struct PlayerContent: View {
|
||||
|
||||
private var titleControls: some View {
|
||||
HStack(spacing: 4) {
|
||||
CastDeviceMenu(model: model)
|
||||
iconButton(model.isOnTop ? "pin.fill" : "pin",
|
||||
model.isOnTop ? "Stop Keeping Window on Top" : "Keep Window on Top", action: model.toggleOnTop)
|
||||
.imageScale(.small)
|
||||
@@ -1610,6 +1852,7 @@ private struct PlayerContent: View {
|
||||
iconButton("pip.exit", "Exit Compact Mode", action: model.toggleCompact)
|
||||
.disabled(!model.canToggleCompact)
|
||||
actionsMenu
|
||||
CastDeviceMenu(model: model)
|
||||
}
|
||||
if showsBufferReadout { bufferReadout }
|
||||
}
|
||||
@@ -1725,7 +1968,7 @@ private struct PlayerContent: View {
|
||||
if let view = model.videoView {
|
||||
VideoSurface(view: view)
|
||||
.accessibilityLabel("Video")
|
||||
.opacity(model.fileName == nil ? 0 : 1)
|
||||
.opacity(model.fileName == nil || model.castDevice != nil ? 0 : 1)
|
||||
.gesture(
|
||||
TapGesture(count: 2).onEnded { model.toggleFullScreen() }
|
||||
.exclusively(before: TapGesture().onEnded { model.hideControls() })
|
||||
@@ -1775,7 +2018,8 @@ private struct PlayerContent: View {
|
||||
} else if model.isBusy {
|
||||
VStack(spacing: 12) {
|
||||
ProgressView().controlSize(.large)
|
||||
Text(model.resolvingYouTube ? "Resolving YouTube…" : "Opening…")
|
||||
Text(model.castConnecting ? "Connecting to \(model.castDevice?.name ?? "Cast device")…" :
|
||||
(model.resolvingYouTube ? "Resolving YouTube…" : "Opening…"))
|
||||
.font(.callout).foregroundStyle(Color(SunsetPalette.secondaryText))
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
@@ -1865,6 +2109,14 @@ private struct PlayerContent: View {
|
||||
.background(PlayerGlass().clipShape(RoundedRectangle(cornerRadius: 12)).allowsHitTesting(false))
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.padding(.top, 32)
|
||||
} else if let device = model.castDevice {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "tv.badge.wifi").font(.system(size: 48))
|
||||
Text("Casting to \(device.name)").font(.headline)
|
||||
Button("Play on This Mac") { model.stopCasting() }
|
||||
}
|
||||
.foregroundStyle(Color(SunsetPalette.text))
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else if !model.state.has_video && model.state.has_audio {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "waveform").font(.system(size: 64, weight: .ultraLight))
|
||||
|
||||
Reference in New Issue
Block a user