Phase 0/1: SwiftUI app (XcodeGen), PlumTVCore framework, player MVP. - PlayerTarget protocol + VLCTarget (HTTP) and BlackTVTarget (ssh black-tv) - config-driven hosts (~/.config/plumtv/hosts.json), seeded plum-vlc + black - reliability: SSH ControlMaster (5s->~1s/poll), endpoint pinning (LAN->overlay), single-flight polling, keep-last-known on transient failure, debounced volume - Hosts pane shows live connection state; Player has target picker + transport - unit tests for status decoding Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
79 lines
3.2 KiB
Swift
79 lines
3.2 KiB
Swift
import Foundation
|
|
|
|
/// Live connection state of a target, kept separate from playback so a single
|
|
/// dropped poll renders as "unreachable (last known …)" rather than wiping state.
|
|
public enum ConnectionState: String, Sendable, Equatable {
|
|
case checking, connected, unreachable
|
|
}
|
|
|
|
/// Unified playback state across every target. Volume is normalized to a
|
|
/// percentage (100 = normal) regardless of the backend's native scale.
|
|
public struct PlaybackStatus: Equatable, Sendable {
|
|
public var playing: Bool
|
|
public var paused: Bool?
|
|
public var title: String?
|
|
public var volume: Double? // percent (100 = normal)
|
|
public var position: Double? // seconds
|
|
public var duration: Double? // seconds
|
|
public var playlistPos: Int?
|
|
public var playlistCount: Int?
|
|
|
|
public init(playing: Bool, paused: Bool? = nil, title: String? = nil,
|
|
volume: Double? = nil, position: Double? = nil, duration: Double? = nil,
|
|
playlistPos: Int? = nil, playlistCount: Int? = nil) {
|
|
self.playing = playing; self.paused = paused; self.title = title
|
|
self.volume = volume; self.position = position; self.duration = duration
|
|
self.playlistPos = playlistPos; self.playlistCount = playlistCount
|
|
}
|
|
|
|
public static let idle = PlaybackStatus(playing: false)
|
|
}
|
|
|
|
/// Result of polling a target: reachability and (if reachable) the playback
|
|
/// state. `status == nil` while reachable means "reachable but no clear state".
|
|
public struct PollResult: Sendable {
|
|
public var reachable: Bool
|
|
public var status: PlaybackStatus?
|
|
public init(reachable: Bool, status: PlaybackStatus?) {
|
|
self.reachable = reachable; self.status = status
|
|
}
|
|
public static let unreachable = PollResult(reachable: false, status: nil)
|
|
}
|
|
|
|
/// A place we can send playback to. Reference type so an implementation can hold
|
|
/// connection state (e.g. a pinned SSH endpoint). Verbs translate onto an
|
|
/// existing backend — the playback intelligence lives there, not here.
|
|
public protocol PlayerTarget: AnyObject {
|
|
var id: String { get }
|
|
var name: String { get }
|
|
var kind: HostKind { get }
|
|
var detail: String { get } // human-readable endpoint, for the Hosts view
|
|
var volumeScale: Int { get } // max value for the volume slider (percent)
|
|
|
|
func poll() async -> PollResult
|
|
func playPause() async // toggle
|
|
func resume() async // ensure playing
|
|
func setVolume(_ percent: Int) async
|
|
func seek(relative seconds: Int) async
|
|
func next() async
|
|
func previous() async
|
|
func stop() async
|
|
}
|
|
|
|
/// Wire shape of `black-tv status` (snake_case JSON from the deployed script).
|
|
public struct BlackStatusDTO: Decodable, Sendable {
|
|
public let playing: Bool
|
|
public let paused: Bool?
|
|
public let title: String?
|
|
public let volume: Double?
|
|
public let position: Double?
|
|
public let duration: Double?
|
|
public let playlist_pos: Int?
|
|
public let playlist_count: Int?
|
|
|
|
public func toStatus() -> PlaybackStatus {
|
|
PlaybackStatus(playing: playing, paused: paused, title: title,
|
|
volume: volume, position: position, duration: duration,
|
|
playlistPos: playlist_pos, playlistCount: playlist_count)
|
|
}
|
|
}
|