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