feat(plum-tv): add quality picker UI and switching support

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-06-07 20:41:37 -07:00
parent e2977041aa
commit 10f2abd022
4 changed files with 81 additions and 1 deletions

View file

@ -16,11 +16,35 @@ struct PlayerView: View {
scrubber
transport
volume
qualityPicker
Button("Stop", role: .destructive) { controller.command { await $0.stop() } }
.disabled(snap.state == .unreachable)
Spacer()
}
.padding(28)
.task(id: status.title) { await controller.refreshReleases() }
.onChange(of: controller.activeID) { Task { await controller.refreshReleases() } }
}
/// Release/quality selector only shown when the active target can switch
/// (black) and the show actually has more than one release available.
@ViewBuilder private var qualityPicker: some View {
if controller.releases.count >= 2 {
HStack(spacing: 8) {
Image(systemName: "rectangle.stack.badge.play").foregroundStyle(.secondary)
Text("Quality").foregroundStyle(.secondary)
Picker("Quality", selection: Binding(
get: { controller.releases.first(where: { $0.current })?.id ?? controller.releases[0].id },
set: { controller.switchRelease($0) }
)) {
ForEach(controller.releases) { r in Text(r.label).tag(r.id) }
}
.labelsHidden()
.fixedSize()
.disabled(snap.state == .unreachable)
}
.font(.callout)
}
}
private var targetPicker: some View {

View file

@ -6,7 +6,7 @@ import Foundation
/// Endpoint pinning try the configured endpoints in order once, pin the
/// winner, and only re-probe the rest after a failure (so a down LAN address
/// doesn't cost a ConnectTimeout on every poll).
public final class BlackTVTarget: PlayerTarget {
public final class BlackTVTarget: PlayerTarget, QualitySwitchable {
public let id: String
public let name: String
public let kind: HostKind = .blacktv
@ -63,4 +63,15 @@ public final class BlackTVTarget: PlayerTarget {
public func next() async { _ = await sh("next") }
public func previous() async { _ = await sh("prev") }
public func stop() async { _ = await sh("stop") }
// MARK: QualitySwitchable
public func releases() async -> [Release] {
let r = await sh("releases")
guard r.ok, let data = r.stdout.data(using: .utf8),
let list = try? JSONDecoder().decode([Release].self, from: data) else { return [] }
return list
}
public func switchRelease(_ id: String) async { _ = await sh("switch", [id]) }
}

View file

@ -16,10 +16,13 @@ public final class PlayerController {
public private(set) var targets: [any PlayerTarget] = []
public var activeID: String = ""
public private(set) var snapshots: [String: Snapshot] = [:]
/// Releases of the active target's current show (empty unless switchable).
public private(set) var releases: [Release] = []
private var pollTask: Task<Void, Never>?
private var polling = false // single-flight guard
private var tickCount = 0
private var lastReleaseKey: String?
public init() { reload() }
@ -92,6 +95,32 @@ public final class PlayerController {
apply(await active.poll(), to: active.id)
}
// MARK: Quality / release switching
/// Refetch the active target's releases only when the show/episode changed
/// (cheap: one SSH call per episode boundary, not per poll).
public func refreshReleases() async {
guard let q = active as? QualitySwitchable else {
if !releases.isEmpty { releases = [] }
lastReleaseKey = nil
return
}
let key = "\(activeID)|\(activeSnapshot.status.title ?? "")"
guard key != lastReleaseKey else { return }
lastReleaseKey = key
releases = await q.releases()
}
public func switchRelease(_ id: String) {
guard let q = active as? QualitySwitchable else { return }
Task {
await q.switchRelease(id)
lastReleaseKey = nil // force a refetch (current flag moved)
await refreshActive()
await refreshReleases()
}
}
/// Keep-last-known: on unreachable we flip the badge but never wipe the
/// previously-known playback state.
private func apply(_ r: PollResult, to id: String) {

View file

@ -0,0 +1,16 @@
import Foundation
/// One available release/quality of the currently-playing show (e.g. "1080p x265"
/// vs "720p x264"). Maps to `black-tv releases` JSON.
public struct Release: Decodable, Sendable, Equatable, Identifiable {
public let id: String // release directory basename
public let label: String // human label, e.g. "720p x264"
public let current: Bool
}
/// A target that can switch the current episode to a different release at the
/// same timestamp. Only black supports this (it owns mpv + the local library).
public protocol QualitySwitchable: AnyObject {
func releases() async -> [Release]
func switchRelease(_ id: String) async
}