305 lines
13 KiB
Swift
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 1080p→720p 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)
|
|
}
|
|
}
|