From 068eeefbac24eb748542feabc3ae91bb3f3c2ba8 Mon Sep 17 00:00:00 2001 From: JonahMMay Date: Wed, 30 Sep 2026 13:32:01 +0000 Subject: [PATCH] feat(player): richer stats for nerds with plan detail and recent events Stats for nerds showed Aether telemetry only, so a failed or degraded stream said nothing about what the server planned or what went wrong. Match prairie-smarttv #116 on iOS and tvOS: - Plan section: method (direct play / remux / transcode), container, codecs, resolution, dynamic range and bitrate from the protocol-v3 plan, the planner's decision reason, active quality, selected audio track, and the stream path with query, user info, ids and media filenames stripped. - Recent events: a ring buffer of the last 8 player events and errors (plan, first frame, rebuffering, stalls, replans, quality and audio changes, typed Aether failures), timestamped, redacted through MediaLogRedactor, with consecutive repeats collapsed so a flapping state can't flush the error that caused it. - tvOS Info HUD stats pane shows Plan in the left column and Recent events at the end of the right column (added to the paging targets). The iOS overlay adds the plan rows and lists events beside the rows in landscape, or four events below them when narrow. Unit tests cover the ring buffer, summary and path formatting, and the projection pass-through. Invariant anchors added for the new wiring. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../AetherPlaybackStatsProjectionTests.swift | 27 ++ iosApp/Tests/PlaybackDiagnosticsTests.swift | 204 +++++++++++++ .../AetherPlaybackStatsProjection.swift | 22 +- .../Screens/Player/PlaybackDiagnostics.swift | 288 ++++++++++++++++++ .../iosApp/Screens/Player/PlaybackStats.swift | 48 ++- .../Screens/Player/PlaybackStatsPanel.swift | 79 ++++- .../Screens/Player/PlayerViewModel.swift | 69 ++++- .../iOS/MobilePlaybackStatsOverlay.swift | 52 +++- .../Screens/Player/tvOS/TVPlayerInfoHUD.swift | 3 + scripts/prairie-invariants.txt | 6 + 10 files changed, 777 insertions(+), 21 deletions(-) create mode 100644 iosApp/Tests/PlaybackDiagnosticsTests.swift create mode 100644 iosApp/iosApp/Screens/Player/PlaybackDiagnostics.swift diff --git a/iosApp/Tests/AetherPlaybackStatsProjectionTests.swift b/iosApp/Tests/AetherPlaybackStatsProjectionTests.swift index a33838288..369f9d44a 100644 --- a/iosApp/Tests/AetherPlaybackStatsProjectionTests.swift +++ b/iosApp/Tests/AetherPlaybackStatsProjectionTests.swift @@ -215,6 +215,33 @@ final class AetherPlaybackStatsProjectionTests: XCTestCase { XCTAssertEqual(stats.dynamicRange, "HDR10 → SDR") } + func testPrairiePlanDetailPassesThroughToStats() throws { + let stats = AetherPlaybackStatsProjection.make( + snapshot: AetherPlaybackStatsSnapshot(route: .remoteBypass, phase: .playing), + source: AetherPlaybackStatsSourceMetadata( + sourceURL: try XCTUnwrap(URL(string: "https://h.example.test/api/v2/stream/abc123?st=do-not-display")), + delivery: "server_transcode_hls", + container: "mpegts", + playbackRate: 1, + planSummary: PlaybackPlanSummary( + delivery: "server_transcode_hls", + videoCodec: "h264", + reason: "video_codec_unsupported" + ), + quality: " Auto ", + audioTrack: "English · Stereo · AAC (track 1)" + ) + ) + + XCTAssertEqual(stats.playbackMethod, "Transcode (HLS)") + XCTAssertEqual(stats.plan, "H.264") + XCTAssertEqual(stats.plannerReason, "video codec unsupported") + XCTAssertEqual(stats.quality, "Auto") + XCTAssertEqual(stats.audioTrack, "English · Stereo · AAC (track 1)") + XCTAssertEqual(stats.streamPath, "Remote · /api/v2/stream/:id") + XCTAssertFalse(stats.allRows.map(\.1).joined().contains("do-not-display")) + } + func testIdleSnapshotProducesNoSyntheticEngineRows() { let stats = AetherPlaybackStatsProjection.make( snapshot: AetherPlaybackStatsSnapshot(route: .none, phase: .idle), diff --git a/iosApp/Tests/PlaybackDiagnosticsTests.swift b/iosApp/Tests/PlaybackDiagnosticsTests.swift new file mode 100644 index 000000000..a3d1e639e --- /dev/null +++ b/iosApp/Tests/PlaybackDiagnosticsTests.swift @@ -0,0 +1,204 @@ +import Foundation +import XCTest +@testable import Prairie + +/// Stats-for-nerds detail Prairie adds on top of Aether's telemetry: the +/// event ring buffer, the plan summary, and the redacted stream path. +final class PlaybackDiagnosticsTests: XCTestCase { + private let t0 = Date(timeIntervalSince1970: 1_700_000_000) + + // MARK: - Event ring buffer + + func testEventLogKeepsOnlyTheNewestEntriesInOrder() { + var log = PlaybackEventLog(capacity: 8) + for index in 0..<11 { + log.record("event \(index)", at: t0.addingTimeInterval(Double(index))) + } + + XCTAssertEqual(log.entries.count, 8) + XCTAssertEqual(log.entries.first?.message, "event 3") + XCTAssertEqual(log.entries.last?.message, "event 10") + XCTAssertEqual(log.newestFirst.map(\.message).prefix(2), ["event 10", "event 9"]) + } + + func testEventLogCollapsesConsecutiveDuplicatesSoAnErrorIsNotFlushed() { + var log = PlaybackEventLog(capacity: 3) + log.record("sourceRefused: 403", kind: .error, at: t0) + for offset in 1...10 { + log.record("Rebuffering", kind: .warning, at: t0.addingTimeInterval(Double(offset))) + } + + XCTAssertEqual(log.entries.count, 2) + XCTAssertEqual(log.entries[0].kind, .error) + XCTAssertEqual(log.entries[1].repeatCount, 10) + XCTAssertEqual(log.entries[1].at, t0.addingTimeInterval(10)) + XCTAssertEqual(log.entries[1].displayMessage, "Warning: Rebuffering ×10") + } + + func testEventLogDoesNotCollapseTheSameMessageAtADifferentSeverity() { + var log = PlaybackEventLog() + log.record("Stalled", kind: .warning, at: t0) + log.record("Stalled", kind: .error, at: t0) + + XCTAssertEqual(log.entries.map(\.kind), [.warning, .error]) + } + + func testEventLogIgnoresBlankMessagesAndClampsCapacity() { + var log = PlaybackEventLog(capacity: 0) + log.record(" \n", at: t0) + XCTAssertTrue(log.entries.isEmpty) + + log.record("a", at: t0) + log.record("b", at: t0) + XCTAssertEqual(log.capacity, 1) + XCTAssertEqual(log.entries.map(\.message), ["b"]) + + log.removeAll() + XCTAssertTrue(log.entries.isEmpty) + } + + func testEventLogRedactsCredentialsAndBoundsLength() { + var log = PlaybackEventLog() + log.record( + "Load failed for https://media.example.test/api/v2/stream/42?st=supersecret-token&x=1", + kind: .error, + at: t0 + ) + log.record(String(repeating: "x", count: 500), at: t0) + + let message = log.entries[0].message + XCTAssertFalse(message.contains("supersecret-token"), message) + XCTAssertLessThanOrEqual(log.entries[1].message.count, PlaybackEventLog.maxMessageLength) + } + + func testEntryFormatsTimeAndSeverity() throws { + let utc = try XCTUnwrap(TimeZone(identifier: "UTC")) + let entry = PlaybackEventLog.Entry( + at: Date(timeIntervalSince1970: 3_600 * 13 + 60 * 4 + 31), + kind: .error, + message: "sourceRefused: 403" + ) + + XCTAssertEqual(entry.timeLabel(timeZone: utc), "13:04:31") + XCTAssertEqual(entry.displayMessage, "Error: sourceRefused: 403") + } + + func testStatsEventRowsAreNewestFirstWithStableIdentity() { + var log = PlaybackEventLog() + log.record("First frame", at: t0) + log.record("Rebuffering", kind: .warning, at: t0) + log.record("Quality: 1080p", at: t0) + var stats = PlaybackStats.empty + stats.recentEvents = log.entries + + let rows = stats.eventRows(limit: 2) + XCTAssertEqual(rows.map(\.message), ["Quality: 1080p", "Warning: Rebuffering"]) + XCTAssertEqual(rows.map(\.id), [0, 1]) + XCTAssertEqual(rows[1].kind, .warning) + XCTAssertFalse(stats.hasRows, "Events alone must not count as projected stats rows") + } + + // MARK: - Plan summary + + func testPlanSummaryDescribesMethodRecipeAndReason() { + let summary = PlaybackPlanSummary( + delivery: "server_remux_progressive", + container: "mp4", + videoCodec: "hevc", + width: 3840, + height: 1600, + dynamicRange: "hdr10", + audioCodec: "aac", + audioChannels: 2, + bitrateKbps: 18_000, + reason: "audio_adaptation" + ) + + XCTAssertEqual(summary.method, "Remux (progressive)") + XCTAssertEqual(summary.detailLine, "MP4 · HEVC 3840×1600 HDR10 · AAC 2ch · 18.0 Mbps") + XCTAssertEqual(summary.reasonLabel, "audio adaptation") + } + + func testPlanSummaryMethodCoversEveryDelivery() { + XCTAssertEqual(PlaybackPlanSummary.method(forDelivery: "original_http"), "Direct play") + XCTAssertEqual(PlaybackPlanSummary.method(forDelivery: "server_remux_hls"), "Remux (HLS)") + XCTAssertEqual(PlaybackPlanSummary.method(forDelivery: "server_transcode_hls"), "Transcode (HLS)") + XCTAssertEqual(PlaybackPlanSummary.method(forDelivery: "future_route"), "future route") + XCTAssertEqual(PlaybackPlanSummary.method(forDelivery: ""), "Unknown") + } + + func testPlanSummaryOmitsSDRAndEmptyFields() { + let summary = PlaybackPlanSummary(delivery: "original_http", videoCodec: "h264", dynamicRange: "sdr") + XCTAssertEqual(summary.detailLine, "H.264") + XCTAssertNil(PlaybackPlanSummary(delivery: "original_http", container: " ").detailLine) + XCTAssertNil(PlaybackPlanSummary(delivery: "original_http", reason: "").reasonLabel) + } + + func testPlanSummaryReadsTheServerPlanFixture() throws { + let plan = try XCTUnwrap( + PlaybackV3FixtureTestSupport.v2Decision(bundleClass: Self.self).playbackPlan + ) + let summary = PlaybackPlanSummary(plan: plan) + + XCTAssertEqual(summary.method, "Direct play") + XCTAssertEqual(summary.detailLine, "MP4 · H.264 1920×1080 · AAC 2ch · 8.0 Mbps") + XCTAssertEqual(summary.reasonLabel, "validated original playback") + } + + // MARK: - Stream path + + func testStreamPathStripsQueryIdsAndUserInfo() throws { + let url = try XCTUnwrap(URL( + string: "https://user:pass@media.example.test/api/v2/stream/11111111-1111-4111-8111-111111111111?st=secret#frag" + )) + let described = try XCTUnwrap(PlaybackStreamPath.describe(url)) + + XCTAssertEqual(described, "Remote · /api/v2/stream/:id") + XCTAssertFalse(described.contains("secret")) + XCTAssertFalse(described.contains("pass")) + } + + func testStreamPathNamesLoopbackAndKeepsManifestNames() throws { + let url = try XCTUnwrap(URL( + string: "http://127.0.0.1:52011/live-hls/aB3dE5fG7hJ9kL1m/master.m3u8?token=abc" + )) + XCTAssertEqual( + PlaybackStreamPath.describe(url), + "Loopback proxy · /live-hls/:id/master.m3u8" + ) + XCTAssertEqual( + PlaybackStreamPath.describe(URL(string: "http://localhost:8080/")), + "Loopback proxy" + ) + } + + func testStreamPathHidesMediaFilenamesAndOfflineFiles() throws { + let remote = try XCTUnwrap(URL(string: "https://cdn.example.test/items/Secret.Title.2024.mkv")) + XCTAssertEqual(PlaybackStreamPath.describe(remote), "Remote · /items/[media]") + + let offline = URL(fileURLWithPath: "/var/mobile/Downloads/Secret Title.mp4") + XCTAssertEqual(PlaybackStreamPath.describe(offline), "Offline file") + XCTAssertNil(PlaybackStreamPath.describe(nil)) + } + + // MARK: - Stats rows + + func testPlanRowsLeadTheCompactOverlay() { + var stats = PlaybackStats.empty + stats.route = "Aether remote HLS" + stats.playbackMethod = "Transcode (HLS)" + stats.plan = "TS · H.264 1920×1080 · AAC 2ch" + stats.plannerReason = "video codec unsupported" + stats.quality = "Auto" + stats.audioTrack = "English · 5.1 · EAC3 (track 2)" + stats.streamPath = "Remote · /api/v2/stream/:id" + + let compactLabels = stats.compactRows.map(\.0) + XCTAssertEqual( + compactLabels, + ["Route", "Method", "Plan", "Planner reason", "Quality", "Audio track", "Stream path"] + ) + XCTAssertEqual(stats.planRows.count, 6) + XCTAssertTrue(stats.hasRows) + } +} diff --git a/iosApp/iosApp/Screens/Player/AetherPlaybackStatsProjection.swift b/iosApp/iosApp/Screens/Player/AetherPlaybackStatsProjection.swift index c2434d7fc..824652a34 100644 --- a/iosApp/iosApp/Screens/Player/AetherPlaybackStatsProjection.swift +++ b/iosApp/iosApp/Screens/Player/AetherPlaybackStatsProjection.swift @@ -11,6 +11,11 @@ struct AetherPlaybackStatsSourceMetadata: Equatable { let plannedSourceDynamicRange: String? let plannedOutputDynamicRange: String? let plannedSourceDolbyVisionProfile: Int? + /// Prairie protocol-v3 plan detail and session labels for stats for nerds. + let planSummary: PlaybackPlanSummary? + let quality: String? + let audioTrack: String? + let streamPath: String? init( sourceURL: URL?, @@ -20,7 +25,10 @@ struct AetherPlaybackStatsSourceMetadata: Equatable { secondarySubtitleLabel: String? = nil, plannedSourceDynamicRange: String? = nil, plannedOutputDynamicRange: String? = nil, - plannedSourceDolbyVisionProfile: Int? = nil + plannedSourceDolbyVisionProfile: Int? = nil, + planSummary: PlaybackPlanSummary? = nil, + quality: String? = nil, + audioTrack: String? = nil ) { source = Self.sourceLabel(for: sourceURL) self.delivery = Self.deliveryLabel(delivery) @@ -30,6 +38,10 @@ struct AetherPlaybackStatsSourceMetadata: Equatable { self.plannedSourceDynamicRange = plannedSourceDynamicRange self.plannedOutputDynamicRange = plannedOutputDynamicRange self.plannedSourceDolbyVisionProfile = plannedSourceDolbyVisionProfile + self.planSummary = planSummary + self.quality = Self.normalized(quality) + self.audioTrack = Self.normalized(audioTrack) + streamPath = PlaybackStreamPath.describe(sourceURL) } private static func sourceLabel(for url: URL?) -> String? { @@ -209,7 +221,13 @@ enum AetherPlaybackStatsProjection { cachedBytes: telemetry?.cachedBytes, demuxerBytesFetched: telemetry.map(\.demuxerBytesFetched), producerRestartCount: telemetry.map(\.producerRestartCount), - residentMemoryBytes: telemetry.map { Int64($0.rssMb) * 1_000_000 } + residentMemoryBytes: telemetry.map { Int64($0.rssMb) * 1_000_000 }, + playbackMethod: source.planSummary?.method, + plan: source.planSummary?.detailLine, + plannerReason: source.planSummary?.reasonLabel, + quality: source.quality, + audioTrack: source.audioTrack, + streamPath: source.streamPath ) } diff --git a/iosApp/iosApp/Screens/Player/PlaybackDiagnostics.swift b/iosApp/iosApp/Screens/Player/PlaybackDiagnostics.swift new file mode 100644 index 000000000..ef4f0908f --- /dev/null +++ b/iosApp/iosApp/Screens/Player/PlaybackDiagnostics.swift @@ -0,0 +1,288 @@ +import Foundation + +// Prairie stats-for-nerds detail that Aether cannot supply: the server's +// playback plan, the stream path handed to the player, and a short rolling +// log of player events. Parity with prairie-smarttv #116 and the web overlay. +// Everything here is pure Foundation so the formatting is unit-testable. + +/// Bounded, oldest-first log of recent player events and errors. +/// +/// Recorded whether or not diagnostics upload is enabled, so a failure can be +/// read on the device itself. Messages pass through `MediaLogRedactor`, which +/// strips URLs, credentials and media filenames before anything is stored. +struct PlaybackEventLog: Equatable { + enum Kind: String, Equatable { + case info + case warning + case error + } + + struct Entry: Equatable { + var at: Date + let kind: Kind + let message: String + /// How many identical consecutive events this entry stands for. + var repeatCount: Int = 1 + } + + static let defaultCapacity = 8 + static let maxMessageLength = 160 + + let capacity: Int + private(set) var entries: [Entry] = [] + + init(capacity: Int = PlaybackEventLog.defaultCapacity) { + self.capacity = max(1, capacity) + } + + /// Appends an event, dropping the oldest once `capacity` is reached. An + /// event identical to the newest one (same kind and message) bumps that + /// entry's count and timestamp instead, so a flapping state can't flush + /// the error that caused it. + mutating func record(_ message: String, kind: Kind = .info, at date: Date = Date()) { + let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + let sanitized = MediaLogRedactor.sanitize(trimmed, maxLength: Self.maxMessageLength) + if var last = entries.last, last.kind == kind, last.message == sanitized { + last.at = date + last.repeatCount += 1 + entries[entries.count - 1] = last + return + } + entries.append(Entry(at: date, kind: kind, message: sanitized)) + if entries.count > capacity { + entries.removeFirst(entries.count - capacity) + } + } + + mutating func removeAll() { + entries.removeAll() + } + + var newestFirst: [Entry] { entries.reversed() } +} + +extension PlaybackEventLog.Entry { + /// `12:04:31` in the device's time zone; seconds matter for correlating + /// with server logs. + func timeLabel(timeZone: TimeZone = .current) -> String { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = timeZone + let parts = calendar.dateComponents([.hour, .minute, .second], from: at) + return String( + format: "%02d:%02d:%02d", + parts.hour ?? 0, + parts.minute ?? 0, + parts.second ?? 0 + ) + } + + /// Message with a severity prefix for warnings and errors and a `×N` + /// suffix for collapsed repeats. + var displayMessage: String { + var text: String + switch kind { + case .info: text = message + case .warning: text = "Warning: \(message)" + case .error: text = "Error: \(message)" + } + if repeatCount > 1 { + text += " ×\(repeatCount)" + } + return text + } +} + +/// One-line description of the negotiated protocol-v3 plan. +struct PlaybackPlanSummary: Equatable { + /// Raw server delivery (`original_http`, `server_remux_hls`, …). + let delivery: String + let container: String? + let videoCodec: String? + let width: Int? + let height: Int? + let dynamicRange: String? + let audioCodec: String? + let audioChannels: Int? + let bitrateKbps: Int? + /// Planner's `decision_reason`. + let reason: String? + + init( + delivery: String, + container: String? = nil, + videoCodec: String? = nil, + width: Int? = nil, + height: Int? = nil, + dynamicRange: String? = nil, + audioCodec: String? = nil, + audioChannels: Int? = nil, + bitrateKbps: Int? = nil, + reason: String? = nil + ) { + self.delivery = delivery + self.container = container + self.videoCodec = videoCodec + self.width = width + self.height = height + self.dynamicRange = dynamicRange + self.audioCodec = audioCodec + self.audioChannels = audioChannels + self.bitrateKbps = bitrateKbps + self.reason = reason + } + + /// The effective recipe describes what the player receives; fall back to + /// the source descriptor for fields a direct-play plan leaves empty. + init(plan: PlaybackV3Plan) { + let recipe = plan.effectiveRecipe + let source = plan.source + self.init( + delivery: plan.delivery, + container: plan.stream.container ?? source.container, + videoCodec: recipe.videoCodec ?? source.videoCodec, + width: recipe.width ?? source.width, + height: recipe.height ?? source.height, + dynamicRange: recipe.dynamicRange ?? source.dynamicRange, + audioCodec: recipe.audioCodec ?? source.audioCodec, + audioChannels: recipe.audioChannels ?? source.audioChannels, + bitrateKbps: recipe.bitrateKbps ?? source.bitrateKbps, + reason: plan.decisionReason + ) + } + + /// Direct play, Remux or Transcode, with the transport when it matters. + var method: String { + Self.method(forDelivery: delivery) + } + + static func method(forDelivery delivery: String) -> String { + switch delivery.lowercased() { + case "original_http": return "Direct play" + case "server_remux_progressive": return "Remux (progressive)" + case "server_remux_hls": return "Remux (HLS)" + case "server_transcode_hls": return "Transcode (HLS)" + default: + let words = delivery.replacingOccurrences(of: "_", with: " ") + return words.isEmpty ? "Unknown" : words + } + } + + /// `MP4 · HEVC 3840×1600 HDR10 · AAC 2ch · 18.0 Mbps` + var detailLine: String? { + var parts: [String] = [] + if let container = Self.normalized(container) { + parts.append(container.uppercased()) + } + var video: [String] = [] + if let codec = Self.normalized(videoCodec) { + video.append(Self.codecLabel(codec)) + } + if let width, let height, width > 0, height > 0 { + video.append("\(width)×\(height)") + } + if let range = Self.normalized(dynamicRange), range.lowercased() != "sdr" { + video.append(Self.dynamicRangeLabel(range)) + } + if !video.isEmpty { parts.append(video.joined(separator: " ")) } + var audio: [String] = [] + if let codec = Self.normalized(audioCodec) { + audio.append(Self.codecLabel(codec)) + } + if let audioChannels, audioChannels > 0 { + audio.append("\(audioChannels)ch") + } + if !audio.isEmpty { parts.append(audio.joined(separator: " ")) } + if let bitrateKbps, bitrateKbps > 0 { + parts.append(String(format: "%.1f Mbps", Double(bitrateKbps) / 1_000)) + } + return parts.isEmpty ? nil : parts.joined(separator: " · ") + } + + /// Planner reason as words (`audio_adaptation` → `audio adaptation`). + var reasonLabel: String? { + Self.normalized(reason)?.replacingOccurrences(of: "_", with: " ") + } + + private static func codecLabel(_ codec: String) -> String { + switch codec.lowercased() { + case "hevc", "h265", "hvc1", "hev1": return "HEVC" + case "h264", "avc", "avc1": return "H.264" + case "av1": return "AV1" + case "vp9": return "VP9" + case "aac": return "AAC" + case "ac3": return "AC-3" + case "eac3": return "E-AC-3" + case "truehd": return "TrueHD" + case "dts": return "DTS" + case "flac": return "FLAC" + case "opus": return "Opus" + default: return codec + } + } + + private static func dynamicRangeLabel(_ value: String) -> String { + switch value.lowercased() { + case "hdr10": return "HDR10" + case "hdr10_plus", "hdr10+": return "HDR10+" + case "hlg": return "HLG" + case "dolby_vision": return "Dolby Vision" + default: return value + } + } + + private static func normalized(_ value: String?) -> String? { + guard let value else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } +} + +/// What kind of URL the player was handed, and its path with every secret +/// removed: no query or fragment (stream tokens ride there), no user info, +/// opaque ids and signatures collapsed to `:id`, media filenames dropped. +enum PlaybackStreamPath { + static func describe(_ url: URL?) -> String? { + guard let url else { return nil } + if url.isFileURL { return "Offline file" } + let kind = isLoopback(url.host) ? "Loopback proxy" : "Remote" + let path = redactedPath(url) + return path.isEmpty || path == "/" ? kind : "\(kind) · \(path)" + } + + static func isLoopback(_ host: String?) -> Bool { + guard let host = host?.lowercased() else { return false } + return host == "localhost" || host == "::1" || host == "[::1]" || host.hasPrefix("127.") + } + + static func redactedPath(_ url: URL) -> String { + let components = url.path + .split(separator: "/", omittingEmptySubsequences: true) + .map { redactedComponent(String($0)) } + return "/" + components.joined(separator: "/") + } + + /// Keeps route words (`api`, `v2`, `live-hls`) and protocol-fixed + /// manifest or segment names (`master.m3u8`, `seg_00042.ts`); replaces + /// anything that looks like an id or signature with `:id` and user media + /// filenames with `[media]`. + static func redactedComponent(_ component: String) -> String { + if MediaLogRedactor.isMediaFilenameComponent(component) { + return "[media]" + } + if matches(component, #"^[A-Za-z]+[0-9]?$"#) && component.count <= 24 { + return component + } + if matches(component, #"^[a-z]+([_-][a-z]+)+$"#) && component.count <= 24 { + return component + } + if matches(component, #"^[A-Za-z][A-Za-z0-9_-]{0,23}\.(m3u8|mpd|mp4|m4s|m4a|ts|aac|vtt|webvtt)$"#) { + return component + } + return ":id" + } + + private static func matches(_ value: String, _ pattern: String) -> Bool { + value.range(of: pattern, options: .regularExpression) != nil + } +} diff --git a/iosApp/iosApp/Screens/Player/PlaybackStats.swift b/iosApp/iosApp/Screens/Player/PlaybackStats.swift index 09f89bdce..be68009d0 100644 --- a/iosApp/iosApp/Screens/Player/PlaybackStats.swift +++ b/iosApp/iosApp/Screens/Player/PlaybackStats.swift @@ -48,12 +48,24 @@ struct PlaybackStats: Equatable { var producerRestartCount: Int? var residentMemoryBytes: Int64? + // Prairie plan and session detail (prairie-smarttv #116 parity). Supplied + // by the host from the protocol-v3 plan, never by Aether. + var playbackMethod: String? + var plan: String? + var plannerReason: String? + var quality: String? + var audioTrack: String? + var streamPath: String? + + /// Recent player events and errors, oldest first. + var recentEvents: [PlaybackEventLog.Entry] = [] + static let empty = PlaybackStats() } extension PlaybackStats { var allRows: [(String, String)] { - sourceRows + mediaRows + bufferRows + networkRows + engineRows + sourceRows + planRows + mediaRows + bufferRows + networkRows + engineRows } /// The short set shown over iPhone video. It intentionally leaves the @@ -61,6 +73,7 @@ extension PlaybackStats { var compactRows: [(String, String)] { let wanted = [ "Route", "Source", "Delivery", "Container", + "Method", "Plan", "Planner reason", "Quality", "Audio track", "Stream path", "Video", "Audio", "Dynamic range", "Subtitles", "Playback status", "Forward buffer", "Display cushion", "Dropped frames", "Instant read bitrate", "Network throughput" @@ -82,6 +95,31 @@ extension PlaybackStats { ]) } + /// The server's plan for this attempt: how the file is delivered and why. + var planRows: [(String, String)] { + stringRows([ + ("Method", playbackMethod), + ("Plan", plan), + ("Planner reason", plannerReason), + ("Quality", quality), + ("Audio track", audioTrack), + ("Stream path", streamPath) + ]) + } + + /// Newest first, capped at `limit`. Kept out of `allRows`: timestamps are + /// not unique labels, so views render these with their own identity. + func eventRows(limit: Int = PlaybackEventLog.defaultCapacity) -> [PlaybackEventRow] { + recentEvents.reversed().prefix(max(0, limit)).enumerated().map { offset, entry in + PlaybackEventRow( + id: offset, + time: entry.timeLabel(), + message: entry.displayMessage, + kind: entry.kind + ) + } + } + var mediaRows: [(String, String)] { var rows: [(String, String)] = [] if let video = mediaDescription(video) { @@ -221,3 +259,11 @@ extension PlaybackStats { ByteCountFormatter.string(fromByteCount: bytes, countStyle: .file) } } + +/// One rendered line of the recent-events list. +struct PlaybackEventRow: Identifiable, Equatable { + let id: Int + let time: String + let message: String + let kind: PlaybackEventLog.Kind +} diff --git a/iosApp/iosApp/Screens/Player/PlaybackStatsPanel.swift b/iosApp/iosApp/Screens/Player/PlaybackStatsPanel.swift index 8b0691bdb..36aad1c22 100644 --- a/iosApp/iosApp/Screens/Player/PlaybackStatsPanel.swift +++ b/iosApp/iosApp/Screens/Player/PlaybackStatsPanel.swift @@ -19,6 +19,8 @@ struct PlaybackStatsPanel: View { var usesTwoColumnLayout = false static let sourceSectionID = "stats-source" + static let planSectionID = "stats-plan" + static let eventsSectionID = "stats-events" static let mediaSectionID = "stats-media" static let bufferSectionID = "stats-buffer" static let networkSectionID = "stats-network" @@ -33,10 +35,10 @@ struct PlaybackStatsPanel: View { HStack(alignment: .top, spacing: 34) { column(leftSections) divider - column(rightSections) + column(rightSections, includesEvents: true) } case .sectioned: - column(allSections) + column(allSections, includesEvents: true) } } .frame(maxWidth: .infinity, alignment: .leading) @@ -59,15 +61,52 @@ struct PlaybackStatsPanel: View { Text(row.1) .foregroundStyle(.white.opacity(0.95)) .fixedSize(horizontal: false, vertical: true) + // Plan and stream-path values run long; wrap them + // instead of widening the plate across the picture. + .frame(maxWidth: 320, alignment: .leading) } } } .font(.system(size: 11)) } + /// Recent player events for the iOS overlay: `time message`, newest + /// first. Rendered beside or below `plainList` by the overlay. + func plainEventList(limit: Int) -> some View { + Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 10, verticalSpacing: 3) { + GridRow { + Text("Recent events") + .foregroundStyle(.white.opacity(0.58)) + .gridCellColumns(2) + } + ForEach(stats.eventRows(limit: limit)) { row in + GridRow { + Text(row.time) + .monospacedDigit() + .foregroundStyle(.white.opacity(0.58)) + Text(row.message) + .foregroundStyle(Self.eventColor(row.kind)) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: 280, alignment: .leading) + } + } + } + .font(.system(size: 11)) + } + + static func eventColor(_ kind: PlaybackEventLog.Kind) -> Color { + switch kind { + case .info: return .white.opacity(0.95) + case .warning: return Color(red: 1, green: 0.8, blue: 0.4) + case .error: return Color(red: 1, green: 0.55, blue: 0.5) + } + } + private var leftSections: [StatsSection] { [ .init(id: Self.sourceSectionID, title: "Source", rows: stats.sourceRows), + .init(id: Self.planSectionID, title: "Plan", rows: stats.planRows), .init(id: Self.mediaSectionID, title: "Media", rows: stats.mediaRows) ] } @@ -91,15 +130,49 @@ struct PlaybackStatsPanel: View { .frame(maxHeight: .infinity) } - private func column(_ sections: [StatsSection]) -> some View { + private func column(_ sections: [StatsSection], includesEvents: Bool = false) -> some View { Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 24, verticalSpacing: 10) { ForEach(sections) { section in rows(for: section) } + if includesEvents { + eventRows + } } .frame(maxWidth: .infinity, alignment: .topLeading) } + /// "Recent events" section: timestamp in the label column, message in + /// the value column. Row identity is positional because timestamps repeat. + @ViewBuilder + private var eventRows: some View { + let events = stats.eventRows() + if !events.isEmpty { + GridRow { + Text("RECENT EVENTS") + .font(headerFont) + .foregroundStyle(.white.opacity(0.52)) + .gridColumnAlignment(.trailing) + .id(Self.eventsSectionID) + Color.clear + .frame(height: 1) + } + ForEach(events) { row in + GridRow { + Text(row.time) + .font(labelFont) + .monospacedDigit() + .foregroundStyle(.white.opacity(0.74)) + Text(row.message) + .font(valueFont) + .foregroundStyle(Self.eventColor(row.kind)) + .lineLimit(2) + .minimumScaleFactor(0.76) + } + } + } + } + @ViewBuilder private func rows(for section: StatsSection) -> some View { if !section.rows.isEmpty { diff --git a/iosApp/iosApp/Screens/Player/PlayerViewModel.swift b/iosApp/iosApp/Screens/Player/PlayerViewModel.swift index 2c4e575a1..b81c82e6e 100644 --- a/iosApp/iosApp/Screens/Player/PlayerViewModel.swift +++ b/iosApp/iosApp/Screens/Player/PlayerViewModel.swift @@ -310,6 +310,9 @@ class PlayerViewModel { /// the active route cannot report a comparable value. var bufferedAheadSeconds: Double = 0 var playbackStats: PlaybackStats = .empty + /// Prairie: rolling log of recent player events for stats for nerds. + /// Survives item changes so a failure stays readable after teardown. + @ObservationIgnored private var playbackEventLog = PlaybackEventLog() var showNextUpScreen = false /// A Next Up load keeps its preview until the successor's own startup /// milestone. Repeated actions cannot reload it or expand an unready frame. @@ -1219,6 +1222,16 @@ class PlayerViewModel { case .playing, .paused, .seeking, .ended, .idle, .error: isLoading = false } + switch phase { + case .rebuffering: + recordPlaybackEvent("Rebuffering", kind: .warning) + case .stalled(let reconnecting): + recordPlaybackEvent(reconnecting ? "Stalled, reconnecting" : "Stalled", kind: .warning) + case .ended: + recordPlaybackEvent("Ended") + default: + break + } refreshPlaybackStats(force: true) syncIntroSkipPrompt() case .playerTime(let playerSeconds): @@ -1294,6 +1307,29 @@ class PlayerViewModel { } } + // MARK: Prairie stats-for-nerds detail + + /// Active quality as the picker labels it (`Auto`, `1080p 8 Mbps`, …). + private var statsQualityLabel: String? { + qualityOptions.first(where: { $0.id == activeQualityId })?.label ?? activeQualityId + } + + /// Selected audio track, e.g. `English · 5.1 · EAC3 · default (track 2)`. + private var statsAudioTrackLabel: String? { + guard let selectedAudioId, + let track = audioTracks.first(where: { $0.trackId == selectedAudioId }) else { + return nil + } + return "\(track.displayLabel) (track \(track.trackId))" + } + + /// Append to the on-device event log and publish it to the stats panel + /// without waiting for the next throttled refresh. + private func recordPlaybackEvent(_ message: String, kind: PlaybackEventLog.Kind = .info) { + playbackEventLog.record(message, kind: kind) + playbackStats.recentEvents = playbackEventLog.entries + } + @MainActor private func handleAetherControllerEvent(_ event: AetherPlaybackController.ControllerEvent) { guard !isDisposed else { return } @@ -1302,6 +1338,9 @@ class PlayerViewModel { syncNowPlayingDestination() refreshPlaybackStats(force: true) case .externalPlaybackChanged(let supported, let active): + if active != isExternalPlaybackActive { + recordPlaybackEvent(active ? "External playback on" : "External playback off") + } supportsExternalPlayback = supported isExternalPlaybackActive = active refreshPlaybackStats(force: true) @@ -1311,6 +1350,7 @@ class PlayerViewModel { private func refreshPlaybackStats(force: Bool = false) { guard let spec = aetherPlaybackController.activeSpec else { playbackStats = .empty + playbackStats.recentEvents = playbackEventLog.entries bufferedAheadSeconds = 0 return } @@ -1334,22 +1374,27 @@ class PlayerViewModel { secondarySubtitleLabel: secondaryLabel, plannedSourceDynamicRange: playbackPlan?.source.dynamicRange, plannedOutputDynamicRange: playbackPlan?.effectiveRecipe.dynamicRange, - plannedSourceDolbyVisionProfile: playbackPlan?.source.dolbyVisionProfile + plannedSourceDolbyVisionProfile: playbackPlan?.source.dolbyVisionProfile, + planSummary: playbackPlan.map(PlaybackPlanSummary.init(plan:)), + quality: statsQualityLabel, + audioTrack: statsAudioTrackLabel ) let snapshot = AetherPlaybackStatsSnapshot( engine: aetherPlaybackController.engine ) - let projected = AetherPlaybackStatsProjection.make( + var projected = AetherPlaybackStatsProjection.make( snapshot: snapshot, source: source, sampledAt: sampledAt ) + projected.recentEvents = playbackEventLog.entries playbackStats = projected bufferedAheadSeconds = max(0, projected.bufferedAheadSeconds ?? 0) } @MainActor private func handleAetherFailure(_ failure: PlaybackErrorInfo) { + recordPlaybackEvent("\(failure.kind.rawValue): \(failure.message)", kind: .error) if failure.kind == .audioTrackSwitchFailed { // The engine tore its pipeline down for the switch and the rebuild // failed, so there is nothing left playing whatever the phase. It @@ -1453,6 +1498,7 @@ class PlayerViewModel { private func handleAetherStartupMilestone(epoch: AetherPlaybackController.LoadEpoch) { guard startedAetherLoadEpoch != epoch else { return } startedAetherLoadEpoch = epoch + recordPlaybackEvent(isAudioOnlyAetherLoad ? "Audio started" : "First frame") handleFileLoaded() if isNextUpTransitioning { isNextUpTransitioning = false @@ -1527,6 +1573,10 @@ class PlayerViewModel { private func handlePlaybackError(_ message: String, failure: PlaybackErrorInfo? = nil) { let logMessage = MediaLogRedactor.sanitize(message) + if failure == nil { + // Typed Aether failures were already logged by handleAetherFailure. + recordPlaybackEvent(message, kind: .error) + } Self.logger.error("Player error: \(logMessage, privacy: .public)") guard !hasReachedEndOfFile else { Self.logger.info("Ignoring playback error after EOF: \(logMessage, privacy: .public)") @@ -1923,6 +1973,11 @@ class PlayerViewModel { trackTarget: QueuedProtocolV3TrackTarget? = nil, outputRouteSnapshot: ApplePlaybackV3CapabilitySnapshot? = nil ) -> Bool { + // A user-driven change names its operation; recovery replans don't. + recordPlaybackEvent( + "Replan: \(classification.replacingOccurrences(of: "_", with: " "))", + kind: operation == nil ? .warning : .info + ) // One classification of the user's target. A track change must have a // stable server ordinal before it is queued or issued: falling back to // the currently published engine selection would turn an unmappable tap @@ -2107,6 +2162,12 @@ class PlayerViewModel { self.currentWatchDetail = prepared.watchDetail self.currentSelectedVersion = prepared.selectedVersion self.activePreparedProtocolV3 = prepared.protocolV3 + if let plan = prepared.protocolV3?.plan { + let summary = PlaybackPlanSummary(plan: plan) + self.recordPlaybackEvent( + "Plan: \(summary.method)" + (summary.reasonLabel.map { " (\($0))" } ?? "") + ) + } if targetsSubtitle || classification == "subtitle_track_changed" { self.localProtocolV3SubtitleSelection = nil } else if priorPreparedProtocolV3?.plan.effectiveMediaFileId != prepared.protocolV3?.plan.effectiveMediaFileId, @@ -4802,6 +4863,9 @@ class PlayerViewModel { ? ApplePlaybackQuality.normalizeStoredId(qualityId) : ApplePlaybackQuality.protocolV3QualityId(qualityId) guard resolvedQualityId != activeQualityId || qualitySwitchError != nil else { return } + recordPlaybackEvent( + "Quality: \(qualityOptions.first(where: { $0.id == resolvedQualityId })?.label ?? resolvedQualityId)" + ) let target = currentTime.isFinite ? max(0, currentTime) : 0 isQualitySwitching = true @@ -5587,6 +5651,7 @@ class PlayerViewModel { // Aether's media-track id namespace. func selectAudio(_ track: PlayerTrack) { + recordPlaybackEvent("Audio: \(track.displayLabel)") if activePreparedProtocolV3 != nil { // The server owns the switch on this path, so the track must not // be applied locally before its plan arrives. The selection is diff --git a/iosApp/iosApp/Screens/Player/iOS/MobilePlaybackStatsOverlay.swift b/iosApp/iosApp/Screens/Player/iOS/MobilePlaybackStatsOverlay.swift index 384ce0d3b..a49c9a70c 100644 --- a/iosApp/iosApp/Screens/Player/iOS/MobilePlaybackStatsOverlay.swift +++ b/iosApp/iosApp/Screens/Player/iOS/MobilePlaybackStatsOverlay.swift @@ -23,19 +23,24 @@ struct MobilePlaybackStatsOverlay: View { private static let topInset: CGFloat = 60 var body: some View { - plate - .padding(.leading, 16) - .padding(.top, Self.topInset) - .frame( - maxWidth: .infinity, - maxHeight: .infinity, - alignment: .topLeading - ) - .allowsHitTesting(false) + // Side by side when the phone is wide enough (landscape), stacked with + // a shorter event list otherwise, so the plate never runs off-screen. + ViewThatFits(in: .horizontal) { + plate(stacked: false) + plate(stacked: true) + } + .padding(.leading, 16) + .padding(.top, Self.topInset) + .frame( + maxWidth: .infinity, + maxHeight: .infinity, + alignment: .topLeading + ) + .allowsHitTesting(false) } - private var plate: some View { - content + private func plate(stacked: Bool) -> some View { + content(stacked: stacked) .padding(.horizontal, 12) .padding(.vertical, 10) // Flat translucent black rather than a material: a blur reads as @@ -50,11 +55,32 @@ struct MobilePlaybackStatsOverlay: View { } @ViewBuilder - private var content: some View { + private func content(stacked: Bool) -> some View { + let panel = PlaybackStatsPanel(stats: stats, layout: .plain) + let hasEvents = !stats.recentEvents.isEmpty + if stacked { + VStack(alignment: .leading, spacing: 10) { + rows(panel) + if hasEvents { + panel.plainEventList(limit: 4) + } + } + } else { + HStack(alignment: .top, spacing: 18) { + rows(panel) + if hasEvents { + panel.plainEventList(limit: PlaybackEventLog.defaultCapacity) + } + } + } + } + + @ViewBuilder + private func rows(_ panel: PlaybackStatsPanel) -> some View { // Gated on the compact set, not `hasRows`: early snapshots can carry // only rows this overlay filters out, which would draw a blank plate. if !stats.compactRows.isEmpty { - PlaybackStatsPanel(stats: stats, layout: .plain) + panel } else { Text("Stats appear once playback starts.") .font(.system(size: 11)) diff --git a/iosApp/iosApp/Screens/Player/tvOS/TVPlayerInfoHUD.swift b/iosApp/iosApp/Screens/Player/tvOS/TVPlayerInfoHUD.swift index 26400c432..3fa9117a8 100644 --- a/iosApp/iosApp/Screens/Player/tvOS/TVPlayerInfoHUD.swift +++ b/iosApp/iosApp/Screens/Player/tvOS/TVPlayerInfoHUD.swift @@ -787,6 +787,9 @@ private struct StatsPane: View { if !viewModel.playbackStats.engineRows.isEmpty { ids.append(PlaybackStatsPanel.engineSectionID) } + if !viewModel.playbackStats.recentEvents.isEmpty { + ids.append(PlaybackStatsPanel.eventsSectionID) + } ids.append(Self.bottomAnchor) return ids } diff --git a/scripts/prairie-invariants.txt b/scripts/prairie-invariants.txt index 421e2f090..b9af76d2a 100644 --- a/scripts/prairie-invariants.txt +++ b/scripts/prairie-invariants.txt @@ -41,6 +41,12 @@ iosApp/iosApp/Screens/LiveTV/LiveTVChannelListView.swift 1 livetv-tab- Guide / C iosApp/iosApp/Screens/Player/tvOS/TVPlayerInfoHUD.swift 1 PlaybackStatsPanel\( tvOS Info HUD stats pane iosApp/iosApp/Screens/Player/iOS/MobilePlayerControls.swift 1 MobilePlaybackStatsOverlay\( iOS stats overlay iosApp/iosApp/Screens/Player/PlayerViewModel.swift 1 AetherPlaybackStatsProjection\.make\( stats refresh from Aether +iosApp/iosApp/Screens/Player/PlaybackDiagnostics.swift 1 struct PlaybackEventLog on-device player event ring buffer (smarttv #116 parity) +iosApp/iosApp/Screens/Player/PlayerViewModel.swift 3 recordPlaybackEvent\( player events feed the stats log +iosApp/iosApp/Screens/Player/PlayerViewModel.swift 1 PlaybackPlanSummary\.init\(plan:\) plan summary in stats +iosApp/iosApp/Screens/Player/PlaybackStats.swift 1 var planRows Plan section rows +iosApp/iosApp/Screens/Player/PlaybackStatsPanel.swift 1 RECENT EVENTS tvOS stats pane event list +iosApp/iosApp/Screens/Player/iOS/MobilePlaybackStatsOverlay.swift 1 plainEventList\( iOS overlay event list # --- Trickplay scrub previews (PR #28). iosApp/iosApp/Networking/Models.swift 1 let trickplay: VersionTrickplay\? trickplay sprite metadata on file versions