Files
yattee/Yattee/Services/RemoteControl/RemoteControlProtocol.swift
2026-02-08 18:33:56 +01:00

258 lines
7.1 KiB
Swift

//
// 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
)
}
}