tv-anarchy/Sources/PlumTVCore/PlayerTarget.swift
Natalie e2977041aa feat: PlumTV — native macOS player with config-driven hosts
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>
2026-06-07 20:24:55 -07:00

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