feat(plum-tv): ✨ add quality picker UI and switching support
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
e2977041aa
commit
10f2abd022
4 changed files with 81 additions and 1 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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]) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
16
Sources/PlumTVCore/Quality.swift
Normal file
16
Sources/PlumTVCore/Quality.swift
Normal 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
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue