diff --git a/Sources/PlumTV/PlayerView.swift b/Sources/PlumTV/PlayerView.swift index 1736c3e..8c32324 100644 --- a/Sources/PlumTV/PlayerView.swift +++ b/Sources/PlumTV/PlayerView.swift @@ -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 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 + } + private func iconButton(_ system: String, big: Bool = false, _ op: @escaping (any PlayerTarget) async -> Void) -> some View { Button { controller.command(op) } label: { diff --git a/Sources/PlumTVCore/BlackTVTarget.swift b/Sources/PlumTVCore/BlackTVTarget.swift index 2d3273c..60699ca 100644 --- a/Sources/PlumTVCore/BlackTVTarget.swift +++ b/Sources/PlumTVCore/BlackTVTarget.swift @@ -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 + } } diff --git a/Sources/PlumTVCore/HostStats.swift b/Sources/PlumTVCore/HostStats.swift new file mode 100644 index 0000000..f992796 --- /dev/null +++ b/Sources/PlumTVCore/HostStats.swift @@ -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? +} diff --git a/Sources/PlumTVCore/PlayerController.swift b/Sources/PlumTVCore/PlayerController.swift index e3c9699..c88990e 100644 --- a/Sources/PlumTVCore/PlayerController.swift +++ b/Sources/PlumTVCore/PlayerController.swift @@ -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? - private var polling = false // single-flight guard + private var statsTask: Task? + 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).