Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package org.prairieserver.prairie.common.player

import androidx.media3.common.Player
import org.prairieserver.prairie.playback.stats.PlayerEventSeverity
import org.prairieserver.prairie.playback.stats.StatsRow
import org.prairieserver.prairie.playback.stats.audioChannelsLabel
import org.prairieserver.prairie.playback.stats.bufferHealthLabel
import org.prairieserver.prairie.playback.stats.formatStatsBitrate

/**
* Prairie stats-for-nerds: turns [PlaybackAnalyticsListener] events into the
* short, timestamped lines the phone sheet and TV HUD list under "Recent
* events" (parity with prairie-smarttv #116). Chatty events (bandwidth
* estimates, track snapshots) are skipped so errors stay in the window.
*/
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
internal fun describePlayerEvent(
event: PlaybackAnalyticsListener.Event,
): Pair<PlayerEventSeverity, String>? = when (event) {
is PlaybackAnalyticsListener.Event.VideoDecoderInitialized ->
PlayerEventSeverity.INFO to buildString {
append("Video decoder ").append(event.decoderName)
event.initializationDurationMs?.let { append(" (").append(it).append(" ms)") }
}
is PlaybackAnalyticsListener.Event.AudioDecoderInitialized ->
PlayerEventSeverity.INFO to "Audio decoder ${event.decoderName}"
is PlaybackAnalyticsListener.Event.VideoFormatChanged -> {
val f = event.format
val size = if (f.width > 0 && f.height > 0) " ${f.width}x${f.height}" else ""
PlayerEventSeverity.INFO to "Video format ${f.codecs ?: f.sampleMimeType ?: "unknown"}$size"
}
is PlaybackAnalyticsListener.Event.AudioFormatChanged -> {
val f = event.format
val channels = audioChannelsLabel(f.channelCount.takeIf { it > 0 })?.let { " $it" } ?: ""
PlayerEventSeverity.INFO to "Audio format ${f.codecs ?: f.sampleMimeType ?: "unknown"}$channels"
}
is PlaybackAnalyticsListener.Event.DroppedFrames ->
PlayerEventSeverity.WARNING to "Dropped ${event.count} frames"
is PlaybackAnalyticsListener.Event.AudioUnderrun ->
PlayerEventSeverity.WARNING to "Audio underrun"
is PlaybackAnalyticsListener.Event.LoadError ->
PlayerEventSeverity.ERROR to "Load error: ${event.throwable.summary()}"
is PlaybackAnalyticsListener.Event.PlayerError ->
PlayerEventSeverity.ERROR to buildString {
append("Player error ").append(event.error.errorCodeName)
event.error.message?.takeIf { it.isNotBlank() }?.let { append(": ").append(it) }
event.error.cause?.let { append(" (").append(it.summary()).append(')') }
}
is PlaybackAnalyticsListener.Event.PlaybackStateChanged -> when (event.state) {
Player.STATE_BUFFERING -> PlayerEventSeverity.INFO to "Buffering"
Player.STATE_READY -> PlayerEventSeverity.INFO to "Ready"
Player.STATE_ENDED -> PlayerEventSeverity.INFO to "Ended"
else -> null
}
is PlaybackAnalyticsListener.Event.FirstFrameRendered -> PlayerEventSeverity.INFO to "First frame"
is PlaybackAnalyticsListener.Event.SeekStarted -> PlayerEventSeverity.INFO to "Seek"
// The format-changed line already names the codec; the decoder output
// format only settles HDR mode, which the Stats rows show.
is PlaybackAnalyticsListener.Event.VideoOutputFormatChanged,
is PlaybackAnalyticsListener.Event.BandwidthEstimate,
is PlaybackAnalyticsListener.Event.TrackSnapshot,
-> null
}

/**
* Appends [event] to the snapshot's event log, stamped [nowEpochMs]. Call
* after [reducePlayerStats] with the wall clock; the reducer itself stays pure.
*/
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
fun PlayerStatsSnapshot.recordPlayerEvent(
event: PlaybackAnalyticsListener.Event,
nowEpochMs: Long,
): PlayerStatsSnapshot {
val (severity, message) = describePlayerEvent(event) ?: return this
return copy(events = events.append(nowEpochMs, severity, message))
}

/**
* Player-side health rows shared by phone and TV: media bitrate, buffer
* ahead, rebuffers and startup time. Complements each app's existing
* decoder/format rows.
*/
fun PlayerStatsSnapshot.healthRows(): List<StatsRow> = buildList {
videoBitrateBps?.let { add(StatsRow("Video bitrate", formatStatsBitrate(it))) }
audioChannelsLabel(audioChannels)?.let { add(StatsRow("Audio channels", it)) }
bufferHealthLabel(bufferedDurationMs)?.let { add(StatsRow("Buffer ahead", it)) }
if (rebufferCount > 0) {
add(StatsRow("Rebuffers", "$rebufferCount (${rebufferTotalMs} ms total, max ${rebufferMaxMs} ms)"))
}
startupReadyMs?.let { add(StatsRow("Startup", "$it ms")) }
}

private fun Throwable.summary(): String {
val name = this::class.java.simpleName.ifBlank { "Error" }
val text = message?.takeIf { it.isNotBlank() }
return if (text != null) "$name: $text" else name
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import androidx.media3.common.C
import androidx.media3.common.Format
import androidx.media3.common.MimeTypes
import androidx.media3.common.Player
import org.prairieserver.prairie.playback.stats.PlayerEventLog

/**
* Snapshot of player statistics surfaced in playback diagnostics UI.
Expand Down Expand Up @@ -48,6 +49,14 @@ data class PlayerStatsSnapshot(
val lastSeekDurationMs: Long? = null,
val seekTotalMs: Long = 0,
val seekMaxMs: Long = 0,
/** Declared media bitrate of the current video format (not network throughput). */
val videoBitrateBps: Long? = null,
val audioChannels: Int? = null,
/**
* Prairie stats-for-nerds: the last few player events, recorded by
* [recordPlayerEvent] (wall-clock stamped, so kept out of the pure reducer).
*/
val events: PlayerEventLog = PlayerEventLog(),
)

/**
Expand Down Expand Up @@ -76,6 +85,7 @@ fun reducePlayerStats(
current.resolution
},
frameRate = if (event.format.frameRate > 0f) event.format.frameRate else current.frameRate,
videoBitrateBps = event.format.declaredBitrate() ?: current.videoBitrateBps,
hdrMode = null, // Wait for the decoder output, including after format changes.
colorTransfer = describeColorTransfer(event.format) ?: current.colorTransfer,
colorRange = describeColorRange(event.format) ?: current.colorRange,
Expand All @@ -84,7 +94,10 @@ fun reducePlayerStats(
hdrMode = describeHdrMode(event.format, event.decoderMimeType),
)
is PlaybackAnalyticsListener.Event.AudioFormatChanged ->
current.copy(audioCodec = event.format.codecs ?: event.format.sampleMimeType)
current.copy(
audioCodec = event.format.codecs ?: event.format.sampleMimeType,
audioChannels = event.format.channelCount.takeIf { it > 0 } ?: current.audioChannels,
)
is PlaybackAnalyticsListener.Event.DroppedFrames ->
current.copy(droppedFrames = current.droppedFrames + event.count)
is PlaybackAnalyticsListener.Event.AudioUnderrun ->
Expand Down Expand Up @@ -154,7 +167,9 @@ data class FinishedPlayerStats(

fun finishPlayerStats(current: PlayerStatsSnapshot, detailedCapture: Boolean): FinishedPlayerStats =
FinishedPlayerStats(
next = PlayerStatsSnapshot(),
// Keep the event log: the error that ended a session is what the
// stats panel of the retry needs to show.
next = PlayerStatsSnapshot(events = current.events),
finalSnapshot = current.takeIf { detailedCapture && it.hasPerformanceEvidence() },
)

Expand All @@ -170,6 +185,11 @@ fun PlayerStatsSnapshot.firstFrameDiagnostics(firstFrameMs: Long): Map<String, S
colorRange?.let { put("color_range", it) }
}

@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
private fun Format.declaredBitrate(): Long? =
(averageBitrate.takeIf { it > 0 } ?: bitrate.takeIf { it > 0 } ?: peakBitrate.takeIf { it > 0 })
?.toLong()

private fun describeColorTransfer(format: Format): String? = when (format.colorInfo?.colorTransfer) {
C.COLOR_TRANSFER_ST2084 -> "st2084"
C.COLOR_TRANSFER_HLG -> "hlg"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
package org.prairieserver.prairie.common.player

import androidx.annotation.OptIn
import androidx.media3.common.Format
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import org.prairieserver.prairie.playback.stats.PlayerEventSeverity
import java.io.IOException
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertSame

@OptIn(UnstableApi::class)
class PlayerStatsEventsTest {

@Test
fun `errors are recorded with the URL query stripped`() {
val snapshot = PlayerStatsSnapshot().recordPlayerEvent(
PlaybackAnalyticsListener.Event.LoadError(
IOException("403 for https://srv/api/v2/stream/a/master.m3u8?stream_token=secret"),
),
nowEpochMs = 1_000L,
)
val entry = snapshot.events.entries.single()
assertEquals(PlayerEventSeverity.ERROR, entry.severity)
assertEquals("Load error: IOException: 403 for https://srv/api/v2/stream/a/master.m3u8?…", entry.message)
assertEquals(1_000L, entry.atEpochMs)
}

@Test
fun `player errors name the error code`() {
val error = PlaybackException("boom", null, PlaybackException.ERROR_CODE_DECODING_FAILED)
val (severity, message) = describePlayerEvent(PlaybackAnalyticsListener.Event.PlayerError(error))!!
assertEquals(PlayerEventSeverity.ERROR, severity)
assertEquals("Player error ERROR_CODE_DECODING_FAILED: boom", message)
}

@Test
fun `state, format and decoder events read as short lines`() {
fun line(e: PlaybackAnalyticsListener.Event) = describePlayerEvent(e)?.second
assertEquals("Buffering", line(PlaybackAnalyticsListener.Event.PlaybackStateChanged(Player.STATE_BUFFERING, 0, 0, true)))
assertEquals("Ready", line(PlaybackAnalyticsListener.Event.PlaybackStateChanged(Player.STATE_READY, 0, 0, true)))
assertEquals("Ended", line(PlaybackAnalyticsListener.Event.PlaybackStateChanged(Player.STATE_ENDED, 0, 0, true)))
assertNull(line(PlaybackAnalyticsListener.Event.PlaybackStateChanged(Player.STATE_IDLE, 0, 0, true)))
assertEquals(
"Video format hvc1.2.4.L153 3840x1600",
line(
PlaybackAnalyticsListener.Event.VideoFormatChanged(
Format.Builder().setCodecs("hvc1.2.4.L153").setWidth(3840).setHeight(1600).build(),
),
),
)
assertEquals(
"Audio format audio/eac3 5.1",
line(
PlaybackAnalyticsListener.Event.AudioFormatChanged(
Format.Builder().setSampleMimeType("audio/eac3").setChannelCount(6).build(),
),
),
)
assertEquals(
"Video decoder c2.qti.hevc.decoder (42 ms)",
line(PlaybackAnalyticsListener.Event.VideoDecoderInitialized("c2.qti.hevc.decoder", 42)),
)
assertEquals("Audio decoder c2.android.aac", line(PlaybackAnalyticsListener.Event.AudioDecoderInitialized("c2.android.aac")))
assertEquals("Dropped 5 frames", line(PlaybackAnalyticsListener.Event.DroppedFrames(5, 1000)))
assertEquals("Audio underrun", line(PlaybackAnalyticsListener.Event.AudioUnderrun))
assertEquals("First frame", line(PlaybackAnalyticsListener.Event.FirstFrameRendered(1)))
assertEquals("Seek", line(PlaybackAnalyticsListener.Event.SeekStarted(1)))
}

@Test
fun `chatty events are not recorded`() {
val start = PlayerStatsSnapshot()
assertSame(start, start.recordPlayerEvent(PlaybackAnalyticsListener.Event.BandwidthEstimate(1), 1))
assertSame(start, start.recordPlayerEvent(PlaybackAnalyticsListener.Event.TrackSnapshot("x"), 1))
}

@Test
fun `reducer tracks declared bitrate and channels`() {
val video = reducePlayerStats(
PlayerStatsSnapshot(),
PlaybackAnalyticsListener.Event.VideoFormatChanged(Format.Builder().setAverageBitrate(18_000_000).build()),
)
assertEquals(18_000_000L, video.videoBitrateBps)
val audio = reducePlayerStats(
video,
PlaybackAnalyticsListener.Event.AudioFormatChanged(Format.Builder().setChannelCount(2).build()),
)
assertEquals(2, audio.audioChannels)
assertEquals(18_000_000L, audio.videoBitrateBps)
}

@Test
fun `health rows list bitrate, channels, buffer, rebuffers and startup`() {
val rows = PlayerStatsSnapshot(
videoBitrateBps = 18_000_000,
audioChannels = 6,
bufferedDurationMs = 12_340,
rebufferCount = 2,
rebufferTotalMs = 900,
rebufferMaxMs = 600,
startupReadyMs = 850,
).healthRows()
assertEquals(
listOf(
"Video bitrate" to "18.0 Mbps",
"Audio channels" to "5.1",
"Buffer ahead" to "12.3 s",
"Rebuffers" to "2 (900 ms total, max 600 ms)",
"Startup" to "850 ms",
),
rows.map { it.label to it.value },
)
assertEquals(emptyList<org.prairieserver.prairie.playback.stats.StatsRow>(), PlayerStatsSnapshot().healthRows())
}

@Test
fun `finishing a session keeps the event log`() {
val withEvent = PlayerStatsSnapshot().recordPlayerEvent(PlaybackAnalyticsListener.Event.AudioUnderrun, 5)
assertEquals(withEvent.events, finishPlayerStats(withEvent, detailedCapture = false).next.events)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,23 @@ import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.prairieserver.prairie.common.player.PlayerStatsSnapshot
import org.prairieserver.prairie.common.player.healthRows
import org.prairieserver.prairie.playback.stats.PlaybackStatsContext
import org.prairieserver.prairie.playback.stats.displayLine
import org.prairieserver.prairie.playback.stats.planRows

@OptIn(ExperimentalMaterial3Api::class)
@Composable
Expand All @@ -41,6 +47,8 @@ fun PlaybackStatsSheet(
playMethod: String? = null,
positionLabel: String? = null,
tabletopPaneHeight: Dp? = null,
// Prairie: plan / audio track / stream path / quality, shared with the TV HUD.
playbackContext: PlaybackStatsContext = PlaybackStatsContext(),
) {
if (!isVisible) return

Expand Down Expand Up @@ -94,13 +102,21 @@ fun PlaybackStatsSheet(
)
Spacer(modifier = Modifier.height(18.dp))

val planRows = playbackContext.planRows()
val rows = buildList {
sessionId?.takeIf { it.isNotBlank() }?.let { add("Session" to it) }
playMethod?.takeIf { it.isNotBlank() }?.let { add("Play method" to it) }
positionLabel?.takeIf { it.isNotBlank() }?.let { add("Position" to it) }
addAll(stats.mobileStatsRows())
stats.healthRows().forEach { add(it.label to it.value) }
}
if (rows.isEmpty()) {
if (planRows.isNotEmpty()) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
planRows.forEach { row -> StatsBlockRow(label = row.label, value = row.value) }
}
Spacer(modifier = Modifier.height(16.dp))
}
if (rows.isEmpty() && planRows.isEmpty()) {
Text(
text = "Waiting for player data",
color = Color.White.copy(alpha = 0.66f),
Expand All @@ -113,11 +129,51 @@ fun PlaybackStatsSheet(
}
}
}
if (!stats.events.isEmpty) {
Spacer(modifier = Modifier.height(18.dp))
Text(
text = "Recent player events",
color = Color.White.copy(alpha = 0.62f),
fontSize = 13.sp,
)
Spacer(modifier = Modifier.height(8.dp))
val offsetMs = remember(stats.events) {
java.util.TimeZone.getDefault().getOffset(System.currentTimeMillis()).toLong()
}
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
stats.events.entries.forEach { event ->
Text(
text = event.displayLine(offsetMs),
color = Color.White.copy(alpha = 0.86f),
fontSize = 12.sp,
fontFamily = FontFamily.Monospace,
)
}
}
}
}
}
}
}

/** Label over a wrapping value, for the long plan / stream / track lines. */
@Composable
private fun StatsBlockRow(label: String, value: String) {
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = label,
color = Color.White.copy(alpha = 0.62f),
fontSize = 13.sp,
)
Text(
text = value,
color = Color.White,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
)
}
}

@Composable
private fun StatsRow(label: String, value: String) {
Row(modifier = Modifier.fillMaxWidth()) {
Expand Down
Loading
Loading