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:
parent
10f2abd022
commit
ba1a3f24b8
4 changed files with 113 additions and 3 deletions
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
17
Sources/PlumTVCore/HostStats.swift
Normal file
17
Sources/PlumTVCore/HostStats.swift
Normal 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?
|
||||
}
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue