Surface the existing pin (keep-from-cull) and per-file delete actions as visible inline buttons on each offline cache row instead of context-menu-only: a star toggles protection from auto-cull (and restore-if-missing), a trash culls that file early. Aligns wording/icons to the star metaphor. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
914 lines
No EOL
35 KiB
Swift
914 lines
No EOL
35 KiB
Swift
import SwiftUI
|
||
import Charts
|
||
import TVAnarchyCore
|
||
|
||
/// Full-screen playback control — Netflix-style hero + bottom dock. Drives the
|
||
/// active host (VLC, black mpv, etc.); video plays on the device, this page is
|
||
/// the remote with scrubber, transport, quality, tracks, sleep, and diagnostics.
|
||
struct PlayerView: View {
|
||
@Bindable var controller: PlayerController
|
||
@Bindable var library: LibraryController
|
||
@Bindable var playlist: PlaylistController
|
||
@Bindable var offline: OfflineCacheController
|
||
var streamability: StreamabilityMonitor?
|
||
var onOfflineDetails: () -> Void = {}
|
||
@Environment(\.appTheme) private var appTheme
|
||
@Environment(\.themePalette) private var palette
|
||
@Environment(\.winampSkin) private var winampSkin
|
||
@State private var dragVolume: Double?
|
||
@State private var dragPosition: Double?
|
||
@State private var showDiagnostics = false
|
||
|
||
private var snap: PlayerController.Snapshot { controller.activeSnapshot }
|
||
private var status: PlaybackStatus { snap.status }
|
||
private var idle: Bool { status.title == nil }
|
||
private var unreachable: Bool { snap.state == .unreachable }
|
||
|
||
var body: some View {
|
||
Group {
|
||
if appTheme.usesWinampChrome {
|
||
winampPlayerShell
|
||
} else {
|
||
standardPlayerShell
|
||
}
|
||
}
|
||
.animation(.default, value: showUpNext)
|
||
.background(appTheme.usesWinampChrome ? palette.chromeBackground : Color.black.opacity(0.94))
|
||
.navigationTitle("Player")
|
||
.toolbar {
|
||
ToolbarItem(placement: .primaryAction) {
|
||
HostSelector(controller: controller, streamability: streamability, compact: true)
|
||
}
|
||
}
|
||
.overlay(alignment: .bottom) { actionToast }
|
||
.task(id: status.title) { await controller.refreshReleases(); await controller.refreshTracks() }
|
||
.onChange(of: controller.activeID) {
|
||
Task { await controller.refreshReleases(); await controller.refreshTracks() }
|
||
}
|
||
}
|
||
|
||
// MARK: layouts
|
||
|
||
private var standardPlayerShell: some View {
|
||
ZStack(alignment: .bottom) {
|
||
heroBackdrop
|
||
VStack(spacing: 0) {
|
||
topBar
|
||
Spacer(minLength: 0)
|
||
if idle {
|
||
idleState
|
||
} else {
|
||
VStack(spacing: 0) {
|
||
if offline.isDownloading { offlineCacheStrip }
|
||
controlDock
|
||
}
|
||
}
|
||
}
|
||
playerOverlays
|
||
}
|
||
}
|
||
|
||
private var winampPlayerShell: some View {
|
||
VStack(spacing: 0) {
|
||
Group {
|
||
if winampSkin.isActive {
|
||
WinampSkinTitleBar(title: winampTitleText)
|
||
} else {
|
||
WinampTitleBar(
|
||
title: "TVAnarchy",
|
||
subtitle: controller.active.map { "▶ \($0.name)" } ?? "idle"
|
||
)
|
||
}
|
||
}
|
||
WinampSpectrum(level: volumeNorm)
|
||
.frame(height: 52)
|
||
.padding(.horizontal, 10)
|
||
.padding(.vertical, 6)
|
||
.winampBevel(inset: true)
|
||
.padding(.horizontal, 8)
|
||
.padding(.bottom, 6)
|
||
|
||
ZStack(alignment: .bottom) {
|
||
heroBackdrop.opacity(0.22)
|
||
VStack(spacing: 0) {
|
||
topBar
|
||
Spacer(minLength: 0)
|
||
if idle {
|
||
idleState
|
||
} else {
|
||
VStack(spacing: 0) {
|
||
if offline.isDownloading { offlineCacheStrip }
|
||
winampControlDock
|
||
}
|
||
}
|
||
}
|
||
playerOverlays
|
||
}
|
||
}
|
||
}
|
||
|
||
@ViewBuilder private var playerOverlays: some View {
|
||
ZStack(alignment: .bottom) {
|
||
if !idle, controller.shouldOfferSkipIntro {
|
||
skipIntroButton
|
||
}
|
||
if showUpNext, let path = controller.upNextPath {
|
||
PlayerUpNextCard(
|
||
nextTitle: {
|
||
let full = PlayerController.label(for: path, library: library)
|
||
let short = PlayerController.shortEpisodeTitle(full)
|
||
return short.isEmpty ? full : short
|
||
}(),
|
||
secondsLeft: upNextSecondsLeft,
|
||
onPlayNow: { controller.command { await $0.next(); await $0.resume() } }
|
||
)
|
||
.padding(.horizontal, 24)
|
||
.padding(.bottom, dockBottomInset + 24)
|
||
.transition(.move(edge: .trailing).combined(with: .opacity))
|
||
}
|
||
}
|
||
}
|
||
|
||
private var volumeNorm: Double {
|
||
let scale = Double(controller.active?.volumeScale ?? 100)
|
||
guard scale > 0 else { return 0 }
|
||
return min(1, Double(status.volume ?? 0) / scale)
|
||
}
|
||
|
||
// MARK: backdrop
|
||
|
||
@ViewBuilder private var heroBackdrop: some View {
|
||
GeometryReader { geo in
|
||
ZStack {
|
||
if let show = artworkShow, let path = show.posterPath, let url = ShowPoster.posterURL(path) {
|
||
AsyncImage(url: url) { phase in
|
||
if let img = phase.image {
|
||
img.resizable().scaledToFill()
|
||
.blur(radius: 28)
|
||
.saturation(0.85)
|
||
} else {
|
||
backdropFallback
|
||
}
|
||
}
|
||
} else {
|
||
backdropFallback
|
||
}
|
||
LinearGradient(
|
||
colors: [.black.opacity(0.15), .black.opacity(0.55), .black.opacity(0.92)],
|
||
startPoint: .top,
|
||
endPoint: .bottom
|
||
)
|
||
}
|
||
.frame(width: geo.size.width, height: geo.size.height)
|
||
.clipped()
|
||
}
|
||
.ignoresSafeArea()
|
||
}
|
||
|
||
private var backdropFallback: some View {
|
||
LinearGradient(
|
||
colors: [Color(white: 0.12), Color(white: 0.06)],
|
||
startPoint: .topLeading,
|
||
endPoint: .bottomTrailing
|
||
)
|
||
}
|
||
|
||
// MARK: chrome
|
||
|
||
private var topBar: some View {
|
||
HStack(spacing: 10) {
|
||
connectionPill
|
||
if library.playbackMode == .stream, let streamability {
|
||
StreamabilityIndicator(sample: streamability.sample, compact: true)
|
||
}
|
||
if let host = controller.active {
|
||
Label("Video on \(host.name)", systemImage: "play.tv.fill")
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
if controller.active?.kind.isLocal == true, controller.hasExternalTV {
|
||
Label("TV connected", systemImage: "tv")
|
||
.font(.caption)
|
||
.foregroundStyle(.green)
|
||
}
|
||
Spacer()
|
||
}
|
||
.padding(.horizontal, 20)
|
||
.padding(.top, 12)
|
||
}
|
||
|
||
private var offlineCacheStrip: some View {
|
||
Button(action: onOfflineDetails) {
|
||
OfflineDownloadPanel(offline: offline, compact: true)
|
||
.padding(.horizontal, 14).padding(.vertical, 10)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 12))
|
||
.contentShape(Rectangle())
|
||
}
|
||
.buttonStyle(.plain)
|
||
.help("Open Offline tab: playable list of cached items (auto-fills and culls)")
|
||
.padding(.horizontal, 24)
|
||
.padding(.bottom, 8)
|
||
}
|
||
|
||
private var dockBottomInset: CGFloat {
|
||
offline.isDownloading ? 300 : 240
|
||
}
|
||
|
||
@ViewBuilder private var connectionPill: some View {
|
||
switch snap.state {
|
||
case .connected:
|
||
Label("Connected", systemImage: "checkmark.circle.fill")
|
||
.font(.caption)
|
||
.foregroundStyle(.green)
|
||
case .checking:
|
||
Label("Connecting…", systemImage: "antenna.radiowaves.left.and.right")
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
case .unreachable:
|
||
if controller.active?.kind == .vlc {
|
||
Label(controller.vlcEnsuring ? "Starting VLC…" : "VLC not running",
|
||
systemImage: controller.vlcEnsuring ? "arrow.clockwise" : "play.rectangle")
|
||
.font(.caption)
|
||
.foregroundStyle(.orange)
|
||
} else {
|
||
Label("Unreachable", systemImage: "exclamationmark.triangle.fill")
|
||
.font(.caption)
|
||
.foregroundStyle(.orange)
|
||
}
|
||
}
|
||
}
|
||
|
||
private var idleState: some View {
|
||
ScrollView {
|
||
VStack(alignment: .leading, spacing: 28) {
|
||
VStack(spacing: 14) {
|
||
Image(systemName: "play.tv")
|
||
.font(.system(size: 48))
|
||
.foregroundStyle(.secondary)
|
||
Text("Nothing playing")
|
||
.font(.title2).bold()
|
||
Text(idleHint)
|
||
.font(.callout)
|
||
.foregroundStyle(.secondary)
|
||
.multilineTextAlignment(.center)
|
||
.frame(maxWidth: 400)
|
||
HostSelector(controller: controller)
|
||
}
|
||
.frame(maxWidth: .infinity)
|
||
.padding(.top, 8)
|
||
|
||
if !continueItems.isEmpty {
|
||
VStack(alignment: .leading, spacing: 10) {
|
||
Text("Continue Watching").font(.title3).bold()
|
||
ScrollView(.horizontal, showsIndicators: false) {
|
||
HStack(alignment: .top, spacing: 14) {
|
||
ForEach(continueItems) { item in
|
||
Button { playContinue(item) } label: { ContinueCard(item: item) }
|
||
.buttonStyle(.plain)
|
||
}
|
||
}
|
||
.padding(.bottom, 4)
|
||
}
|
||
}
|
||
}
|
||
|
||
if !playlist.isEmpty {
|
||
Button { playQueue() } label: {
|
||
Label("Play Queue (\(playlist.count))", systemImage: "list.bullet.rectangle")
|
||
}
|
||
.help("Play the current queue")
|
||
.buttonStyle(.bordered)
|
||
.controlSize(.large)
|
||
}
|
||
}
|
||
.padding(24)
|
||
}
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
.task {
|
||
while !Task.isCancelled {
|
||
library.refreshContinueWatching()
|
||
await library.syncWatchHistory()
|
||
try? await Task.sleep(for: .seconds(8))
|
||
}
|
||
}
|
||
}
|
||
|
||
private var continueItems: [ContinueItem] { library.homeContinueWatching }
|
||
|
||
private var idleHint: String {
|
||
if !continueItems.isEmpty {
|
||
return "Resume below, or pick something from Home or Library."
|
||
}
|
||
if !playlist.isEmpty {
|
||
return "Start your queue, or pick something from Home or Library."
|
||
}
|
||
return "Pick something from Home or Library — playback runs on your active device."
|
||
}
|
||
|
||
private func playContinue(_ item: ContinueItem) {
|
||
if playlist.playContinue(item, shows: library.shows, on: controller) { return }
|
||
guard let kind = controller.activeKind,
|
||
let req = library.launchRequest(continue: item, targetKind: kind) else {
|
||
controller.note("No player selected"); return
|
||
}
|
||
controller.launch(req, series: item.show, resumeSeconds: item.positionSeconds)
|
||
}
|
||
|
||
private func playQueue() {
|
||
playlist.play(on: controller)
|
||
}
|
||
|
||
// MARK: control dock (Netflix-style bottom stack)
|
||
|
||
private var controlDock: some View {
|
||
VStack(alignment: .leading, spacing: 18) {
|
||
titleBlock
|
||
PlayerQueueRail(controller: controller, library: library, playlist: playlist)
|
||
scrubber
|
||
transport
|
||
HStack(spacing: 14) {
|
||
volumeControl
|
||
Spacer(minLength: 0)
|
||
settingsRow
|
||
}
|
||
if showDiagnostics { diagnosticsPanel }
|
||
}
|
||
.padding(.horizontal, 24)
|
||
.padding(.vertical, 22)
|
||
.background {
|
||
LinearGradient(
|
||
colors: [.clear, .black.opacity(0.65), .black.opacity(0.9)],
|
||
startPoint: .top,
|
||
endPoint: .bottom
|
||
)
|
||
}
|
||
.opacity(unreachable ? 0.55 : 1)
|
||
.disabled(unreachable)
|
||
}
|
||
|
||
private var winampTitleText: String {
|
||
if let host = controller.active?.name {
|
||
return "TVAnarchy — \(host)"
|
||
}
|
||
return "TVAnarchy"
|
||
}
|
||
|
||
private var winampControlDock: some View {
|
||
VStack(alignment: .leading, spacing: 10) {
|
||
titleBlock
|
||
.font(.system(.body, design: .monospaced))
|
||
PlayerQueueRail(controller: controller, library: library, playlist: playlist)
|
||
winampScrubber
|
||
Group {
|
||
if winampSkin.isActive {
|
||
WinampSkinTransportRow(
|
||
paused: status.paused == true,
|
||
onPrevious: { controller.command { await $0.previous() } },
|
||
onSeekBack: { controller.command { await $0.seek(relative: -10) } },
|
||
onPlayPause: { controller.command { await $0.playPause() } },
|
||
onSeekForward: { controller.command { await $0.seek(relative: 10) } },
|
||
onNext: { controller.command { await $0.next(); await $0.resume() } }
|
||
)
|
||
} else {
|
||
HStack(spacing: 6) {
|
||
WinampTransportButton(symbol: "backward.end.fill", label: "PREV", help: "Previous episode or track") {
|
||
controller.command { await $0.previous() }
|
||
}
|
||
WinampTransportButton(symbol: "gobackward.10", label: "−10", help: "Seek back 10 seconds") {
|
||
controller.command { await $0.seek(relative: -10) }
|
||
}
|
||
WinampTransportButton(
|
||
symbol: status.paused == true ? "play.fill" : "pause.fill",
|
||
label: status.paused == true ? "PLAY" : "PAUSE",
|
||
width: 44,
|
||
height: 26,
|
||
help: status.paused == true ? "Play" : "Pause"
|
||
) { controller.command { await $0.playPause() } }
|
||
WinampTransportButton(symbol: "goforward.10", label: "+10", help: "Seek forward 10 seconds") {
|
||
controller.command { await $0.seek(relative: 10) }
|
||
}
|
||
WinampTransportButton(symbol: "forward.end.fill", label: "NEXT", help: "Next episode or track") {
|
||
controller.command { await $0.next(); await $0.resume() }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity)
|
||
HStack(spacing: 10) {
|
||
winampVolumeControl
|
||
Spacer(minLength: 0)
|
||
settingsRow
|
||
}
|
||
if showDiagnostics { diagnosticsPanel }
|
||
}
|
||
.padding(.horizontal, 14)
|
||
.padding(.vertical, 12)
|
||
.background(palette.surface)
|
||
.winampBevel()
|
||
.padding(.horizontal, 8)
|
||
.padding(.bottom, 8)
|
||
.opacity(unreachable ? 0.55 : 1)
|
||
.disabled(unreachable)
|
||
}
|
||
|
||
@ViewBuilder private var winampScrubber: some View {
|
||
if let pos = status.position, let dur = status.duration, dur > 0 {
|
||
let shown = dragPosition ?? min(pos, dur)
|
||
VStack(spacing: 6) {
|
||
if winampSkin.isActive {
|
||
WinampSkinPositionSlider(
|
||
value: Binding(
|
||
get: { dragPosition ?? min(pos, dur) },
|
||
set: { dragPosition = $0 }
|
||
),
|
||
range: 0...dur,
|
||
onEditingChanged: { editing in
|
||
if !editing, let v = dragPosition {
|
||
Task {
|
||
await controller.commandAwait { await $0.seek(toSeconds: Int(v)) }
|
||
if dragPosition == v { dragPosition = nil }
|
||
}
|
||
}
|
||
}
|
||
)
|
||
WinampSkinLEDDisplay(left: fmt(shown), right: fmt(dur))
|
||
} else {
|
||
Slider(
|
||
value: Binding(
|
||
get: { dragPosition ?? min(pos, dur) },
|
||
set: { dragPosition = $0 }
|
||
),
|
||
in: 0...dur,
|
||
onEditingChanged: { editing in
|
||
if !editing, let v = dragPosition {
|
||
Task {
|
||
await controller.commandAwait { await $0.seek(toSeconds: Int(v)) }
|
||
if dragPosition == v { dragPosition = nil }
|
||
}
|
||
}
|
||
}
|
||
)
|
||
.tint(palette.sliderFill)
|
||
WinampLEDDisplay(left: fmt(shown), right: fmt(dur))
|
||
}
|
||
}
|
||
} else {
|
||
ProgressView().controlSize(.small)
|
||
}
|
||
}
|
||
|
||
private var winampVolumeControl: some View {
|
||
let scale = Double(controller.active?.volumeScale ?? 100)
|
||
let level = dragVolume ?? (status.volume ?? 0)
|
||
return HStack(spacing: 6) {
|
||
Text("VOL")
|
||
.font(.system(size: 8, weight: .bold, design: .monospaced))
|
||
.foregroundStyle(palette.textSecondary)
|
||
Group {
|
||
if winampSkin.isActive {
|
||
WinampSkinVolumeSlider(
|
||
value: Binding(
|
||
get: { dragVolume ?? (status.volume ?? 0) },
|
||
set: { dragVolume = $0 }
|
||
),
|
||
range: 0...scale,
|
||
onEditingChanged: { editing in
|
||
if !editing, let v = dragVolume {
|
||
Task {
|
||
await controller.commandAwait { await $0.setVolume(Int(v)) }
|
||
if dragVolume == v { dragVolume = nil }
|
||
}
|
||
}
|
||
}
|
||
)
|
||
} else {
|
||
Slider(
|
||
value: Binding(
|
||
get: { dragVolume ?? (status.volume ?? 0) },
|
||
set: { dragVolume = $0 }
|
||
),
|
||
in: 0...scale,
|
||
onEditingChanged: { editing in
|
||
if !editing, let v = dragVolume {
|
||
Task {
|
||
await controller.commandAwait { await $0.setVolume(Int(v)) }
|
||
if dragVolume == v { dragVolume = nil }
|
||
}
|
||
}
|
||
}
|
||
)
|
||
.tint(palette.sliderFill)
|
||
}
|
||
}
|
||
.frame(maxWidth: 120)
|
||
Text("\(Int(level))")
|
||
.font(.system(size: 10, design: .monospaced))
|
||
.foregroundStyle(palette.ledText)
|
||
.frame(width: 28, alignment: .trailing)
|
||
}
|
||
}
|
||
|
||
private var titleBlock: some View {
|
||
VStack(alignment: .leading, spacing: 6) {
|
||
if let series = controller.activeSeries {
|
||
Text(series)
|
||
.font(.subheadline)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
Text(PlayerController.displayTitle(for: status, library: library,
|
||
preferShow: controller.activeSeries))
|
||
.font(.title.bold())
|
||
.lineLimit(2)
|
||
HStack(spacing: 10) {
|
||
if let line = controller.positionLine(library: library) {
|
||
Text(line)
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
if status.paused == true {
|
||
Text("Paused")
|
||
.font(.caption2)
|
||
.padding(.horizontal, 6).padding(.vertical, 2)
|
||
.background(.quaternary, in: Capsule())
|
||
}
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
|
||
@ViewBuilder private var scrubber: some View {
|
||
if let pos = status.position, let dur = status.duration, dur > 0 {
|
||
let shown = dragPosition ?? min(pos, dur)
|
||
VStack(spacing: 6) {
|
||
Slider(
|
||
value: Binding(
|
||
get: { dragPosition ?? min(pos, dur) },
|
||
set: { dragPosition = $0 }
|
||
),
|
||
in: 0...dur,
|
||
onEditingChanged: { editing in
|
||
if !editing, let v = dragPosition {
|
||
Task {
|
||
await controller.commandAwait { await $0.seek(toSeconds: Int(v)) }
|
||
if dragPosition == v { dragPosition = nil }
|
||
}
|
||
}
|
||
}
|
||
)
|
||
.tint(.white)
|
||
HStack {
|
||
Text(fmt(shown))
|
||
Spacer()
|
||
if dur - shown > 1 {
|
||
Text("\(fmt(max(0, dur - shown))) left")
|
||
}
|
||
Text(fmt(dur))
|
||
}
|
||
.font(.caption.monospacedDigit())
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
} else {
|
||
ProgressView().controlSize(.small)
|
||
}
|
||
}
|
||
|
||
private var transport: some View {
|
||
HStack(spacing: 0) {
|
||
Spacer(minLength: 0)
|
||
HStack(spacing: 28) {
|
||
transportButton("backward.end.fill", size: 22, help: "Previous episode or track") { await $0.previous() }
|
||
transportButton("gobackward.10", size: 26, help: "Seek back 10 seconds") { await $0.seek(relative: -10) }
|
||
transportButton(
|
||
status.paused == true ? "play.fill" : "pause.fill",
|
||
size: 44,
|
||
prominent: true,
|
||
help: status.paused == true ? "Play" : "Pause"
|
||
) { await $0.playPause() }
|
||
transportButton("goforward.10", size: 26, help: "Seek forward 10 seconds") { await $0.seek(relative: 10) }
|
||
transportButton("forward.end.fill", size: 22, help: "Next episode or track") { await $0.next(); await $0.resume() }
|
||
}
|
||
Spacer(minLength: 0)
|
||
}
|
||
}
|
||
|
||
private func transportButton(
|
||
_ system: String,
|
||
size: CGFloat,
|
||
prominent: Bool = false,
|
||
help: String? = nil,
|
||
_ op: @escaping (any PlayerTarget) async -> Void
|
||
) -> some View {
|
||
let b = Button { controller.command(op) } label: {
|
||
Image(systemName: system)
|
||
.font(.system(size: size, weight: prominent ? .semibold : .regular))
|
||
.frame(width: prominent ? 64 : 44, height: prominent ? 64 : 44)
|
||
.background(prominent ? AnyShapeStyle(.white.opacity(0.08)) : AnyShapeStyle(.clear),
|
||
in: Circle())
|
||
}
|
||
.buttonStyle(.plain)
|
||
if let h = help {
|
||
return AnyView(b.help(h))
|
||
} else {
|
||
return AnyView(b)
|
||
}
|
||
}
|
||
|
||
private var volumeControl: some View {
|
||
let scale = Double(controller.active?.volumeScale ?? 100)
|
||
let level = dragVolume ?? (status.volume ?? 0)
|
||
return HStack(spacing: 8) {
|
||
Image(systemName: level < 1 ? "speaker.slash.fill" : "speaker.wave.2.fill")
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
.frame(width: 16)
|
||
Slider(
|
||
value: Binding(
|
||
get: { dragVolume ?? (status.volume ?? 0) },
|
||
set: { dragVolume = $0 }
|
||
),
|
||
in: 0...scale,
|
||
onEditingChanged: { editing in
|
||
if !editing, let v = dragVolume {
|
||
Task {
|
||
await controller.commandAwait { await $0.setVolume(Int(v)) }
|
||
if dragVolume == v { dragVolume = nil }
|
||
}
|
||
}
|
||
}
|
||
)
|
||
.frame(maxWidth: 140)
|
||
Text("\(Int(level))%")
|
||
.font(.caption2.monospacedDigit())
|
||
.foregroundStyle(.secondary)
|
||
.frame(width: 34, alignment: .trailing)
|
||
}
|
||
}
|
||
|
||
private var settingsRow: some View {
|
||
HStack(spacing: 8) {
|
||
if controller.active?.kind.isLocal == true { displayMenu }
|
||
if controller.releases.count >= 2 { qualityMenu }
|
||
if controller.activeSupportsTracks { tracksMenus }
|
||
sleepMenu
|
||
Button {
|
||
withAnimation { showDiagnostics.toggle() }
|
||
} label: {
|
||
Image(systemName: showDiagnostics ? "gauge.with.dots.needle.67percent" : "gauge")
|
||
}
|
||
.buttonStyle(.bordered)
|
||
.controlSize(.small)
|
||
.help("Host diagnostics")
|
||
|
||
Button("Stop", role: .destructive) {
|
||
controller.command { await $0.stop() }
|
||
}
|
||
.buttonStyle(.bordered)
|
||
.controlSize(.small)
|
||
.help("Stop playback")
|
||
}
|
||
}
|
||
|
||
@ViewBuilder private var displayMenu: some View {
|
||
Menu {
|
||
Button {
|
||
controller.setPlaybackDisplay(nil)
|
||
} label: {
|
||
Label("Auto (TV when connected)",
|
||
systemImage: library.playbackDisplayId == nil ? "checkmark" : "")
|
||
}
|
||
.help("Automatic display selection")
|
||
Divider()
|
||
ForEach(controller.displays) { d in
|
||
Button {
|
||
controller.setPlaybackDisplay(d.displayId)
|
||
} label: {
|
||
Label("\(d.shortLabel) · \(d.width)×\(d.height)",
|
||
systemImage: displaySelected(d) ? "checkmark"
|
||
: (d.isBuiltIn ? "laptopcomputer" : "tv"))
|
||
}
|
||
.help("Use this display for local playback")
|
||
}
|
||
} label: {
|
||
Label(displayMenuTitle, systemImage: controller.hasExternalTV ? "tv" : "display")
|
||
}
|
||
.menuStyle(.borderedButton)
|
||
.controlSize(.small)
|
||
.help("Where local playback appears — auto uses the external TV when plugged in")
|
||
}
|
||
|
||
private var displayMenuTitle: String {
|
||
if library.playbackDisplayId == nil {
|
||
return controller.hasExternalTV ? "TV (auto)" : "Display (auto)"
|
||
}
|
||
return controller.effectivePlaybackDisplay?.shortLabel ?? "Display"
|
||
}
|
||
|
||
private func displaySelected(_ d: DisplayInfo) -> Bool {
|
||
library.playbackDisplayId == d.displayId
|
||
|| (library.playbackDisplayId == nil && d.displayId == controller.effectivePlaybackDisplay?.displayId)
|
||
}
|
||
|
||
@ViewBuilder private var qualityMenu: some View {
|
||
Menu {
|
||
ForEach(controller.releases) { r in
|
||
Button {
|
||
controller.switchRelease(r.id)
|
||
} label: {
|
||
Label(r.label, systemImage: r.current ? "checkmark" : "")
|
||
}
|
||
.help("Switch to release: \(r.label)")
|
||
}
|
||
} label: {
|
||
Label("Quality", systemImage: "rectangle.stack.badge.play")
|
||
}
|
||
.menuStyle(.borderedButton)
|
||
.controlSize(.small)
|
||
}
|
||
|
||
@ViewBuilder private var tracksMenus: some View {
|
||
Menu {
|
||
Picker("Audio preference", selection: Binding(
|
||
get: { controller.currentDubSub },
|
||
set: { controller.setDubSub($0) }
|
||
)) {
|
||
ForEach(DubSub.allCases) { Text($0.label).tag($0) }
|
||
}
|
||
if !controller.audioTracks.isEmpty {
|
||
Divider()
|
||
ForEach(controller.audioTracks) { t in
|
||
Button { controller.selectAudioTrack(t.id) } label: {
|
||
Label(t.label, systemImage: t.selected ? "checkmark" : "")
|
||
}
|
||
.help("Select audio track: \(t.label)")
|
||
}
|
||
}
|
||
} label: {
|
||
Label("Audio", systemImage: "speaker.wave.2")
|
||
}
|
||
.menuStyle(.borderedButton)
|
||
.controlSize(.small)
|
||
|
||
Menu {
|
||
if !controller.subtitleTracks.isEmpty {
|
||
Button("Off") { controller.selectSubtitleTrack(nil) }
|
||
.help("Turn subtitles off")
|
||
Divider()
|
||
ForEach(controller.subtitleTracks) { t in
|
||
Button { controller.selectSubtitleTrack(t.id) } label: {
|
||
Label(t.label, systemImage: t.selected ? "checkmark" : "")
|
||
}
|
||
.help("Select subtitle track: \(t.label)")
|
||
}
|
||
}
|
||
} label: {
|
||
Label("Subs", systemImage: "captions.bubble")
|
||
}
|
||
.menuStyle(.borderedButton)
|
||
.controlSize(.small)
|
||
.disabled(controller.subtitleTracks.isEmpty)
|
||
}
|
||
|
||
private var sleepMenu: some View {
|
||
Menu {
|
||
ForEach([15, 30, 45, 60, 90], id: \.self) { m in
|
||
Button("\(m) minutes") { controller.setSleepTimer(minutes: m) }
|
||
.help("Sleep after \(m) minutes")
|
||
}
|
||
Button("End of episode") { controller.setSleepAtEpisodeEnd() }
|
||
.help("Sleep at end of current episode")
|
||
Divider()
|
||
Toggle("Also sleep this Mac", isOn: $controller.sleepSystem)
|
||
.help("When the timer fires, put this Mac to sleep after stopping playback — resets each launch for safety")
|
||
if controller.sleepArmed {
|
||
Divider()
|
||
Button("Turn off sleep timer", role: .destructive) { controller.cancelSleep() }
|
||
.help("Cancel the sleep timer")
|
||
}
|
||
} label: {
|
||
Label(sleepMenuTitle, systemImage: controller.sleepArmed ? "moon.fill" : "moon")
|
||
}
|
||
.menuStyle(.borderedButton)
|
||
.controlSize(.small)
|
||
}
|
||
|
||
private var sleepMenuTitle: String {
|
||
if let fire = controller.sleepFiresAt {
|
||
let left = max(0, fire.timeIntervalSinceNow)
|
||
return "Sleep \(fmt(left))"
|
||
}
|
||
if controller.sleepArmed { return "End of ep" }
|
||
return "Sleep"
|
||
}
|
||
|
||
@ViewBuilder private var diagnosticsPanel: some View {
|
||
if let s = controller.hostStats {
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
HStack {
|
||
Label("Decode CPU", 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(.white.opacity(0.8))
|
||
AreaMark(x: .value("t", idx), y: .value("cpu", v))
|
||
.interpolationMethod(.monotone)
|
||
.foregroundStyle(.white.opacity(0.08))
|
||
}
|
||
.chartYScale(domain: 0...max(160, (controller.decodeHistory.max() ?? 100) + 20))
|
||
.chartXAxis(.hidden)
|
||
.chartYAxis { AxisMarks(values: [0, 100]) }
|
||
.frame(height: 48)
|
||
}
|
||
Text("Load \(String(format: "%.1f", s.load1)) · \(s.cores) cores · 100% = 1 core")
|
||
.font(.caption2)
|
||
.foregroundStyle(.tertiary)
|
||
}
|
||
.padding(12)
|
||
.background(.white.opacity(0.06), in: RoundedRectangle(cornerRadius: 10))
|
||
}
|
||
}
|
||
|
||
@ViewBuilder private var actionToast: some View {
|
||
if let msg = controller.actionMessage, !shouldSuppressToast(msg) {
|
||
Text(msg)
|
||
.font(.callout)
|
||
.padding(.horizontal, 14).padding(.vertical, 10)
|
||
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 12))
|
||
.padding(.bottom, idle ? 16 : dockBottomInset)
|
||
.onTapGesture { copyToClipboard(msg) }
|
||
.help("Click to copy")
|
||
.task(id: msg) {
|
||
try? await Task.sleep(for: .seconds(4))
|
||
controller.note(nil)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Download progress has a dedicated strip above the dock; don't stack a toast too.
|
||
private func shouldSuppressToast(_ msg: String) -> Bool {
|
||
PlayerController.shouldDeferToOfflineCacheUI(msg)
|
||
}
|
||
|
||
// MARK: skip intro + up next
|
||
|
||
private var skipIntroButton: some View {
|
||
VStack {
|
||
Spacer()
|
||
HStack {
|
||
Spacer()
|
||
Button { controller.skipIntro() } label: {
|
||
Label("Skip Intro", systemImage: "forward.end.fill")
|
||
.font(.callout.bold())
|
||
.padding(.horizontal, 16).padding(.vertical, 10)
|
||
.background(.black.opacity(0.55), in: Capsule())
|
||
.overlay { Capsule().strokeBorder(.white.opacity(0.25), lineWidth: 1) }
|
||
}
|
||
.buttonStyle(.plain)
|
||
.padding(.trailing, 28)
|
||
.padding(.bottom, dockBottomInset)
|
||
.help("Skip the opening intro or credits")
|
||
}
|
||
}
|
||
}
|
||
|
||
private var showUpNext: Bool {
|
||
guard controller.upNextPath != nil,
|
||
let rem = controller.secondsRemaining,
|
||
rem <= 30, rem > 0 else { return false }
|
||
return true
|
||
}
|
||
|
||
private var upNextSecondsLeft: Int {
|
||
Int(ceil(controller.secondsRemaining ?? 0))
|
||
}
|
||
|
||
// MARK: helpers
|
||
|
||
private var artworkShow: CachedShow? {
|
||
if let name = controller.activeSeries {
|
||
if let exact = library.shows.first(where: { $0.name == name }) { return exact }
|
||
}
|
||
guard let title = status.title?.lowercased() else { return nil }
|
||
return library.shows.first { show in
|
||
let sn = show.name.lowercased()
|
||
if title.contains(sn) || sn.contains(title) { return true }
|
||
return show.episodes.contains { title.contains($0.label.lowercased()) }
|
||
}
|
||
}
|
||
|
||
private func decodeColor(_ cpu: Double?) -> Color {
|
||
guard let c = cpu else { return .secondary }
|
||
return c > 130 ? .orange : .green
|
||
}
|
||
|
||
private func fmt(_ s: Double) -> String {
|
||
let i = Int(s.rounded())
|
||
return String(format: "%d:%02d", i / 60, i % 60)
|
||
}
|
||
} |