feat(plum-tv): add cpu load and history chart to player view

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-06-07 20:52:42 -07:00
parent 10f2abd022
commit ba1a3f24b8
4 changed files with 113 additions and 3 deletions

View file

@ -1,4 +1,5 @@
import SwiftUI
import Charts
import PlumTVCore
struct PlayerView: View {
@ -17,6 +18,7 @@ struct PlayerView: View {
transport
volume
qualityPicker
hostStatsCard
Button("Stop", role: .destructive) { controller.command { await $0.stop() } }
.disabled(snap.state == .unreachable)
Spacer()
@ -132,6 +134,54 @@ struct PlayerView: View {
.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
}
private func iconButton(_ system: String, big: Bool = false,
_ op: @escaping (any PlayerTarget) async -> Void) -> some View {
Button { controller.command(op) } label: {

View file

@ -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, QualitySwitchable {
public final class BlackTVTarget: PlayerTarget, QualitySwitchable, HostStatsProvider {
public let id: String
public let name: String
public let kind: HostKind = .blacktv
@ -74,4 +74,13 @@ public final class BlackTVTarget: PlayerTarget, QualitySwitchable {
}
public func switchRelease(_ id: String) async { _ = await sh("switch", [id]) }
// MARK: HostStatsProvider
public func stats() async -> HostStats? {
let r = await sh("stats")
guard r.ok, let data = r.stdout.data(using: .utf8),
let s = try? JSONDecoder().decode(HostStats.self, from: data) else { return nil }
return s
}
}

View file

@ -0,0 +1,17 @@
import Foundation
/// Host load for a target load averages plus mpv's instantaneous decode %CPU
/// (100 = one core), the figure that actually moves when you change quality.
public struct HostStats: Decodable, Sendable, Equatable {
public let load1: Double
public let load5: Double
public let load15: Double
public let cores: Int
public let mpv_cpu: Double? // null when nothing is playing
}
/// A target that can report its host's load (only black it's a real machine
/// we have a shell on; VLC-on-plum is local and not interesting to chart).
public protocol HostStatsProvider: AnyObject {
func stats() async -> HostStats?
}

View file

@ -18,11 +18,18 @@ public final class PlayerController {
public private(set) var snapshots: [String: Snapshot] = [:]
/// Releases of the active target's current show (empty unless switchable).
public private(set) var releases: [Release] = []
/// Host load of the active target (nil unless it reports stats), plus a
/// rolling window of decode %CPU so the UI can chart the drop on a switch.
public private(set) var hostStats: HostStats?
public private(set) var decodeHistory: [Double] = []
private var pollTask: Task<Void, Never>?
private var polling = false // single-flight guard
private var statsTask: Task<Void, Never>?
private var polling = false // single-flight guard for status
private var tickCount = 0
private var lastReleaseKey: String?
private var statsTarget: String?
private let historyCap = 48
public init() { reload() }
@ -62,9 +69,36 @@ public final class PlayerController {
try? await Task.sleep(for: .seconds(1.5))
}
}
// Stats run on their own cadence so the 0.25s decode sample never slows
// the transport status poll (ControlMaster multiplexes the two channels).
statsTask?.cancel()
statsTask = Task { [weak self] in
while !Task.isCancelled {
await self?.refreshStats()
try? await Task.sleep(for: .seconds(2))
}
}
}
public func stop() { pollTask?.cancel() }
public func stop() { pollTask?.cancel(); statsTask?.cancel() }
public func refreshStats() async {
guard let p = active as? HostStatsProvider else {
if hostStats != nil { hostStats = nil }
if !decodeHistory.isEmpty { decodeHistory = [] }
statsTarget = nil
return
}
if statsTarget != activeID { statsTarget = activeID; decodeHistory = [] }
let s = await p.stats()
hostStats = s
if let cpu = s?.mpv_cpu {
decodeHistory.append(cpu)
if decodeHistory.count > historyCap {
decodeHistory.removeFirst(decodeHistory.count - historyCap)
}
}
}
/// Poll the active target every tick; the others every 4th (their state only
/// drives the picker's reachability dot).