mirror of
https://github.com/yattee/yattee.git
synced 2026-02-21 02:09:46 +00:00
Yattee v2 rewrite
This commit is contained in:
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