From 10f2abd022aee2ad34ab72dc4e53ae2e14eecd5b Mon Sep 17 00:00:00 2001 From: Natalie Date: Sun, 7 Jun 2026 20:41:37 -0700 Subject: [PATCH] =?UTF-8?q?feat(plum-tv):=20=E2=9C=A8=20add=20quality=20pi?= =?UTF-8?q?cker=20UI=20and=20switching=20support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- Sources/PlumTV/PlayerView.swift | 24 +++++++++++++++++++ Sources/PlumTVCore/BlackTVTarget.swift | 13 +++++++++- Sources/PlumTVCore/PlayerController.swift | 29 +++++++++++++++++++++++ Sources/PlumTVCore/Quality.swift | 16 +++++++++++++ 4 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 Sources/PlumTVCore/Quality.swift diff --git a/Sources/PlumTV/PlayerView.swift b/Sources/PlumTV/PlayerView.swift index 48f4f70..1736c3e 100644 --- a/Sources/PlumTV/PlayerView.swift +++ b/Sources/PlumTV/PlayerView.swift @@ -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 { diff --git a/Sources/PlumTVCore/BlackTVTarget.swift b/Sources/PlumTVCore/BlackTVTarget.swift index 2ac0f02..2d3273c 100644 --- a/Sources/PlumTVCore/BlackTVTarget.swift +++ b/Sources/PlumTVCore/BlackTVTarget.swift @@ -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]) } } diff --git a/Sources/PlumTVCore/PlayerController.swift b/Sources/PlumTVCore/PlayerController.swift index 7ec7d06..e3c9699 100644 --- a/Sources/PlumTVCore/PlayerController.swift +++ b/Sources/PlumTVCore/PlayerController.swift @@ -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? 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) { diff --git a/Sources/PlumTVCore/Quality.swift b/Sources/PlumTVCore/Quality.swift new file mode 100644 index 0000000..631471f --- /dev/null +++ b/Sources/PlumTVCore/Quality.swift @@ -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 +}