tv-anarchy/Sources/TVAnarchy/PlayerView.swift
Natalie 4a2ceb9781 feat(offline): inline star-to-keep and trash-to-cull on cache rows
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>
2026-06-30 00:12:41 -04:00

914 lines
No EOL
35 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
}
}