tv-anarchy/Sources/TVAnarchy/PlayerView.swift
Natalie b8b148b788 feat(library): add queue context menu for shows
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-06-08 22:40:53 -07:00

305 lines
13 KiB
Swift

import SwiftUI
import Charts
import TVAnarchyCore
struct PlayerView: View {
@Bindable var controller: PlayerController
@State private var dragVolume: Double?
@State private var dragPosition: Double?
private var snap: PlayerController.Snapshot { controller.activeSnapshot }
private var status: PlaybackStatus { snap.status }
var body: some View {
VStack(spacing: 20) {
connectionBadge
nowPlaying
scrubber
transport
volume
qualityPicker
tracksControl
sleepControl
hostStatsCard
Button("Stop", role: .destructive) { controller.command { await $0.stop() } }
.disabled(snap.state == .unreachable)
Spacer()
}
.padding(28)
.navigationTitle("Player")
.toolbar { ToolbarItem(placement: .primaryAction) { HostSelector(controller: controller, compact: true) } }
.task(id: status.title) { await controller.refreshReleases(); await controller.refreshTracks() }
.onChange(of: controller.activeID) {
Task { await controller.refreshReleases(); await controller.refreshTracks() }
}
}
/// Sub/Dub preference (persists per series) plus manual audio/subtitle track
/// menus. Shown only when the active target can select tracks (mpv; VLC
/// best-effort).
@ViewBuilder private var tracksControl: some View {
if controller.activeSupportsTracks {
VStack(spacing: 10) {
HStack(spacing: 8) {
Image(systemName: "captions.bubble").foregroundStyle(.secondary)
Picker("Audio", selection: Binding(
get: { controller.currentDubSub },
set: { controller.setDubSub($0) }
)) {
ForEach(DubSub.allCases) { Text($0.label).tag($0) }
}
.pickerStyle(.segmented).labelsHidden().fixedSize()
.disabled(snap.state == .unreachable)
if let s = controller.activeSeries { Text(s).font(.caption).foregroundStyle(.secondary).lineLimit(1) }
}
if !controller.audioTracks.isEmpty {
trackMenu("Audio", systemImage: "speaker.wave.2", tracks: controller.audioTracks,
includeOff: false) { controller.selectAudioTrack($0!) }
}
if !controller.subtitleTracks.isEmpty {
trackMenu("Subtitles", systemImage: "text.bubble", tracks: controller.subtitleTracks,
includeOff: true) { controller.selectSubtitleTrack($0) }
}
}
.font(.callout)
}
}
private func trackMenu(_ title: String, systemImage: String, tracks: [MediaTrack],
includeOff: Bool, onPick: @escaping (Int?) -> Void) -> some View {
HStack(spacing: 8) {
Image(systemName: systemImage).foregroundStyle(.secondary)
Text(title).foregroundStyle(.secondary)
Menu {
if includeOff { Button("Off") { onPick(nil) } }
ForEach(tracks) { t in
Button { onPick(t.id) } label: {
Label(t.label, systemImage: t.selected ? "checkmark" : "")
}
}
} label: {
Text(tracks.first(where: { $0.selected })?.label ?? (includeOff ? "Off" : "Auto"))
}
.fixedSize()
.disabled(snap.state == .unreachable)
}
}
/// 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(
// Never subscript: SwiftUI may evaluate this getter mid-transition
// when `releases` is briefly empty (switching targets) [0] would trap.
get: { controller.releases.first(where: { $0.current })?.id
?? controller.releases.first?.id ?? "" },
set: { controller.switchRelease($0) }
)) {
ForEach(controller.releases) { r in Text(r.label).tag(r.id) }
}
.labelsHidden()
.fixedSize()
.disabled(snap.state == .unreachable)
}
.font(.callout)
}
}
@ViewBuilder private var connectionBadge: some View {
switch snap.state {
case .connected:
Color.clear.frame(height: 16)
case .checking:
Label("Connecting…", systemImage: "antenna.radiowaves.left.and.right")
.font(.caption).foregroundStyle(.secondary).frame(height: 16)
case .unreachable:
Label("Unreachable — showing last known state", systemImage: "exclamationmark.triangle.fill")
.font(.caption).foregroundStyle(.orange).frame(height: 16)
}
}
private var nowPlaying: some View {
VStack(spacing: 6) {
Text(status.title ?? "Nothing playing")
.font(.headline).multilineTextAlignment(.center).lineLimit(2)
if let n = status.playlistCount, let i = status.playlistPos {
Text("\(i + 1) of \(n)").font(.caption).foregroundStyle(.secondary)
}
}
.frame(height: 50)
.opacity(snap.state == .unreachable ? 0.5 : 1)
}
@ViewBuilder private var scrubber: some View {
if let pos = status.position, let dur = status.duration, dur > 0 {
VStack(spacing: 4) {
Slider(
value: Binding(
get: { dragPosition ?? min(pos, dur) },
set: { dragPosition = $0 }
),
in: 0...dur,
onEditingChanged: { editing in
// Commit the absolute seek once on release; hold the dragged
// value across the send + re-poll so the thumb doesn't snap
// back to a stale position (same pattern as the volume slider).
if !editing, let v = dragPosition {
Task {
await controller.commandAwait { await $0.seek(toSeconds: Int(v)) }
if dragPosition == v { dragPosition = nil }
}
}
}
)
HStack { Text(fmt(dragPosition ?? pos)); Spacer(); Text(fmt(dur)) }
.font(.caption.monospacedDigit()).foregroundStyle(.secondary)
}
.opacity(snap.state == .unreachable ? 0.5 : 1)
.disabled(snap.state == .unreachable)
} else {
ProgressView(value: 0).opacity(0.25)
}
}
private var transport: some View {
HStack(spacing: 26) {
iconButton("backward.end.fill") { await $0.previous() }
iconButton("gobackward.10") { await $0.seek(relative: -10) }
iconButton(status.paused == true ? "play.fill" : "pause.fill", big: true) { await $0.playPause() }
iconButton("goforward.10") { await $0.seek(relative: 10) }
iconButton("forward.end.fill") { await $0.next() }
}
.disabled(snap.state == .unreachable)
}
private var volume: some View {
let scale = Double(controller.active?.volumeScale ?? 100)
return HStack(spacing: 12) {
Image(systemName: "speaker.fill")
Slider(
value: Binding(
get: { dragVolume ?? (status.volume ?? 0) },
set: { dragVolume = $0 }
),
in: 0...scale,
onEditingChanged: { editing in
// Commit once on release never an ssh call per drag tick. Hold
// the dragged value as the displayed value across the send +
// re-poll round-trip, then clear it (unless another drag started),
// so the thumb never snaps back to a stale polled value.
if !editing, let v = dragVolume {
Task {
await controller.commandAwait { await $0.setVolume(Int(v)) }
if dragVolume == v { dragVolume = nil }
}
}
}
)
Image(systemName: "speaker.wave.3.fill")
Text("\(Int(dragVolume ?? (status.volume ?? 0)))%")
.font(.caption.monospacedDigit()).frame(width: 40)
}
.disabled(snap.state == .unreachable)
}
/// Live host-load card leads with mpv decode %CPU (what changes with
/// quality), charts its recent history so a 1080p720p switch shows a step
/// down, and gives load average / cores as context.
@ViewBuilder private var hostStatsCard: some View {
if let s = controller.hostStats {
VStack(spacing: 8) {
HStack {
Label("Decode", systemImage: "cpu")
Spacer()
Text(s.mpv_cpu.map { String(format: "%.0f%%", $0) } ?? "")
.bold().monospacedDigit()
.foregroundStyle(decodeColor(s.mpv_cpu))
}
if controller.decodeHistory.count >= 2 {
Chart(Array(controller.decodeHistory.enumerated()), id: \.offset) { idx, v in
LineMark(x: .value("t", idx), y: .value("cpu", v))
.interpolationMethod(.monotone)
.foregroundStyle(.tint)
AreaMark(x: .value("t", idx), y: .value("cpu", v))
.interpolationMethod(.monotone)
.foregroundStyle(.tint.opacity(0.12))
}
.chartYScale(domain: 0...max(160, (controller.decodeHistory.max() ?? 100) + 20))
.chartXAxis(.hidden)
.chartYAxis { AxisMarks(values: [0, 100]) }
.frame(height: 40)
}
HStack(spacing: 6) {
Image(systemName: "gauge.with.dots.needle.50percent")
Text("load \(String(format: "%.1f", s.load1))")
Text("·").foregroundStyle(.tertiary)
Text("\(s.cores) cores")
Spacer()
Text("100% = 1 core").foregroundStyle(.tertiary)
}
.font(.caption).foregroundStyle(.secondary)
}
.padding(12)
.background(.quaternary.opacity(0.25), in: RoundedRectangle(cornerRadius: 10))
.frame(maxWidth: 360)
}
}
private func decodeColor(_ cpu: Double?) -> Color {
guard let c = cpu else { return .secondary }
return c > 130 ? .orange : .green
}
/// Sleep timer: pick a preset (or end-of-episode). On fire it *stops* the
/// active host (drops the signal) so the TV powers off on its own schedule;
/// the toggle adds sleeping this Mac too. Shows a live countdown while armed.
private var sleepControl: some View {
HStack(spacing: 8) {
Image(systemName: controller.sleepArmed ? "moon.fill" : "moon")
.foregroundStyle(controller.sleepArmed ? AnyShapeStyle(.tint) : AnyShapeStyle(.secondary))
Menu {
ForEach([15, 30, 45, 60, 90], id: \.self) { m in
Button("\(m) minutes") { controller.setSleepTimer(minutes: m) }
}
Button("End of episode") { controller.setSleepAtEpisodeEnd() }
Divider()
Toggle("Also sleep this Mac", isOn: $controller.sleepSystem)
if controller.sleepArmed {
Divider()
Button("Turn off sleep timer", role: .destructive) { controller.cancelSleep() }
}
} label: { sleepLabel }
.fixedSize()
}
.font(.callout)
}
@ViewBuilder private var sleepLabel: some View {
if let fire = controller.sleepFiresAt {
TimelineView(.periodic(from: .now, by: 1)) { ctx in
Text("Sleep in \(fmt(max(0, fire.timeIntervalSince(ctx.date))))")
}
} else if controller.sleepArmed {
Text("Sleep at end of episode")
} else {
Text("Sleep timer")
}
}
private func iconButton(_ system: String, big: Bool = false,
_ op: @escaping (any PlayerTarget) async -> Void) -> some View {
Button { controller.command(op) } label: {
Image(systemName: system).font(big ? .system(size: 34) : .title2)
}
.buttonStyle(.borderless)
}
private func fmt(_ s: Double) -> String {
let i = Int(s.rounded())
return String(format: "%d:%02d", i / 60, i % 60)
}
}