Skip to content

Commit 547a69b

Browse files
committed
feat: measure TV playback quality
1 parent 30b57c7 commit 547a69b

4 files changed

Lines changed: 143 additions & 0 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package video.typetype.tv.player
2+
3+
import android.util.Log
4+
5+
internal fun logTvPlaybackQuality(metrics: TvPlaybackQualityMetrics) {
6+
val snapshot = metrics.snapshot()
7+
Log.i(
8+
"TypeTypePlayback",
9+
"quality startupMs=${snapshot.startupMilliseconds ?: -1L} " +
10+
"playedMs=${snapshot.playedMilliseconds} rebufferMs=${snapshot.rebufferMilliseconds} " +
11+
"droppedVideoFrames=${snapshot.droppedVideoFrames}",
12+
)
13+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
@file:androidx.annotation.OptIn(markerClass = [androidx.media3.common.util.UnstableApi::class])
2+
3+
package video.typetype.tv.player
4+
5+
import android.os.SystemClock
6+
import androidx.media3.common.Player
7+
import androidx.media3.exoplayer.analytics.AnalyticsListener
8+
9+
internal data class TvPlaybackQualitySnapshot(
10+
val startupMilliseconds: Long?,
11+
val playedMilliseconds: Long,
12+
val rebufferMilliseconds: Long,
13+
val droppedVideoFrames: Long,
14+
)
15+
16+
internal class TvPlaybackQualityMetrics : AnalyticsListener {
17+
private var playStartedAt = UNSET
18+
private var firstFrameAt = UNSET
19+
private var playingSince = UNSET
20+
private var rebufferingSince = UNSET
21+
private var playedMilliseconds = 0L
22+
private var rebufferMilliseconds = 0L
23+
private var droppedVideoFrames = 0L
24+
25+
fun onPlaybackStateChanged(state: Int, now: Long = SystemClock.elapsedRealtime()) {
26+
if (firstFrameAt == UNSET) return
27+
if (state == Player.STATE_BUFFERING && rebufferingSince == UNSET) {
28+
rebufferingSince = now
29+
} else if (state != Player.STATE_BUFFERING && rebufferingSince != UNSET) {
30+
rebufferMilliseconds += (now - rebufferingSince).coerceAtLeast(0L)
31+
rebufferingSince = UNSET
32+
}
33+
}
34+
35+
fun onIsPlayingChanged(isPlaying: Boolean, now: Long = SystemClock.elapsedRealtime()) {
36+
if (isPlaying) {
37+
if (playStartedAt == UNSET) playStartedAt = now
38+
if (playingSince == UNSET) playingSince = now
39+
} else if (playingSince != UNSET) {
40+
playedMilliseconds += (now - playingSince).coerceAtLeast(0L)
41+
playingSince = UNSET
42+
}
43+
}
44+
45+
override fun onRenderedFirstFrame(
46+
eventTime: AnalyticsListener.EventTime,
47+
output: Any,
48+
renderTimeMs: Long,
49+
) {
50+
onRenderedFirstFrameAt(eventTime.realtimeMs)
51+
}
52+
53+
fun onRenderedFirstFrameAt(now: Long) {
54+
if (firstFrameAt == UNSET) firstFrameAt = now
55+
}
56+
57+
override fun onDroppedVideoFrames(
58+
eventTime: AnalyticsListener.EventTime,
59+
droppedFrames: Int,
60+
elapsedMs: Long,
61+
) {
62+
onDroppedVideoFramesCount(droppedFrames)
63+
}
64+
65+
fun onDroppedVideoFramesCount(droppedFrames: Int) {
66+
droppedVideoFrames += droppedFrames.coerceAtLeast(0).toLong()
67+
}
68+
69+
fun snapshot(now: Long = SystemClock.elapsedRealtime()): TvPlaybackQualitySnapshot {
70+
val played = playedMilliseconds + if (playingSince == UNSET) 0L else {
71+
(now - playingSince).coerceAtLeast(0L)
72+
}
73+
val rebuffered = rebufferMilliseconds + if (rebufferingSince == UNSET) 0L else {
74+
(now - rebufferingSince).coerceAtLeast(0L)
75+
}
76+
return TvPlaybackQualitySnapshot(
77+
startupMilliseconds = if (playStartedAt == UNSET || firstFrameAt == UNSET) null
78+
else (firstFrameAt - playStartedAt).coerceAtLeast(0L),
79+
playedMilliseconds = played,
80+
rebufferMilliseconds = rebuffered,
81+
droppedVideoFrames = droppedVideoFrames,
82+
)
83+
}
84+
85+
private companion object {
86+
const val UNSET = -1L
87+
}
88+
}

‎tv/src/main/java/video/typetype/tv/player/TypeTypePlaybackService.kt‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ public class TypeTypePlaybackService : MediaSessionService() {
4040
private lateinit var session: MediaSession
4141
private lateinit var client: TypeTypeClient
4242
private var sourceHandle: PlaybackMediaSourceHandle? = null
43+
private val qualityMetrics = TvPlaybackQualityMetrics()
4344
private var currentRequest: TvPlaybackRequest? = null
4445
private var currentSubtitle: SubtitleTrack? = null
4546
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
@@ -57,6 +58,12 @@ public class TypeTypePlaybackService : MediaSessionService() {
5758
player.addListener(object : Player.Listener {
5859
override fun onPlayerError(error: PlaybackException) = handlePlayerError(error)
5960

61+
override fun onPlaybackStateChanged(playbackState: Int) =
62+
qualityMetrics.onPlaybackStateChanged(playbackState)
63+
64+
override fun onIsPlayingChanged(isPlaying: Boolean) =
65+
qualityMetrics.onIsPlayingChanged(isPlaying)
66+
6067
override fun onPositionDiscontinuity(
6168
oldPosition: Player.PositionInfo,
6269
newPosition: Player.PositionInfo,
@@ -67,6 +74,7 @@ public class TypeTypePlaybackService : MediaSessionService() {
6774
}
6875
}
6976
})
77+
player.addAnalyticsListener(qualityMetrics)
7078
session = MediaSession.Builder(this, player).build()
7179
}
7280

@@ -85,6 +93,7 @@ public class TypeTypePlaybackService : MediaSessionService() {
8593
override fun onDestroy() {
8694
sponsorBlockController?.stop()
8795
logSabrMetrics(sourceHandle)
96+
logTvPlaybackQuality(qualityMetrics)
8897
sourceHandle?.close()
8998
serviceScope.cancel()
9099
session.release()
@@ -117,6 +126,7 @@ public class TypeTypePlaybackService : MediaSessionService() {
117126
player.stop()
118127
player.clearMediaItems()
119128
logSabrMetrics(sourceHandle)
129+
logTvPlaybackQuality(qualityMetrics)
120130
sourceHandle?.close()
121131
sourceHandle = null
122132
currentRequest = null
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package video.typetype.tv.player
2+
3+
import androidx.media3.common.Player
4+
import org.junit.Assert.assertEquals
5+
import org.junit.Test
6+
7+
public class TvPlaybackQualityMetricsTest {
8+
@Test
9+
public fun recordsStartupPlaybackAndRebuffering() {
10+
val metrics = TvPlaybackQualityMetrics()
11+
metrics.onIsPlayingChanged(true, now = 100L)
12+
metrics.onPlaybackStateChanged(Player.STATE_READY, now = 110L)
13+
metrics.onRenderedFirstFrameAt(120L)
14+
metrics.onPlaybackStateChanged(Player.STATE_BUFFERING, now = 300L)
15+
metrics.onPlaybackStateChanged(Player.STATE_READY, now = 450L)
16+
metrics.onIsPlayingChanged(false, now = 700L)
17+
18+
val snapshot = metrics.snapshot(now = 900L)
19+
assertEquals(20L, snapshot.startupMilliseconds)
20+
assertEquals(600L, snapshot.playedMilliseconds)
21+
assertEquals(150L, snapshot.rebufferMilliseconds)
22+
}
23+
24+
@Test
25+
public fun clampsNegativeDroppedFrameCounts() {
26+
val metrics = TvPlaybackQualityMetrics()
27+
metrics.onDroppedVideoFramesCount(-2)
28+
metrics.onDroppedVideoFramesCount(3)
29+
30+
assertEquals(3L, metrics.snapshot(now = 100L).droppedVideoFrames)
31+
}
32+
}

0 commit comments

Comments
 (0)