Files
yattee/Yattee/Services/RemoteControl/RemoteControlProtocol.swift
Arkadiusz Fal 908a916f76 Fix sending extracted videos (Twitch streams) to other devices
The remote control loadVideo command only carried the raw video ID and
an instance URL, so extracted videos (Twitch live streams and other
yt-dlp sites) failed on the receiver: it tried to fetch the ID from the
/api/v1/videos endpoint, which rejects non-YouTube IDs.

The command now carries the full ContentSource and title. The receiver
rebuilds the VideoID from the source and opens a placeholder Video -
the player then re-extracts streams and full metadata from the original
URL, same as when opening the video locally. Media-source extractors
(WebDAV/SMB/local) keep using the existing UUID:path branch.

Both fields are optional for protocol compatibility: old receivers
ignore them, commands from old senders keep the legacy behavior.

Also stop sending a start time for live streams - there is no shared
timeline, the receiver joins at the live edge.
2026-08-03 23:34:06 +02:00

261 lines
7.3 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)
/// `videoSource` carries the full ContentSource so non-API videos (e.g. extracted
/// Twitch streams) can be reconstructed on the receiver. Optional for
/// backward compatibility with older app versions.
case loadVideo(videoID: String, instanceURL: String?, startTime: TimeInterval?, awaitPlayCommand: Bool?, videoSource: ContentSource?, videoTitle: String?)
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
)
}
}