mirror of
https://github.com/yattee/yattee.git
synced 2026-04-12 02:26:59 +00:00
Yattee v2 rewrite
This commit is contained in:
1894
Yattee/Services/RemoteControl/LocalNetworkService.swift
Normal file
1894
Yattee/Services/RemoteControl/LocalNetworkService.swift
Normal file
File diff suppressed because it is too large
Load Diff
999
Yattee/Services/RemoteControl/RemoteControlCoordinator.swift
Normal file
999
Yattee/Services/RemoteControl/RemoteControlCoordinator.swift
Normal file
@@ -0,0 +1,999 @@
|
||||
//
|
||||
// RemoteControlCoordinator.swift
|
||||
// Yattee
|
||||
//
|
||||
// Coordinates remote control between network service and player.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
/// Coordinates remote control functionality between the local network service and player.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class RemoteControlCoordinator {
|
||||
|
||||
// MARK: - Constants
|
||||
|
||||
private static let enabledKey = "RemoteControl.Enabled"
|
||||
|
||||
// MARK: - Remote Play Toast
|
||||
|
||||
/// Toast ID for the current remote play operation (for updating status).
|
||||
private var remotePlayToastID: UUID?
|
||||
|
||||
/// Device ID we're waiting for state confirmation from.
|
||||
private var pendingRemotePlayDeviceID: String?
|
||||
|
||||
/// Video ID we're waiting for (to ignore stale state updates).
|
||||
private var pendingRemotePlayVideoID: String?
|
||||
|
||||
/// Whether we're waiting to pause local playback after remote device starts playing (for "Move to" feature).
|
||||
private var pendingMoveOperation: Bool = false
|
||||
|
||||
/// Clears any pending remote play state. Called when starting a new operation or on failure.
|
||||
private func clearPendingRemotePlay() {
|
||||
if let toastID = remotePlayToastID {
|
||||
toastManager?.dismiss(id: toastID)
|
||||
}
|
||||
remotePlayToastID = nil
|
||||
pendingRemotePlayDeviceID = nil
|
||||
pendingRemotePlayVideoID = nil
|
||||
pendingMoveOperation = false
|
||||
}
|
||||
|
||||
// MARK: - Public State
|
||||
|
||||
/// Whether remote control is enabled. Persisted across app launches.
|
||||
var isEnabled: Bool = false {
|
||||
didSet {
|
||||
if isEnabled != oldValue {
|
||||
UserDefaults.standard.set(isEnabled, forKey: Self.enabledKey)
|
||||
if isEnabled {
|
||||
start()
|
||||
} else {
|
||||
stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Discovered devices on the local network.
|
||||
var discoveredDevices: [DiscoveredDevice] {
|
||||
networkService.discoveredDevices
|
||||
}
|
||||
|
||||
/// Device IDs we're currently controlling.
|
||||
private(set) var controllingDevices: Set<String> = []
|
||||
|
||||
/// Last known state of devices we're controlling (keyed by device ID).
|
||||
private(set) var remoteDeviceStates: [String: RemotePlayerState] = [:]
|
||||
|
||||
/// Whether we're being controlled by another device.
|
||||
private(set) var isBeingControlled: Bool = false
|
||||
|
||||
/// The device that's controlling us, if any.
|
||||
private(set) var controllingDevice: DiscoveredDevice?
|
||||
|
||||
/// This device's name for display.
|
||||
var deviceName: String {
|
||||
get { networkService.deviceName }
|
||||
set { networkService.deviceName = newValue }
|
||||
}
|
||||
|
||||
/// This device's ID.
|
||||
var deviceID: String {
|
||||
networkService.deviceID
|
||||
}
|
||||
|
||||
// MARK: - Dependencies
|
||||
|
||||
private let networkService: LocalNetworkService
|
||||
private weak var playerService: PlayerService?
|
||||
private weak var contentService: ContentService?
|
||||
private weak var instancesManager: InstancesManager?
|
||||
private weak var navigationCoordinator: NavigationCoordinator?
|
||||
private weak var mediaSourcesManager: MediaSourcesManager?
|
||||
private weak var toastManager: ToastManager?
|
||||
private weak var settingsManager: SettingsManager?
|
||||
|
||||
// MARK: - Logging Helpers
|
||||
|
||||
/// Log to LoggingService for comprehensive debugging.
|
||||
private func rcLog(_ operation: String, _ message: String, isWarning: Bool = false, isError: Bool = false, details: String? = nil) {
|
||||
let fullMessage = "[RemoteControl] \(operation) - \(message)"
|
||||
if isError {
|
||||
LoggingService.shared.logRemoteControlError(fullMessage, error: nil)
|
||||
} else if isWarning {
|
||||
LoggingService.shared.logRemoteControlWarning(fullMessage, details: details)
|
||||
} else {
|
||||
LoggingService.shared.logRemoteControl(fullMessage, details: details)
|
||||
}
|
||||
}
|
||||
|
||||
/// Log debug-level message. Only logs if verbose remote control logging is enabled.
|
||||
private func rcDebug(_ operation: String, _ message: String) {
|
||||
guard UserDefaults.standard.bool(forKey: "verboseRemoteControlLogging") else { return }
|
||||
let fullMessage = "[RemoteControl] \(operation) - \(message)"
|
||||
LoggingService.shared.logRemoteControlDebug(fullMessage)
|
||||
}
|
||||
|
||||
/// Task for listening to incoming commands.
|
||||
private var commandListenerTask: Task<Void, Never>?
|
||||
|
||||
/// Task for state broadcast timer.
|
||||
private var stateBroadcastTask: Task<Void, Never>?
|
||||
|
||||
/// Task for observing incognito mode changes.
|
||||
private var incognitoObserverTask: Task<Void, Never>?
|
||||
|
||||
/// Last state that was broadcast (for change detection).
|
||||
private var lastBroadcastState: RemotePlayerState?
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(networkService: LocalNetworkService) {
|
||||
self.networkService = networkService
|
||||
}
|
||||
|
||||
/// Set the player service reference (called after AppEnvironment is set up).
|
||||
func setPlayerService(_ playerService: PlayerService) {
|
||||
self.playerService = playerService
|
||||
}
|
||||
|
||||
/// Set the content service reference.
|
||||
func setContentService(_ contentService: ContentService) {
|
||||
self.contentService = contentService
|
||||
}
|
||||
|
||||
/// Set the instances manager reference.
|
||||
func setInstancesManager(_ instancesManager: InstancesManager) {
|
||||
self.instancesManager = instancesManager
|
||||
}
|
||||
|
||||
/// Set the navigation coordinator reference.
|
||||
func setNavigationCoordinator(_ navigationCoordinator: NavigationCoordinator) {
|
||||
self.navigationCoordinator = navigationCoordinator
|
||||
}
|
||||
|
||||
/// Set the media sources manager reference.
|
||||
func setMediaSourcesManager(_ manager: MediaSourcesManager) {
|
||||
self.mediaSourcesManager = manager
|
||||
}
|
||||
|
||||
/// Set the toast manager reference.
|
||||
func setToastManager(_ manager: ToastManager) {
|
||||
self.toastManager = manager
|
||||
}
|
||||
|
||||
/// Set the settings manager reference and start observing incognito mode.
|
||||
func setSettingsManager(_ manager: SettingsManager) {
|
||||
self.settingsManager = manager
|
||||
startIncognitoObserver()
|
||||
}
|
||||
|
||||
/// Restore persisted enabled state. Call after all services are set up.
|
||||
func restoreEnabledState() {
|
||||
let wasEnabled = UserDefaults.standard.bool(forKey: Self.enabledKey)
|
||||
if wasEnabled {
|
||||
rcLog("LIFECYCLE", "Restoring remote control enabled state from UserDefaults")
|
||||
isEnabled = true
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
/// Start remote control services (discovery + hosting).
|
||||
/// Does nothing if incognito mode is enabled - remote control is completely disabled in incognito.
|
||||
private func start() {
|
||||
let isIncognito = settingsManager?.incognitoModeEnabled ?? false
|
||||
|
||||
// Remote control is completely disabled in incognito mode
|
||||
if isIncognito {
|
||||
rcLog("LIFECYCLE", "Remote control disabled (incognito mode enabled)")
|
||||
return
|
||||
}
|
||||
|
||||
rcLog("LIFECYCLE", "Starting remote control services", details: "deviceName=\(networkService.deviceName), deviceID=\(networkService.deviceID)")
|
||||
|
||||
networkService.startHosting()
|
||||
networkService.startDiscovery()
|
||||
|
||||
// Reset the commands stream so the new listener Task gets a fresh stream
|
||||
// This is needed because AsyncStream doesn't work well with cancelled consumers
|
||||
networkService.resetCommandsStream()
|
||||
|
||||
startCommandListener()
|
||||
startStateBroadcast()
|
||||
|
||||
rcLog("LIFECYCLE", "Remote control services started")
|
||||
}
|
||||
|
||||
/// Stop all remote control services.
|
||||
private func stop() {
|
||||
rcLog("LIFECYCLE", "Stopping remote control services", details: "controlling=\(controllingDevices.count) devices")
|
||||
|
||||
commandListenerTask?.cancel()
|
||||
commandListenerTask = nil
|
||||
|
||||
stateBroadcastTask?.cancel()
|
||||
stateBroadcastTask = nil
|
||||
|
||||
networkService.disconnectAll()
|
||||
networkService.stopDiscovery()
|
||||
networkService.stopHosting()
|
||||
|
||||
controllingDevices.removeAll()
|
||||
isBeingControlled = false
|
||||
controllingDevice = nil
|
||||
|
||||
rcLog("LIFECYCLE", "Remote control services stopped")
|
||||
}
|
||||
|
||||
/// Start observing incognito mode changes.
|
||||
private func startIncognitoObserver() {
|
||||
incognitoObserverTask?.cancel()
|
||||
incognitoObserverTask = Task { [weak self] in
|
||||
while !Task.isCancelled {
|
||||
guard let self, let settings = self.settingsManager else { break }
|
||||
|
||||
let wasIncognito = settings.incognitoModeEnabled
|
||||
_ = withObservationTracking {
|
||||
settings.incognitoModeEnabled
|
||||
} onChange: { }
|
||||
|
||||
try? await Task.sleep(for: .milliseconds(100))
|
||||
guard !Task.isCancelled else { break }
|
||||
|
||||
let isIncognito = settings.incognitoModeEnabled
|
||||
if isIncognito != wasIncognito {
|
||||
self.handleIncognitoModeChanged(isIncognito: isIncognito)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle incognito mode changes by disabling remote control.
|
||||
/// Remote control is completely disabled in incognito mode to ensure the device is invisible.
|
||||
/// When incognito is disabled, user must manually re-enable remote control.
|
||||
private func handleIncognitoModeChanged(isIncognito: Bool) {
|
||||
if isIncognito && isEnabled {
|
||||
rcLog("LIFECYCLE", "Incognito enabled - disabling remote control")
|
||||
isEnabled = false // This triggers stop() via didSet
|
||||
}
|
||||
// When incognito is disabled, user must manually re-enable remote control
|
||||
}
|
||||
|
||||
/// Handle app scene phase changes - refresh services when returning to foreground.
|
||||
func handleScenePhase(_ phase: ScenePhase) {
|
||||
guard isEnabled else { return }
|
||||
|
||||
switch phase {
|
||||
case .active:
|
||||
let isIncognito = settingsManager?.incognitoModeEnabled ?? false
|
||||
rcLog("LIFECYCLE", "App became active - refreshing services", details: "incognito=\(isIncognito)")
|
||||
if !isIncognito {
|
||||
networkService.refreshServices()
|
||||
} else {
|
||||
networkService.refreshDiscoveryOnly()
|
||||
}
|
||||
case .background:
|
||||
#if os(iOS) || os(tvOS)
|
||||
let hideWhenBackgrounded = settingsManager?.remoteControlHideWhenBackgrounded ?? true
|
||||
let isIncognito = settingsManager?.incognitoModeEnabled ?? false
|
||||
// Only stop hosting if we were actually hosting (not incognito)
|
||||
if hideWhenBackgrounded && !isIncognito {
|
||||
rcLog("LIFECYCLE", "App entering background - stopping hosting to hide from remote devices")
|
||||
networkService.stopHosting()
|
||||
} else {
|
||||
rcLog("LIFECYCLE", "App entering background", details: "hideWhenBackgrounded=\(hideWhenBackgrounded), incognito=\(isIncognito)")
|
||||
}
|
||||
#else
|
||||
rcLog("LIFECYCLE", "App entering background")
|
||||
#endif
|
||||
case .inactive:
|
||||
rcDebug("LIFECYCLE", "App became inactive")
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Connection Management
|
||||
|
||||
/// Connect to a device to control it.
|
||||
func connect(to device: DiscoveredDevice) async throws {
|
||||
rcLog("CONTROL", "[\(device.name)] Connecting to control device")
|
||||
try await networkService.connect(to: device)
|
||||
controllingDevices.insert(device.id)
|
||||
rcLog("CONTROL", "[\(device.name)] Connected, requesting initial state")
|
||||
|
||||
// Request current state from the device
|
||||
try await networkService.send(command: .requestState, to: device.id)
|
||||
}
|
||||
|
||||
/// Disconnect from a device.
|
||||
func disconnect(from device: DiscoveredDevice) {
|
||||
rcLog("CONTROL", "[\(device.name)] Disconnecting from device")
|
||||
networkService.disconnect(from: device.id)
|
||||
controllingDevices.remove(device.id)
|
||||
}
|
||||
|
||||
// MARK: - Command Sending
|
||||
|
||||
/// Send a command to a specific device.
|
||||
func sendCommand(_ command: RemoteControlCommand, to device: DiscoveredDevice) async {
|
||||
let commandDesc = String(describing: command).prefix(50)
|
||||
do {
|
||||
try await networkService.send(command: command, to: device.id)
|
||||
rcLog("COMMAND", "[\(device.name)] Sent: \(commandDesc)")
|
||||
} catch {
|
||||
rcLog("COMMAND", "[\(device.name)] Failed to send \(commandDesc): \(error.localizedDescription)", isError: true)
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a command to all connected devices.
|
||||
func broadcastCommand(_ command: RemoteControlCommand) async {
|
||||
let commandDesc = String(describing: command).prefix(50)
|
||||
rcDebug("COMMAND", "Broadcasting: \(commandDesc)")
|
||||
await networkService.broadcast(command: command)
|
||||
}
|
||||
|
||||
// MARK: - Convenience Commands
|
||||
|
||||
/// Send play command to a device.
|
||||
func play(on device: DiscoveredDevice) async {
|
||||
await sendCommand(.play, to: device)
|
||||
}
|
||||
|
||||
/// Send pause command to a device.
|
||||
func pause(on device: DiscoveredDevice) async {
|
||||
await sendCommand(.pause, to: device)
|
||||
}
|
||||
|
||||
/// Send toggle play/pause command to a device.
|
||||
func togglePlayPause(on device: DiscoveredDevice) async {
|
||||
await sendCommand(.togglePlayPause, to: device)
|
||||
}
|
||||
|
||||
/// Send seek command to a device.
|
||||
func seek(to time: TimeInterval, on device: DiscoveredDevice) async {
|
||||
await sendCommand(.seek(time: time), to: device)
|
||||
}
|
||||
|
||||
/// Send volume command to a device.
|
||||
func setVolume(_ volume: Float, on device: DiscoveredDevice) async {
|
||||
await sendCommand(.setVolume(volume), to: device)
|
||||
}
|
||||
|
||||
/// Send mute command to a device.
|
||||
func setMuted(_ muted: Bool, on device: DiscoveredDevice) async {
|
||||
await sendCommand(.setMuted(muted), to: device)
|
||||
}
|
||||
|
||||
/// Send playback rate command to a device.
|
||||
func setRate(_ rate: Float, on device: DiscoveredDevice) async {
|
||||
await sendCommand(.setRate(rate), to: device)
|
||||
}
|
||||
|
||||
/// Load a video on a device.
|
||||
/// - Parameters:
|
||||
/// - videoID: The video ID to load.
|
||||
/// - videoTitle: Optional video title for logging.
|
||||
/// - instanceURL: The instance URL to use for loading the video.
|
||||
/// - startTime: Optional start time to seek to after loading.
|
||||
/// - pauseLocalPlayback: If true, pause local playback when remote device starts playing (for "Move to" feature).
|
||||
/// - device: The device to load the video on.
|
||||
func loadVideo(videoID: String, videoTitle: String? = nil, instanceURL: String?, startTime: TimeInterval? = nil, pauseLocalPlayback: Bool = false, on device: DiscoveredDevice) async {
|
||||
rcLog("REMOTEPLAY", "[\(device.name)] Starting remote play", details: "videoID=\(videoID), instance=\(instanceURL ?? "default"), startTime=\(startTime ?? 0), pauseLocal=\(pauseLocalPlayback)")
|
||||
|
||||
// Clear any stale pending state from previous timed-out operations
|
||||
clearPendingRemotePlay()
|
||||
|
||||
// First, verify we have a working connection to the device
|
||||
// This prevents sending into dead connections that look "ready"
|
||||
rcLog("REMOTEPLAY", "[\(device.name)] Verifying connection before send...")
|
||||
let connectionVerified = await networkService.ensureConnection(to: device.id)
|
||||
|
||||
guard connectionVerified else {
|
||||
rcLog("REMOTEPLAY", "[\(device.name)] Connection verification FAILED", isError: true)
|
||||
toastManager?.show(
|
||||
category: .remoteControl,
|
||||
title: String(localized: "toast.remote.connectionFailed.title"),
|
||||
subtitle: device.name,
|
||||
icon: "exclamationmark.triangle.fill",
|
||||
iconColor: .orange,
|
||||
autoDismissDelay: 3.0
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
rcLog("REMOTEPLAY", "[\(device.name)] Connection verified, sending loadVideo command")
|
||||
|
||||
// Show persistent toast for sending status
|
||||
let toastID = toastManager?.show(
|
||||
category: .remoteControl,
|
||||
title: String(localized: "toast.remote.playingOn.title"),
|
||||
subtitle: device.name,
|
||||
icon: nil, // Shows ProgressView for remoteControl category
|
||||
iconColor: nil,
|
||||
autoDismissDelay: 10.0, // Timeout if no response
|
||||
isPersistent: true
|
||||
)
|
||||
remotePlayToastID = toastID
|
||||
pendingRemotePlayDeviceID = device.id
|
||||
pendingRemotePlayVideoID = videoID
|
||||
pendingMoveOperation = pauseLocalPlayback
|
||||
|
||||
// For move operations, use handshake protocol: remote prepares but waits for play command
|
||||
let awaitPlayCommand = pauseLocalPlayback
|
||||
await sendCommand(.loadVideo(videoID: videoID, instanceURL: instanceURL, startTime: startTime, awaitPlayCommand: awaitPlayCommand), to: device)
|
||||
rcLog("REMOTEPLAY", "[\(device.name)] loadVideo command sent, waiting for state update...")
|
||||
}
|
||||
|
||||
/// Close the current video on a device.
|
||||
func closeVideo(on device: DiscoveredDevice) async {
|
||||
await sendCommand(.closeVideo, to: device)
|
||||
}
|
||||
|
||||
/// Toggle fullscreen on a device.
|
||||
func toggleFullscreen(on device: DiscoveredDevice) async {
|
||||
await sendCommand(.toggleFullscreen, to: device)
|
||||
}
|
||||
|
||||
/// Play next video in queue on a device.
|
||||
func playNext(on device: DiscoveredDevice) async {
|
||||
await sendCommand(.playNext, to: device)
|
||||
}
|
||||
|
||||
/// Play previous video in history on a device.
|
||||
func playPrevious(on device: DiscoveredDevice) async {
|
||||
await sendCommand(.playPrevious, to: device)
|
||||
}
|
||||
|
||||
// MARK: - State Updates
|
||||
|
||||
/// Update the network service with current player state.
|
||||
func updatePlayerState() {
|
||||
guard let playerService else { return }
|
||||
|
||||
let state = playerService.state
|
||||
networkService.updateAdvertisement(
|
||||
videoTitle: state.currentVideo?.title,
|
||||
channelName: state.currentVideo?.author.name,
|
||||
thumbnailURL: state.currentVideo?.bestThumbnail?.url,
|
||||
isPlaying: state.playbackState == .playing
|
||||
)
|
||||
}
|
||||
|
||||
/// Get current player state for sharing.
|
||||
func currentRemoteState() -> RemotePlayerState {
|
||||
guard let playerService else { return .idle }
|
||||
|
||||
let state = playerService.state
|
||||
|
||||
// Compute fullscreen state (same logic as shouldShowFullscreenButton in PlayerControlsActions)
|
||||
#if os(iOS)
|
||||
let isIPad = UIDevice.current.userInterfaceIdiom == .pad
|
||||
let screenBounds = UIScreen.main.bounds
|
||||
let isWideScreenLayout = screenBounds.width > screenBounds.height
|
||||
// iPad in widescreen = can't force rotation, so fullscreen toggle not available
|
||||
let canToggleFullscreen = !(isIPad && isWideScreenLayout) && state.currentVideo != nil
|
||||
let isFullscreen = isWideScreenLayout
|
||||
#else
|
||||
let canToggleFullscreen = false
|
||||
let isFullscreen = false
|
||||
#endif
|
||||
|
||||
return RemotePlayerState(
|
||||
videoID: state.currentVideo?.id.videoID,
|
||||
videoTitle: state.currentVideo?.title,
|
||||
channelName: state.currentVideo?.author.name,
|
||||
thumbnailURL: state.currentVideo?.bestThumbnail?.url,
|
||||
currentTime: state.currentTime,
|
||||
duration: state.duration,
|
||||
isPlaying: state.playbackState == .playing,
|
||||
rate: Float(state.rate.rawValue),
|
||||
volume: state.volume,
|
||||
isMuted: state.isMuted,
|
||||
volumeMode: GlobalLayoutSettings.cached.volumeMode.rawValue,
|
||||
isFullscreen: isFullscreen,
|
||||
canToggleFullscreen: canToggleFullscreen,
|
||||
hasPrevious: state.hasPrevious,
|
||||
hasNext: state.hasNext
|
||||
)
|
||||
}
|
||||
|
||||
/// Broadcasts the current state to all connected devices.
|
||||
/// Call this when settings change that affect remote control behavior (e.g., volume mode).
|
||||
func broadcastStateUpdate() {
|
||||
Task {
|
||||
let state = currentRemoteState()
|
||||
await networkService.broadcast(command: .stateUpdate(state))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Command Listening
|
||||
|
||||
private func startCommandListener() {
|
||||
commandListenerTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
|
||||
for await message in networkService.incomingCommands {
|
||||
await self.handleIncomingCommand(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleIncomingCommand(_ message: RemoteControlMessage) async {
|
||||
let senderName = message.senderDeviceName ?? message.senderDeviceID
|
||||
let commandDesc = String(describing: message.command).prefix(80)
|
||||
rcLog("HANDLE", "[\(senderName)] Handling: \(commandDesc)")
|
||||
|
||||
// Track that we're being controlled
|
||||
if case .stateUpdate = message.command {
|
||||
// State updates are responses, not control
|
||||
} else if case .requestState = message.command {
|
||||
// Don't respond to state requests in incognito mode - device should be invisible
|
||||
let isIncognito = settingsManager?.incognitoModeEnabled ?? false
|
||||
guard !isIncognito else {
|
||||
rcLog("HANDLE", "[\(senderName)] Ignoring state request (incognito mode)")
|
||||
return
|
||||
}
|
||||
// Request for state, respond with current state - do this in background to not block command processing
|
||||
rcLog("HANDLE", "[\(senderName)] Sending state response")
|
||||
let state = currentRemoteState()
|
||||
let deviceID = message.senderDeviceID
|
||||
Task {
|
||||
do {
|
||||
try await networkService.send(
|
||||
command: .stateUpdate(state),
|
||||
to: deviceID
|
||||
)
|
||||
self.rcDebug("HANDLE", "[\(senderName)] State response sent")
|
||||
} catch {
|
||||
self.rcLog("HANDLE", "[\(senderName)] Failed to send state: \(error.localizedDescription)", isError: true)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Being controlled
|
||||
isBeingControlled = true
|
||||
controllingDevice = discoveredDevices.first { $0.id == message.senderDeviceID }
|
||||
rcLog("HANDLE", "[\(senderName)] Now being controlled by this device")
|
||||
}
|
||||
|
||||
// Handle the command
|
||||
switch message.command {
|
||||
case .play:
|
||||
rcDebug("HANDLE", "[\(senderName)] Executing: play")
|
||||
playerService?.resume()
|
||||
|
||||
case .pause:
|
||||
rcDebug("HANDLE", "[\(senderName)] Executing: pause")
|
||||
playerService?.pause()
|
||||
|
||||
case .togglePlayPause:
|
||||
rcDebug("HANDLE", "[\(senderName)] Executing: togglePlayPause")
|
||||
playerService?.togglePlayPause()
|
||||
|
||||
case .seek(let time):
|
||||
rcDebug("HANDLE", "[\(senderName)] Executing: seek to \(time)s")
|
||||
await playerService?.seek(to: time)
|
||||
|
||||
case .setVolume(let volume):
|
||||
// Only handle volume commands when in-app (MPV) volume mode
|
||||
guard GlobalLayoutSettings.cached.volumeMode == .mpv else {
|
||||
rcDebug("HANDLE", "[\(senderName)] Ignoring setVolume - system volume mode active")
|
||||
return
|
||||
}
|
||||
if let backend = playerService?.currentBackend {
|
||||
rcDebug("HANDLE", "[\(senderName)] Executing: setVolume to \(volume)")
|
||||
backend.volume = volume
|
||||
playerService?.state.volume = volume
|
||||
} else {
|
||||
rcLog("HANDLE", "[\(senderName)] Cannot set volume - no player backend", isError: true)
|
||||
}
|
||||
|
||||
case .setMuted(let muted):
|
||||
// Only handle mute commands when in-app (MPV) volume mode
|
||||
guard GlobalLayoutSettings.cached.volumeMode == .mpv else {
|
||||
rcDebug("HANDLE", "[\(senderName)] Ignoring setMuted - system volume mode active")
|
||||
return
|
||||
}
|
||||
if let backend = playerService?.currentBackend {
|
||||
rcDebug("HANDLE", "[\(senderName)] Executing: setMuted to \(muted)")
|
||||
backend.isMuted = muted
|
||||
playerService?.state.isMuted = muted
|
||||
} else {
|
||||
rcLog("HANDLE", "[\(senderName)] Cannot set muted - no player backend", isError: true)
|
||||
}
|
||||
|
||||
case .setRate(let rate):
|
||||
rcDebug("HANDLE", "[\(senderName)] Executing: setRate to \(rate)")
|
||||
playerService?.currentBackend?.rate = rate
|
||||
if let playbackRate = PlaybackRate(rawValue: Double(rate)) {
|
||||
playerService?.state.rate = playbackRate
|
||||
}
|
||||
|
||||
case .loadVideo(let videoID, let instanceURLString, let startTime, let awaitPlayCommand):
|
||||
rcLog("HANDLE", "[\(senderName)] Executing: loadVideo", details: "videoID=\(videoID), instance=\(instanceURLString ?? "default"), startTime=\(startTime ?? 0), awaitPlay=\(awaitPlayCommand ?? false)")
|
||||
// Show toast indicating remote video opening
|
||||
if let deviceName = controllingDevice?.name {
|
||||
toastManager?.show(
|
||||
category: .remoteControl,
|
||||
title: String(localized: "toast.remote.openingVideo.title"),
|
||||
subtitle: deviceName,
|
||||
icon: "arrow.down.circle.fill",
|
||||
iconColor: .blue,
|
||||
autoDismissDelay: 5.0
|
||||
)
|
||||
}
|
||||
await handleLoadVideo(videoID: videoID, instanceURLString: instanceURLString, startTime: startTime, awaitPlayCommand: awaitPlayCommand ?? false, senderDeviceID: message.senderDeviceID)
|
||||
|
||||
case .closeVideo:
|
||||
rcLog("HANDLE", "[\(senderName)] Executing: closeVideo")
|
||||
// Collapse the player UI first
|
||||
navigationCoordinator?.isPlayerExpanded = false
|
||||
// Then stop playback
|
||||
playerService?.stop()
|
||||
// Send state update after a short delay so the stop has time to complete
|
||||
Task {
|
||||
try? await Task.sleep(for: .milliseconds(300))
|
||||
let state = self.currentRemoteState()
|
||||
await self.networkService.broadcast(command: .stateUpdate(state))
|
||||
}
|
||||
|
||||
case .toggleFullscreen:
|
||||
rcLog("HANDLE", "[\(senderName)] Executing: toggleFullscreen")
|
||||
// Trigger fullscreen toggle via navigation coordinator
|
||||
navigationCoordinator?.pendingFullscreenToggle += 1
|
||||
// Send state update after a short delay so the orientation change has time to complete
|
||||
Task {
|
||||
try? await Task.sleep(for: .milliseconds(500))
|
||||
let state = self.currentRemoteState()
|
||||
await self.networkService.broadcast(command: .stateUpdate(state))
|
||||
}
|
||||
|
||||
case .playNext:
|
||||
rcLog("HANDLE", "[\(senderName)] Executing: playNext")
|
||||
await playerService?.playNext()
|
||||
|
||||
case .playPrevious:
|
||||
rcLog("HANDLE", "[\(senderName)] Executing: playPrevious")
|
||||
await playerService?.playPrevious()
|
||||
|
||||
case .requestState:
|
||||
// Already handled above
|
||||
break
|
||||
|
||||
case .stateUpdate(let state):
|
||||
// This is a response to our requestState - store it for the UI
|
||||
remoteDeviceStates[message.senderDeviceID] = state
|
||||
|
||||
// Also update the discovered device list so the device list UI shows current video
|
||||
networkService.updateDiscoveredDevicePlaybackState(
|
||||
deviceID: message.senderDeviceID,
|
||||
videoTitle: state.videoTitle,
|
||||
channelName: state.channelName,
|
||||
thumbnailURL: state.thumbnailURL,
|
||||
isPlaying: state.isPlaying
|
||||
)
|
||||
rcLog("HANDLE", "[\(senderName)] Received state update", details: "video=\(state.videoTitle ?? "none"), playing=\(state.isPlaying)")
|
||||
|
||||
// Check if we were waiting for loadVideo confirmation
|
||||
rcDebug("HANDLE", "Remote play check: pending=\(pendingRemotePlayDeviceID ?? "none"), expected=\(pendingRemotePlayVideoID ?? "none")")
|
||||
if let toastID = remotePlayToastID,
|
||||
message.senderDeviceID == pendingRemotePlayDeviceID,
|
||||
let expectedVideoID = pendingRemotePlayVideoID {
|
||||
let senderDevice = discoveredDevices.first { $0.id == message.senderDeviceID }
|
||||
let deviceName = senderDevice?.name ?? "device"
|
||||
|
||||
rcDebug("HANDLE", "Remote play state: playing=\(state.isPlaying), videoID=\(state.videoID ?? "nil"), expected=\(expectedVideoID)")
|
||||
|
||||
// Only process if the state is for our expected video (ignore stale updates)
|
||||
if let receivedVideoID = state.videoID, receivedVideoID == expectedVideoID {
|
||||
if pendingMoveOperation && !state.isPlaying {
|
||||
// Handshake protocol: remote device is ready but waiting for play command
|
||||
// Pause local playback first, then tell remote to start
|
||||
rcLog("REMOTEPLAY", "[\(deviceName)] Remote ready - pausing local and sending play command")
|
||||
playerService?.pause()
|
||||
|
||||
// Send play command to remote device
|
||||
if let device = senderDevice {
|
||||
Task {
|
||||
await self.sendCommand(.play, to: device)
|
||||
}
|
||||
}
|
||||
|
||||
// Update toast to success
|
||||
toastManager?.update(
|
||||
id: toastID,
|
||||
title: String(localized: "toast.remote.playingOnSuccess.title"),
|
||||
subtitle: deviceName,
|
||||
icon: "checkmark.circle.fill",
|
||||
iconColor: .green,
|
||||
autoDismissDelay: 2.0
|
||||
)
|
||||
|
||||
remotePlayToastID = nil
|
||||
pendingRemotePlayDeviceID = nil
|
||||
pendingRemotePlayVideoID = nil
|
||||
pendingMoveOperation = false
|
||||
} else if state.isPlaying {
|
||||
// Video started playing successfully (normal flow without handshake)
|
||||
rcLog("REMOTEPLAY", "[\(deviceName)] Video started playing SUCCESS")
|
||||
toastManager?.update(
|
||||
id: toastID,
|
||||
title: String(localized: "toast.remote.playingOnSuccess.title"),
|
||||
subtitle: deviceName,
|
||||
icon: "checkmark.circle.fill",
|
||||
iconColor: .green,
|
||||
autoDismissDelay: 2.0
|
||||
)
|
||||
|
||||
remotePlayToastID = nil
|
||||
pendingRemotePlayDeviceID = nil
|
||||
pendingRemotePlayVideoID = nil
|
||||
pendingMoveOperation = false
|
||||
}
|
||||
// If videoID matches but not playing yet and not a move operation, keep showing loading
|
||||
}
|
||||
// Ignore state updates with different/nil videoID - they're stale
|
||||
}
|
||||
}
|
||||
|
||||
// Send state update after handling command
|
||||
switch message.command {
|
||||
case .stateUpdate:
|
||||
// Don't respond to state updates with more state updates
|
||||
break
|
||||
case .requestState:
|
||||
// Already sent above
|
||||
break
|
||||
case .loadVideo, .closeVideo, .toggleFullscreen:
|
||||
// Don't send immediate state update for loadVideo/closeVideo/toggleFullscreen - the state is still changing
|
||||
// The periodic state broadcast will send the update once the state settles
|
||||
break
|
||||
default:
|
||||
// Broadcast updated state to all connected peers
|
||||
let state = currentRemoteState()
|
||||
await networkService.broadcast(command: .stateUpdate(state))
|
||||
}
|
||||
}
|
||||
|
||||
private func handleLoadVideo(videoID: String, instanceURLString: String?, startTime: TimeInterval? = nil, awaitPlayCommand: Bool = false, senderDeviceID: String? = nil) async {
|
||||
rcLog("LOADVIDEO", "Loading video: \(videoID)", details: "startTime=\(startTime ?? 0), awaitPlay=\(awaitPlayCommand)")
|
||||
|
||||
// Check if we're already playing the same video - just seek instead of reloading
|
||||
if let currentVideoID = playerService?.state.currentVideo?.id.videoID,
|
||||
currentVideoID == videoID {
|
||||
rcLog("LOADVIDEO", "Same video already playing - just seeking to \(startTime ?? 0)s")
|
||||
|
||||
if let seekTime = startTime {
|
||||
await playerService?.seek(to: seekTime)
|
||||
}
|
||||
|
||||
// Send state update to confirm
|
||||
let state = currentRemoteState()
|
||||
if let senderID = senderDeviceID {
|
||||
try? await networkService.send(command: .stateUpdate(state), to: senderID)
|
||||
} else {
|
||||
await networkService.broadcast(command: .stateUpdate(state))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Check if this is a WebDAV video (format: "UUID:path")
|
||||
// WebDAV videoIDs start with a UUID followed by colon and path
|
||||
let components = videoID.split(separator: ":", maxSplits: 1)
|
||||
if components.count == 2,
|
||||
let sourceUUID = UUID(uuidString: String(components[0])),
|
||||
let mediaSourcesManager,
|
||||
let source = mediaSourcesManager.source(byID: sourceUUID) {
|
||||
// This is a WebDAV video - play directly
|
||||
rcLog("LOADVIDEO", "Detected WebDAV video, loading from source: \(source.name)")
|
||||
await handleLoadMediaSourceVideo(
|
||||
videoID: videoID,
|
||||
path: String(components[1]),
|
||||
source: source,
|
||||
startTime: startTime,
|
||||
awaitPlayCommand: awaitPlayCommand,
|
||||
senderDeviceID: senderDeviceID
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Existing API video loading...
|
||||
guard let contentService, let instancesManager else {
|
||||
rcLog("LOADVIDEO", "Missing contentService or instancesManager", isError: true)
|
||||
return
|
||||
}
|
||||
|
||||
let instance: Instance?
|
||||
if let urlString = instanceURLString, let url = URL(string: urlString) {
|
||||
// First try to find an exact match in configured instances
|
||||
if let configuredInstance = instancesManager.instances.first(where: { $0.url == url }) {
|
||||
instance = configuredInstance
|
||||
rcDebug("LOADVIDEO", "Using configured instance: \(instance?.name ?? "not found")")
|
||||
} else {
|
||||
// Instance not configured locally - check if it's a PeerTube video
|
||||
// PeerTube video IDs are numeric, while YouTube IDs are alphanumeric with dashes/underscores
|
||||
let isPeerTubeVideoID = videoID.allSatisfy { $0.isNumber }
|
||||
|
||||
if isPeerTubeVideoID {
|
||||
// Create a temporary PeerTube instance for this request
|
||||
rcDebug("LOADVIDEO", "Creating temporary PeerTube instance for: \(url.absoluteString)")
|
||||
instance = Instance(
|
||||
type: .peertube,
|
||||
url: url,
|
||||
name: url.host,
|
||||
isEnabled: true
|
||||
)
|
||||
} else {
|
||||
// For YouTube content, any configured instance that supports it works
|
||||
instance = instancesManager.instances.first { $0.isEnabled && $0.isYouTubeInstance }
|
||||
rcDebug("LOADVIDEO", "Using fallback YouTube instance: \(instance?.name ?? "none")")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
instance = instancesManager.instances.first { $0.isEnabled }
|
||||
rcDebug("LOADVIDEO", "Using default instance: \(instance?.name ?? "none")")
|
||||
}
|
||||
|
||||
guard let instance else {
|
||||
rcLog("LOADVIDEO", "No instance available to load video", isError: true)
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
rcLog("LOADVIDEO", "Fetching video metadata from \(instance.name ?? "unknown")")
|
||||
let video = try await contentService.video(id: videoID, instance: instance)
|
||||
|
||||
if awaitPlayCommand {
|
||||
// Handshake protocol: load video, then pause and notify sender we're ready
|
||||
rcLog("LOADVIDEO", "Handshake mode: loading video, will pause when ready")
|
||||
playerService?.openVideo(video, startTime: startTime)
|
||||
|
||||
// Wait for video to start playing, then pause
|
||||
// This gives time for the video to load and seek to position
|
||||
try? await Task.sleep(for: .milliseconds(1000))
|
||||
|
||||
// Pause playback - video is loaded and at correct position but not playing
|
||||
playerService?.pause()
|
||||
rcLog("LOADVIDEO", "Video loaded and paused, ready for handoff")
|
||||
|
||||
// Send state update to let sender know we're ready (isPlaying will be false)
|
||||
let state = currentRemoteState()
|
||||
rcLog("LOADVIDEO", "Sending ready state to sender", details: "videoID=\(state.videoID ?? "nil"), playing=\(state.isPlaying)")
|
||||
if let senderID = senderDeviceID {
|
||||
try? await networkService.send(command: .stateUpdate(state), to: senderID)
|
||||
} else {
|
||||
await networkService.broadcast(command: .stateUpdate(state))
|
||||
}
|
||||
} else {
|
||||
// Normal mode: start playing immediately
|
||||
playerService?.openVideo(video, startTime: startTime)
|
||||
rcLog("LOADVIDEO", "Loaded and started playing: \(video.title)", details: "startTime=\(startTime ?? 0)")
|
||||
}
|
||||
} catch {
|
||||
rcLog("LOADVIDEO", "Failed to load video \(videoID): \(error.localizedDescription)", isError: true)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleLoadMediaSourceVideo(videoID: String, path: String, source: MediaSource, startTime: TimeInterval? = nil, awaitPlayCommand: Bool = false, senderDeviceID: String? = nil) async {
|
||||
// Construct full URL from source base URL + path
|
||||
let url = source.url.appendingPathComponent(path)
|
||||
|
||||
// Extract filename for title
|
||||
let title = url.deletingPathExtension().lastPathComponent
|
||||
|
||||
rcLog("LOADVIDEO", "Loading WebDAV video: \(title)", details: "source=\(source.name), path=\(path), startTime=\(startTime ?? 0), awaitPlay=\(awaitPlayCommand)")
|
||||
|
||||
// Create Video with correct ContentSource
|
||||
let video = Video(
|
||||
id: VideoID(
|
||||
source: .extracted(extractor: MediaFile.webdavProvider, originalURL: url),
|
||||
videoID: videoID
|
||||
),
|
||||
title: title,
|
||||
description: nil,
|
||||
author: Author(id: source.id.uuidString, name: source.name),
|
||||
duration: 0,
|
||||
publishedAt: nil,
|
||||
publishedText: nil,
|
||||
viewCount: nil,
|
||||
likeCount: nil,
|
||||
thumbnails: [],
|
||||
isLive: false,
|
||||
isUpcoming: false,
|
||||
scheduledStartTime: nil
|
||||
)
|
||||
|
||||
if awaitPlayCommand {
|
||||
// Handshake protocol: load video, then pause and notify sender we're ready
|
||||
rcLog("LOADVIDEO", "Handshake mode: loading WebDAV video, will pause when ready")
|
||||
playerService?.openVideo(video, startTime: startTime)
|
||||
|
||||
// Wait for video to start playing, then pause
|
||||
try? await Task.sleep(for: .milliseconds(1000))
|
||||
|
||||
// Pause playback - video is loaded and at correct position but not playing
|
||||
playerService?.pause()
|
||||
rcLog("LOADVIDEO", "WebDAV video loaded and paused, ready for handoff")
|
||||
|
||||
// Send state update to let sender know we're ready (isPlaying will be false)
|
||||
let state = currentRemoteState()
|
||||
rcLog("LOADVIDEO", "Sending ready state to sender", details: "videoID=\(state.videoID ?? "nil"), playing=\(state.isPlaying)")
|
||||
if let senderID = senderDeviceID {
|
||||
try? await networkService.send(command: .stateUpdate(state), to: senderID)
|
||||
} else {
|
||||
await networkService.broadcast(command: .stateUpdate(state))
|
||||
}
|
||||
} else {
|
||||
// Normal mode: start playing immediately
|
||||
playerService?.openVideo(video, startTime: startTime)
|
||||
rcLog("LOADVIDEO", "WebDAV video opened: \(title)", details: "startTime=\(startTime ?? 0)")
|
||||
}
|
||||
}
|
||||
|
||||
private func expandPlayerIfNeeded() {
|
||||
#if os(iOS) || os(macOS)
|
||||
let isPiPActive = (playerService?.currentBackend as? MPVBackend)?.isPiPActive ?? false
|
||||
rcDebug("PLAYER", "expandPlayerIfNeeded - isPiPActive=\(isPiPActive)")
|
||||
if !isPiPActive {
|
||||
navigationCoordinator?.expandPlayer()
|
||||
}
|
||||
#else
|
||||
navigationCoordinator?.expandPlayer()
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - State Broadcasting
|
||||
|
||||
private func startStateBroadcast() {
|
||||
stateBroadcastTask = Task { [weak self] in
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(for: .seconds(2))
|
||||
|
||||
guard let self, !Task.isCancelled else { break }
|
||||
|
||||
// Don't broadcast state in incognito mode - device should be invisible
|
||||
let isIncognito = self.settingsManager?.incognitoModeEnabled ?? false
|
||||
guard !isIncognito else { continue }
|
||||
|
||||
let state = self.currentRemoteState()
|
||||
|
||||
// Only broadcast if state actually changed
|
||||
if state != self.lastBroadcastState {
|
||||
self.lastBroadcastState = state
|
||||
self.updatePlayerState()
|
||||
await self.networkService.broadcast(command: .stateUpdate(state))
|
||||
}
|
||||
|
||||
// Re-request state from connected devices that still show UUID as name
|
||||
// (their initial probe response may have been lost)
|
||||
await self.requestStateFromUnnamedDevices()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Request state from connected devices that still have UUID as their display name.
|
||||
private func requestStateFromUnnamedDevices() async {
|
||||
for device in discoveredDevices {
|
||||
// Check if name is still the UUID (probe response may have been lost)
|
||||
let looksLikeUUID = device.name.count > 30 && device.name.contains("-")
|
||||
let isConnected = networkService.connectedPeers.contains(device.id)
|
||||
if looksLikeUUID && isConnected {
|
||||
rcDebug("BROADCAST", "Re-requesting state from unnamed device: \(device.id.prefix(8))...")
|
||||
do {
|
||||
try await networkService.send(command: .requestState, to: device.id)
|
||||
} catch {
|
||||
// Connection might be dead, ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
257
Yattee/Services/RemoteControl/RemoteControlProtocol.swift
Normal file
257
Yattee/Services/RemoteControl/RemoteControlProtocol.swift
Normal file
@@ -0,0 +1,257 @@
|
||||
//
|
||||
// RemoteControlProtocol.swift
|
||||
// Yattee
|
||||
//
|
||||
// Protocol and data types for remote control between Yattee instances.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
// MARK: - Device Platform
|
||||
|
||||
/// Platform type for identifying device capabilities.
|
||||
enum DevicePlatform: String, Codable, Sendable {
|
||||
case iOS
|
||||
case macOS
|
||||
case tvOS
|
||||
|
||||
/// Returns the platform of the current device.
|
||||
static var current: DevicePlatform {
|
||||
#if os(iOS)
|
||||
return .iOS
|
||||
#elseif os(macOS)
|
||||
return .macOS
|
||||
#elseif os(tvOS)
|
||||
return .tvOS
|
||||
#else
|
||||
return .iOS
|
||||
#endif
|
||||
}
|
||||
|
||||
/// SF Symbol name for the platform icon.
|
||||
var iconName: String {
|
||||
switch self {
|
||||
case .iOS:
|
||||
return "iphone"
|
||||
case .macOS:
|
||||
return "laptopcomputer"
|
||||
case .tvOS:
|
||||
return "appletv"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Remote Control Commands
|
||||
|
||||
/// Commands that can be sent between devices.
|
||||
enum RemoteControlCommand: Codable, Sendable {
|
||||
case play
|
||||
case pause
|
||||
case togglePlayPause
|
||||
case seek(time: TimeInterval)
|
||||
case setVolume(Float)
|
||||
case setMuted(Bool)
|
||||
case setRate(Float)
|
||||
case loadVideo(videoID: String, instanceURL: String?, startTime: TimeInterval?, awaitPlayCommand: Bool?)
|
||||
case closeVideo
|
||||
case toggleFullscreen
|
||||
case playNext
|
||||
case playPrevious
|
||||
case requestState
|
||||
case stateUpdate(RemotePlayerState)
|
||||
}
|
||||
|
||||
// MARK: - Remote Player State
|
||||
|
||||
/// Snapshot of player state for sharing with remote devices.
|
||||
struct RemotePlayerState: Codable, Sendable, Equatable {
|
||||
let videoID: String?
|
||||
let videoTitle: String?
|
||||
let channelName: String?
|
||||
let thumbnailURL: URL?
|
||||
let currentTime: TimeInterval
|
||||
let duration: TimeInterval
|
||||
let isPlaying: Bool
|
||||
let rate: Float
|
||||
let volume: Float
|
||||
let isMuted: Bool
|
||||
/// Volume mode of the device (mpv = in-app control, system = device volume).
|
||||
/// When system mode, remote devices should hide volume controls.
|
||||
let volumeMode: String?
|
||||
|
||||
/// Whether the device is currently in fullscreen (landscape) mode.
|
||||
let isFullscreen: Bool
|
||||
|
||||
/// Whether fullscreen toggle is available (same logic as player controls fullscreen button).
|
||||
let canToggleFullscreen: Bool
|
||||
|
||||
/// Whether there's a previous video in history.
|
||||
let hasPrevious: Bool
|
||||
|
||||
/// Whether there's a next video in queue.
|
||||
let hasNext: Bool
|
||||
|
||||
/// Creates an empty/idle state.
|
||||
static let idle = RemotePlayerState(
|
||||
videoID: nil,
|
||||
videoTitle: nil,
|
||||
channelName: nil,
|
||||
thumbnailURL: nil,
|
||||
currentTime: 0,
|
||||
duration: 0,
|
||||
isPlaying: false,
|
||||
rate: 1.0,
|
||||
volume: 1.0,
|
||||
isMuted: false,
|
||||
volumeMode: "mpv",
|
||||
isFullscreen: false,
|
||||
canToggleFullscreen: false,
|
||||
hasPrevious: false,
|
||||
hasNext: false
|
||||
)
|
||||
|
||||
/// Whether this device accepts in-app volume control.
|
||||
var acceptsVolumeControl: Bool {
|
||||
volumeMode == nil || volumeMode == "mpv"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Discovered Device
|
||||
|
||||
/// A Yattee instance discovered on the local network.
|
||||
struct DiscoveredDevice: Identifiable, Codable, Sendable, Equatable, Hashable {
|
||||
/// Unique identifier for this device instance.
|
||||
let id: String
|
||||
|
||||
/// User-visible device name (e.g., "Arek's MacBook Pro").
|
||||
let name: String
|
||||
|
||||
/// Platform type (iOS, macOS, tvOS).
|
||||
let platform: DevicePlatform
|
||||
|
||||
/// Title of currently playing video, if any.
|
||||
let currentVideoTitle: String?
|
||||
|
||||
/// Channel name of currently playing video, if any.
|
||||
let currentChannelName: String?
|
||||
|
||||
/// Thumbnail URL of currently playing video, if any.
|
||||
let currentVideoThumbnailURL: URL?
|
||||
|
||||
/// Whether video is currently playing.
|
||||
let isPlaying: Bool
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(id)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Remote Control Message
|
||||
|
||||
/// A message sent between devices.
|
||||
struct RemoteControlMessage: Codable, Sendable {
|
||||
/// Unique message identifier for deduplication.
|
||||
let id: UUID
|
||||
|
||||
/// Device ID of the sender.
|
||||
let senderDeviceID: String
|
||||
|
||||
/// Device name of the sender (for display when TXT record not available).
|
||||
let senderDeviceName: String?
|
||||
|
||||
/// Platform of the sender.
|
||||
let senderPlatform: DevicePlatform?
|
||||
|
||||
/// Device ID of the target (nil for broadcast).
|
||||
let targetDeviceID: String?
|
||||
|
||||
/// The command being sent.
|
||||
let command: RemoteControlCommand
|
||||
|
||||
/// When the message was created.
|
||||
let timestamp: Date
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
senderDeviceID: String,
|
||||
senderDeviceName: String? = nil,
|
||||
senderPlatform: DevicePlatform? = nil,
|
||||
targetDeviceID: String? = nil,
|
||||
command: RemoteControlCommand,
|
||||
timestamp: Date = Date()
|
||||
) {
|
||||
self.id = id
|
||||
self.senderDeviceID = senderDeviceID
|
||||
self.senderDeviceName = senderDeviceName
|
||||
self.senderPlatform = senderPlatform
|
||||
self.targetDeviceID = targetDeviceID
|
||||
self.command = command
|
||||
self.timestamp = timestamp
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Device Info for Advertisement
|
||||
|
||||
/// Information advertised via Bonjour TXT record.
|
||||
struct DeviceAdvertisement: Codable, Sendable {
|
||||
let deviceID: String
|
||||
let deviceName: String
|
||||
let platform: DevicePlatform
|
||||
let currentVideoTitle: String?
|
||||
let currentChannelName: String?
|
||||
let currentVideoThumbnailURL: URL?
|
||||
let isPlaying: Bool
|
||||
|
||||
/// Encodes to a TXT record dictionary for Bonjour.
|
||||
func toTXTRecord() -> [String: String] {
|
||||
var record: [String: String] = [
|
||||
"id": deviceID,
|
||||
"name": deviceName,
|
||||
"platform": platform.rawValue,
|
||||
"playing": isPlaying ? "1" : "0"
|
||||
]
|
||||
if let title = currentVideoTitle {
|
||||
record["title"] = title
|
||||
}
|
||||
if let channel = currentChannelName {
|
||||
record["channel"] = channel
|
||||
}
|
||||
if let url = currentVideoThumbnailURL {
|
||||
record["thumb"] = url.absoluteString
|
||||
}
|
||||
return record
|
||||
}
|
||||
|
||||
/// Creates from a TXT record dictionary.
|
||||
static func from(txtRecord: [String: String]) -> DeviceAdvertisement? {
|
||||
guard let deviceID = txtRecord["id"],
|
||||
let deviceName = txtRecord["name"],
|
||||
let platformRaw = txtRecord["platform"],
|
||||
let platform = DevicePlatform(rawValue: platformRaw) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return DeviceAdvertisement(
|
||||
deviceID: deviceID,
|
||||
deviceName: deviceName,
|
||||
platform: platform,
|
||||
currentVideoTitle: txtRecord["title"],
|
||||
currentChannelName: txtRecord["channel"],
|
||||
currentVideoThumbnailURL: txtRecord["thumb"].flatMap { URL(string: $0) },
|
||||
isPlaying: txtRecord["playing"] == "1"
|
||||
)
|
||||
}
|
||||
|
||||
/// Converts to a DiscoveredDevice.
|
||||
func toDiscoveredDevice() -> DiscoveredDevice {
|
||||
DiscoveredDevice(
|
||||
id: deviceID,
|
||||
name: deviceName,
|
||||
platform: platform,
|
||||
currentVideoTitle: currentVideoTitle,
|
||||
currentChannelName: currentChannelName,
|
||||
currentVideoThumbnailURL: currentVideoThumbnailURL,
|
||||
isPlaying: isPlaying
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user