From 35b01635a81019173eb8bf3d8ce8a125b7993426 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 23 Oct 2026 21:46:17 +0200 Subject: [PATCH 1/6] fix: align SABR windows with requested buffer goals --- .../stream/SabrPlaybackSessionProtocol.kt | 44 ++++++- .../domain/stream/SabrPlaybackRepository.kt | 1 + .../services/SabrPlaybackWindowLoader.kt | 19 ++- .../stream/SabrPlaybackRateContractTest.kt | 19 +++ .../stream/SabrPlaybackSessionPreparerTest.kt | 116 +---------------- .../SabrPlaybackSessionPreparerTestSupport.kt | 120 ++++++++++++++++++ 6 files changed, 195 insertions(+), 124 deletions(-) create mode 100644 app/src/test/java/dev/typetype/android/data/stream/SabrPlaybackSessionPreparerTestSupport.kt diff --git a/app/src/main/java/dev/typetype/android/data/stream/SabrPlaybackSessionProtocol.kt b/app/src/main/java/dev/typetype/android/data/stream/SabrPlaybackSessionProtocol.kt index c6548e27..f3e49854 100644 --- a/app/src/main/java/dev/typetype/android/data/stream/SabrPlaybackSessionProtocol.kt +++ b/app/src/main/java/dev/typetype/android/data/stream/SabrPlaybackSessionProtocol.kt @@ -49,9 +49,14 @@ internal fun SabrPlaybackResponse.windowRequest( audioItag = audioItag, audioTrackId = audioTrackId, playbackRate = playbackRate.sanitizedPlaybackRate(), - bufferGoalMs = playbackRate.rateAwareBufferGoalMs( - isLive, - hasBufferedPlayback(ranges, playerTimeMs, audioOnly), + bufferGoalMs = requestedBufferGoalMs( + videoItag = videoItag, + audioItag = audioItag, + audioOnly = audioOnly, + isLive = isLive, + ranges = ranges, + playerTimeMs = playerTimeMs, + playbackRate = playbackRate, ), bufferedRanges = ranges.map { SabrPlaybackBufferedRangeDto(it.itag, it.startMs, it.endMs) @@ -68,6 +73,33 @@ internal fun SabrPlaybackWindowResponseDto.requireWindowIdentity( return this } +internal fun SabrPlaybackTarget.requestedBufferGoalMs( + ranges: List, + playerTimeMs: Long, + playbackRate: Float, +): Long = requestedBufferGoalMs( + videoItag = videoItag, + audioItag = audioItag, + audioOnly = audioOnly, + isLive = isLive, + ranges = ranges, + playerTimeMs = playerTimeMs, + playbackRate = playbackRate, +) + +private fun requestedBufferGoalMs( + videoItag: Int, + audioItag: Int, + audioOnly: Boolean, + isLive: Boolean, + ranges: List, + playerTimeMs: Long, + playbackRate: Float, +): Long = playbackRate.rateAwareBufferGoalMs( + isLive, + hasBufferedPlayback(videoItag, audioItag, audioOnly, ranges, playerTimeMs), +) + internal fun SabrPlaybackWindowResponseDto.throwTerminalFailure() { val message = terminalError?.takeIf { it.isNotBlank() } ?: return throw SabrPlaybackRecoveryException( @@ -145,10 +177,12 @@ private fun Float.rateAwareBufferGoalMs(isLive: Boolean, hasBufferedPlayback: Bo .toLong() .coerceAtMost(MAX_BUFFER_GOAL_MS) -private fun SabrPlaybackResponse.hasBufferedPlayback( +private fun hasBufferedPlayback( + videoItag: Int, + audioItag: Int, + audioOnly: Boolean, ranges: List, playerTimeMs: Long, - audioOnly: Boolean, ): Boolean { val requiredItags = listOfNotNull(videoItag.takeUnless { audioOnly }, audioItag) return requiredItags.isNotEmpty() && requiredItags.all { itag -> diff --git a/app/src/main/java/dev/typetype/android/domain/stream/SabrPlaybackRepository.kt b/app/src/main/java/dev/typetype/android/domain/stream/SabrPlaybackRepository.kt index 26725d7f..48041569 100644 --- a/app/src/main/java/dev/typetype/android/domain/stream/SabrPlaybackRepository.kt +++ b/app/src/main/java/dev/typetype/android/domain/stream/SabrPlaybackRepository.kt @@ -20,6 +20,7 @@ data class SabrPlaybackSession( val audioWindow: SabrPlaybackWindowTrack? = null, val videoWindow: SabrPlaybackWindowTrack? = null, val live: SabrLivePlaybackWindow? = null, + val requestedBufferGoalMs: Long = 2_500L, ) data class SabrPlaybackBufferedRange( diff --git a/app/src/main/java/dev/typetype/android/services/SabrPlaybackWindowLoader.kt b/app/src/main/java/dev/typetype/android/services/SabrPlaybackWindowLoader.kt index 4d88e7a5..3ebcfd9e 100644 --- a/app/src/main/java/dev/typetype/android/services/SabrPlaybackWindowLoader.kt +++ b/app/src/main/java/dev/typetype/android/services/SabrPlaybackWindowLoader.kt @@ -1,5 +1,6 @@ package dev.typetype.android.services +import dev.typetype.android.data.stream.requestedBufferGoalMs import dev.typetype.android.domain.stream.SabrPlaybackBinding import dev.typetype.android.domain.stream.SabrPlaybackBufferedRange import dev.typetype.android.domain.stream.SabrPlaybackRepository @@ -41,17 +42,26 @@ internal class SabrPlaybackWindowLoader( ): PlaybackLoadCancellation { return launchWindowRequest(callback) { val activeBinding = transportState.currentBinding() + var requestedBufferGoalMs = 2_500L repository.refresh( target = target, binding = activeBinding, ) { val current = request() + val ranges = current.bufferedRanges.mapNotNull(::toDomainRange) + val playerTimeMs = current.positionUs.toMilliseconds() + val currentPlaybackRate = playbackRate() + requestedBufferGoalMs = target.requestedBufferGoalMs( + ranges = ranges, + playerTimeMs = playerTimeMs, + playbackRate = currentPlaybackRate, + ) SabrPlaybackSnapshot( - playerTimeMs = current.positionUs.toMilliseconds(), - bufferedRanges = current.bufferedRanges.mapNotNull(::toDomainRange), - playbackRate = playbackRate(), + playerTimeMs = playerTimeMs, + bufferedRanges = ranges, + playbackRate = currentPlaybackRate, ) - } + }.map { session -> session.copy(requestedBufferGoalMs = requestedBufferGoalMs) } } } @@ -125,6 +135,7 @@ internal fun SabrPlaybackSession.toPlayerWindow( val audio = requireNotNull(audioWindow) { "SABR omitted its validated audio window" } return PlaybackWindow( generation = generation, + bufferGoalUs = requestedBufferGoalMs.toMicroseconds(), durationUs = durationMs.toMicroseconds(), startPositionUs = startTimeMs.toMicroseconds(), endOfStream = endOfStream, diff --git a/app/src/test/java/dev/typetype/android/data/stream/SabrPlaybackRateContractTest.kt b/app/src/test/java/dev/typetype/android/data/stream/SabrPlaybackRateContractTest.kt index 6bbb0e94..274e5f22 100644 --- a/app/src/test/java/dev/typetype/android/data/stream/SabrPlaybackRateContractTest.kt +++ b/app/src/test/java/dev/typetype/android/data/stream/SabrPlaybackRateContractTest.kt @@ -2,6 +2,8 @@ package dev.typetype.android.data.stream import dev.typetype.android.data.network.dto.SabrPlaybackResponse import dev.typetype.android.domain.stream.SabrPlaybackBufferedRange +import dev.typetype.android.domain.stream.SabrPlaybackTarget +import dev.typetype.android.domain.stream.StreamRequestScope import org.junit.Assert.assertEquals import org.junit.Test @@ -60,6 +62,23 @@ class SabrPlaybackRateContractTest { assertEquals(10_000L, response().windowRequest(bufferedTracks()).bufferGoalMs) } + @Test + fun playerWindowAndServerRequestUseTheSameBufferGoal() { + val ranges = bufferedTracks() + val playbackTarget = SabrPlaybackTarget( + videoId = "video", + requestScope = StreamRequestScope("server", "account", "https://example.test/api/"), + videoItag = 137, + audioItag = 140, + audioTrackId = "en.0", + ) + + assertEquals( + response().windowRequest(ranges, playerTimeMs = 5_000L, playbackRate = 1.5f).bufferGoalMs, + playbackTarget.requestedBufferGoalMs(ranges, 5_000L, 1.5f), + ) + } + @Test fun unrelatedTracksDoNotExpandTheWindow() { val ranges = listOf(SabrPlaybackBufferedRange(303, 0L, 10_000L)) diff --git a/app/src/test/java/dev/typetype/android/data/stream/SabrPlaybackSessionPreparerTest.kt b/app/src/test/java/dev/typetype/android/data/stream/SabrPlaybackSessionPreparerTest.kt index 24c62e96..4668fdab 100644 --- a/app/src/test/java/dev/typetype/android/data/stream/SabrPlaybackSessionPreparerTest.kt +++ b/app/src/test/java/dev/typetype/android/data/stream/SabrPlaybackSessionPreparerTest.kt @@ -1,48 +1,17 @@ package dev.typetype.android.data.stream -import dev.typetype.android.data.network.RetrofitFactory -import dev.typetype.android.data.network.TypeTypeApi -import dev.typetype.android.data.network.sabrControlClient -import dev.typetype.android.domain.stream.SabrPlaybackBinding import dev.typetype.android.domain.stream.SabrPlaybackBufferedRange -import dev.typetype.android.domain.stream.SabrPlaybackTarget -import dev.typetype.android.domain.stream.StreamRequestScope import kotlinx.coroutines.runBlocking import kotlinx.serialization.json.Json import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive -import okhttp3.OkHttpClient -import okhttp3.mockwebserver.MockResponse -import okhttp3.mockwebserver.MockWebServer -import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue -import org.junit.Before import org.junit.Test -class SabrPlaybackSessionPreparerTest { - private lateinit var server: MockWebServer - private lateinit var api: TypeTypeApi - private lateinit var baseUrl: String - - @Before - fun setUp() { - server = MockWebServer() - server.start() - baseUrl = server.url("/api/").toString() - api = RetrofitFactory( - sessionClient = OkHttpClient().sabrControlClient(), - json = Json { ignoreUnknownKeys = true }, - ).create(baseUrl) - } - - @After - fun tearDown() { - server.shutdown() - } - +class SabrPlaybackSessionPreparerTest : SabrPlaybackSessionPreparerTestSupport() { @Test fun sharedSessionUsesPositionPrefetchAndSegmentsBeforeMedia3() = runBlocking { enqueueCreate(startTimeMs = 42_000) @@ -276,87 +245,4 @@ class SabrPlaybackSessionPreparerTest { assertTrue(failure?.message.orEmpty().contains("origin")) } - private fun preparer() = SabrPlaybackSessionPreparer(pause = {}, maxWindowPolls = 4) - - private fun target( - videoItag: Int = 137, - recoveryVideoItags: Set = emptySet(), - ) = SabrPlaybackTarget( - videoId = "video", - requestScope = StreamRequestScope("server", "account", baseUrl), - videoItag = videoItag, - audioItag = 140, - audioTrackId = "en.0", - recoveryVideoItags = recoveryVideoItags, - ) - - private fun binding() = SabrPlaybackBinding("session", 0, 137, 140, "en.0") - - private fun enqueueCreate( - startTimeMs: Long = 0, - ready: Boolean = false, - sessionId: String = "session", - generation: Long = 0, - videoItag: Int = 137, - ) { - server.enqueue( - jsonResponse( - """{"sessionId":"$sessionId","videoId":"video","manifestUrl":${if (ready) "\"/sabr/playback/$sessionId/manifest\"" else "null"},"videoItag":$videoItag,"audioItag":140,"audioTrackId":"en.0","startTimeMs":$startTimeMs,"generation":$generation,"ready":$ready,"status":"${if (ready) "ready" else "preparing"}","retryAfterMs":${if (ready) "null" else "250"}}""", - if (ready) 200 else 202, - ), - ) - } - - private fun enqueuePosition( - playerTimeMs: Long, - sessionId: String = "session", - generation: Long = 0, - ) { - server.enqueue( - jsonResponse( - """{"sessionId":"$sessionId","generation":$generation,"playerTimeMs":$playerTimeMs,"readerHeadMs":0,"readerTailMs":0,"bufferedEdgeMs":70000}""", - ), - ) - } - - private fun enqueuePrefetch( - ready: Boolean, - sessionId: String = "session", - generation: Long = 0, - ) { - server.enqueue( - jsonResponse( - """{"sessionId":"$sessionId","generation":$generation,"ready":$ready,"retryAfterMs":${if (ready) "null" else "250"},"status":"${if (ready) "ready" else "preparing"}","bufferedEdgeMs":70000}""", - if (ready) 200 else 202, - ), - ) - } - - private fun enqueueWindow( - startTimeMs: Long, - endOfStream: Boolean, - segmentStartMs: Long = 40_000, - audioInitUrl: String = "/api/sabr/playback/session/140/init?generation=0", - sessionId: String = "session", - generation: Long = 0, - videoItag: Int = 137, - ) { - val audioEnd = segmentStartMs + 30_000 - val videoEnd = segmentStartMs + 30_000 - val resolvedAudioInitUrl = if (audioInitUrl.contains("media.example")) { - audioInitUrl - } else { - "/api/sabr/playback/$sessionId/140/init?generation=$generation" - } - server.enqueue( - jsonResponse( - """{"sessionId":"$sessionId","generation":$generation,"ready":true,"retryAfterMs":null,"durationMs":120000,"endOfStream":$endOfStream,"startTimeMs":$startTimeMs,"audio":{"mime":"audio/mp4","initUrl":"$resolvedAudioInitUrl","segments":[{"url":"/api/sabr/playback/$sessionId/140/segment/5?generation=$generation","startMs":$segmentStartMs,"durationMs":${audioEnd - segmentStartMs}}]},"video":{"mime":"video/mp4","initUrl":"/api/sabr/playback/$sessionId/$videoItag/init?generation=$generation","segments":[{"url":"/api/sabr/playback/$sessionId/$videoItag/segment/5?generation=$generation","startMs":$segmentStartMs,"durationMs":${videoEnd - segmentStartMs}}]}}""", - ), - ) - } - - private fun jsonResponse(body: String, code: Int = 200) = MockResponse() - .setResponseCode(code) - .setHeader("Content-Type", "application/json") - .setBody(body) } diff --git a/app/src/test/java/dev/typetype/android/data/stream/SabrPlaybackSessionPreparerTestSupport.kt b/app/src/test/java/dev/typetype/android/data/stream/SabrPlaybackSessionPreparerTestSupport.kt new file mode 100644 index 00000000..942e8639 --- /dev/null +++ b/app/src/test/java/dev/typetype/android/data/stream/SabrPlaybackSessionPreparerTestSupport.kt @@ -0,0 +1,120 @@ +package dev.typetype.android.data.stream + +import dev.typetype.android.data.network.RetrofitFactory +import dev.typetype.android.data.network.TypeTypeApi +import dev.typetype.android.data.network.sabrControlClient +import dev.typetype.android.domain.stream.SabrPlaybackBinding +import dev.typetype.android.domain.stream.SabrPlaybackTarget +import dev.typetype.android.domain.stream.StreamRequestScope +import kotlinx.serialization.json.Json +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Before + +abstract class SabrPlaybackSessionPreparerTestSupport { + protected lateinit var server: MockWebServer + protected lateinit var api: TypeTypeApi + protected lateinit var baseUrl: String + + @Before + fun setUp() { + server = MockWebServer() + server.start() + baseUrl = server.url("/api/").toString() + api = RetrofitFactory( + sessionClient = OkHttpClient().sabrControlClient(), + json = Json { ignoreUnknownKeys = true }, + ).create(baseUrl) + } + + @After + fun tearDown() { + server.shutdown() + } + + internal fun preparer() = SabrPlaybackSessionPreparer(pause = {}, maxWindowPolls = 4) + + protected fun target( + videoItag: Int = 137, + recoveryVideoItags: Set = emptySet(), + ) = SabrPlaybackTarget( + videoId = "video", + requestScope = StreamRequestScope("server", "account", baseUrl), + videoItag = videoItag, + audioItag = 140, + audioTrackId = "en.0", + recoveryVideoItags = recoveryVideoItags, + ) + + protected fun binding() = SabrPlaybackBinding("session", 0, 137, 140, "en.0") + + protected fun enqueueCreate( + startTimeMs: Long = 0, + ready: Boolean = false, + sessionId: String = "session", + generation: Long = 0, + videoItag: Int = 137, + ) { + server.enqueue( + jsonResponse( + """{"sessionId":"$sessionId","videoId":"video","manifestUrl":${if (ready) "\"/sabr/playback/$sessionId/manifest\"" else "null"},"videoItag":$videoItag,"audioItag":140,"audioTrackId":"en.0","startTimeMs":$startTimeMs,"generation":$generation,"ready":$ready,"status":"${if (ready) "ready" else "preparing"}","retryAfterMs":${if (ready) "null" else "250"}}""", + if (ready) 200 else 202, + ), + ) + } + + protected fun enqueuePosition( + playerTimeMs: Long, + sessionId: String = "session", + generation: Long = 0, + ) { + server.enqueue( + jsonResponse( + """{"sessionId":"$sessionId","generation":$generation,"playerTimeMs":$playerTimeMs,"readerHeadMs":0,"readerTailMs":0,"bufferedEdgeMs":70000}""", + ), + ) + } + + protected fun enqueuePrefetch( + ready: Boolean, + sessionId: String = "session", + generation: Long = 0, + ) { + server.enqueue( + jsonResponse( + """{"sessionId":"$sessionId","generation":$generation,"ready":$ready,"retryAfterMs":${if (ready) "null" else "250"},"status":"${if (ready) "ready" else "preparing"}","bufferedEdgeMs":70000}""", + if (ready) 200 else 202, + ), + ) + } + + protected fun enqueueWindow( + startTimeMs: Long, + endOfStream: Boolean, + segmentStartMs: Long = 40_000, + audioInitUrl: String = "/api/sabr/playback/session/140/init?generation=0", + sessionId: String = "session", + generation: Long = 0, + videoItag: Int = 137, + ) { + val audioEnd = segmentStartMs + 30_000 + val videoEnd = segmentStartMs + 30_000 + val resolvedAudioInitUrl = if (audioInitUrl.contains("media.example")) { + audioInitUrl + } else { + "/api/sabr/playback/$sessionId/140/init?generation=$generation" + } + server.enqueue( + jsonResponse( + """{"sessionId":"$sessionId","generation":$generation,"ready":true,"retryAfterMs":null,"durationMs":120000,"endOfStream":$endOfStream,"startTimeMs":$startTimeMs,"audio":{"mime":"audio/mp4","initUrl":"$resolvedAudioInitUrl","segments":[{"url":"/api/sabr/playback/$sessionId/140/segment/5?generation=$generation","startMs":$segmentStartMs,"durationMs":${audioEnd - segmentStartMs}}]},"video":{"mime":"video/mp4","initUrl":"/api/sabr/playback/$sessionId/$videoItag/init?generation=$generation","segments":[{"url":"/api/sabr/playback/$sessionId/$videoItag/segment/5?generation=$generation","startMs":$segmentStartMs,"durationMs":${videoEnd - segmentStartMs}}]}}""", + ), + ) + } + + protected fun jsonResponse(body: String, code: Int = 200) = MockResponse() + .setResponseCode(code) + .setHeader("Content-Type", "application/json") + .setBody(body) +} From 9358ba5197c02a02c9a70f4b773f16c749676057 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 23 Oct 2026 21:46:27 +0200 Subject: [PATCH 2/6] perf: throttle SABR playback window refreshes --- .../dev/typetype/player/PlaybackWindow.kt | 2 + .../player/PlaybackWindowRefreshCadence.kt | 35 ++++++++ .../typetype/player/TypeTypeMediaPeriod.kt | 41 +++++++--- .../dev/typetype/player/PlaybackWindowTest.kt | 81 ++++++++++++++++++- 4 files changed, 146 insertions(+), 13 deletions(-) create mode 100644 player/src/main/java/dev/typetype/player/PlaybackWindowRefreshCadence.kt diff --git a/player/src/main/java/dev/typetype/player/PlaybackWindow.kt b/player/src/main/java/dev/typetype/player/PlaybackWindow.kt index 5b86b6de..f2f879e5 100644 --- a/player/src/main/java/dev/typetype/player/PlaybackWindow.kt +++ b/player/src/main/java/dev/typetype/player/PlaybackWindow.kt @@ -8,9 +8,11 @@ data class PlaybackWindow( val audio: PlaybackTrack, val video: PlaybackTrack?, val live: PlaybackLiveWindow? = null, + val bufferGoalUs: Long = 10_000_000L, ) { init { require(generation >= 0L) + require(bufferGoalUs > 0L) require(durationUs > 0L) require(startPositionUs >= 0L) require(audio.kind == PlaybackTrackKind.Audio) diff --git a/player/src/main/java/dev/typetype/player/PlaybackWindowRefreshCadence.kt b/player/src/main/java/dev/typetype/player/PlaybackWindowRefreshCadence.kt new file mode 100644 index 00000000..c9c9a27a --- /dev/null +++ b/player/src/main/java/dev/typetype/player/PlaybackWindowRefreshCadence.kt @@ -0,0 +1,35 @@ +package dev.typetype.player + +internal class PlaybackWindowRefreshCadence( + private val initialPollIntervalMs: Long = 500L, + private val maximumPollIntervalMs: Long = 2_000L, + private val progressThresholdUs: Long = 1_000_000L, +) { + var pollIntervalMs: Long = initialPollIntervalMs + private set + + fun reset() { + pollIntervalMs = initialPollIntervalMs + } + + fun onWindowAvailable( + previousAheadUs: Long, + nextAheadUs: Long, + refreshThresholdUs: Long, + ): Long { + val progressUs = nextAheadUs - previousAheadUs + pollIntervalMs = if ( + nextAheadUs >= refreshThresholdUs || progressUs >= progressThresholdUs + ) { + initialPollIntervalMs + } else { + (pollIntervalMs * 2).coerceAtMost(maximumPollIntervalMs) + } + return pollIntervalMs + } +} + +internal fun PlaybackWindow.availableWindowAheadUs(positionUs: Long): Long { + val availableEndUs = listOfNotNull(audio.endPositionUs, video?.endPositionUs).min() + return (availableEndUs - positionUs).coerceAtLeast(0L) +} diff --git a/player/src/main/java/dev/typetype/player/TypeTypeMediaPeriod.kt b/player/src/main/java/dev/typetype/player/TypeTypeMediaPeriod.kt index 193961b2..f2862f66 100644 --- a/player/src/main/java/dev/typetype/player/TypeTypeMediaPeriod.kt +++ b/player/src/main/java/dev/typetype/player/TypeTypeMediaPeriod.kt @@ -48,6 +48,9 @@ internal class TypeTypeMediaPeriod( private var loader: SequenceableLoader = DefaultCompositeSequenceableLoaderFactory().empty() private var callback: MediaPeriod.Callback? = null private var released = false + private val refreshCadence = PlaybackWindowRefreshCadence() + private var availableWindowAheadUs = + initialWindow.availableWindowAheadUs(playbackPositionUs()) private val refreshPlaybackWindow = object : Runnable { override fun run() { pollPlaybackWindow() @@ -106,6 +109,10 @@ internal class TypeTypeMediaPeriod( override fun readDiscontinuity(): Long = C.TIME_UNSET override fun seekToUs(positionUs: Long): Long { + refreshCadence.reset() + availableWindowAheadUs = coordinator.window + ?.availableWindowAheadUs(positionUs) + ?: 0L val bufferedStartUs = retainedPlaybackStartUs( streams.map { it.chunkSource.bufferedStartPositionUs }, ) @@ -145,6 +152,15 @@ internal class TypeTypeMediaPeriod( override fun onWindowAvailable(window: PlaybackWindow) { try { + val nextAvailableAheadUs = window.availableWindowAheadUs(playbackPositionUs()) + if (window.live?.active != true) { + refreshCadence.onWindowAvailable( + previousAheadUs = availableWindowAheadUs, + nextAheadUs = nextAvailableAheadUs, + refreshThresholdUs = playbackWindowRefreshThresholdUs(window.bufferGoalUs), + ) + } + availableWindowAheadUs = nextAvailableAheadUs streams.forEach { it.chunkSource.update(window) } callback?.onContinueLoadingRequested(this) } catch (failure: IOException) { @@ -211,6 +227,7 @@ internal class TypeTypeMediaPeriod( playbackPositionUs = positionUs, bufferedEndUs = bufferedEndUs, availableEndUs = availableEndUs, + bufferGoalUs = window.bufferGoalUs, endOfStream = window.endOfStream, activeLive = window.live?.active == true, ) @@ -223,7 +240,7 @@ internal class TypeTypeMediaPeriod( if (coordinator.window?.live?.active == true) { LIVE_WINDOW_POLL_INTERVAL_MS } else { - WINDOW_POLL_INTERVAL_MS + refreshCadence.pollIntervalMs } private fun requestWindow(playbackPositionUs: Long, bufferedEndUs: Long) { @@ -248,9 +265,6 @@ internal class TypeTypeMediaPeriod( coordinator.load(playbackPositionUs, ranges) } - private companion object { - const val WINDOW_POLL_INTERVAL_MS = 500L - } } internal fun shouldRefreshPlaybackWindow( @@ -258,20 +272,26 @@ internal fun shouldRefreshPlaybackWindow( bufferedEndUs: Long, availableEndUs: Long, endOfStream: Boolean, + bufferGoalUs: Long = DEFAULT_VOD_BUFFER_GOAL_US, activeLive: Boolean = false, ): Boolean { if (endOfStream) return false - val refreshThresholdUs = if (activeLive) { - LIVE_WINDOW_REFRESH_THRESHOLD_US - } else { - WINDOW_REFRESH_THRESHOLD_US - } + val refreshThresholdUs = playbackWindowRefreshThresholdUs(bufferGoalUs, activeLive) val bufferedAheadUs = bufferedEndUs - playbackPositionUs val availableAheadUs = availableEndUs - playbackPositionUs return bufferedAheadUs < refreshThresholdUs && availableAheadUs < refreshThresholdUs } +internal fun playbackWindowRefreshThresholdUs( + bufferGoalUs: Long, + activeLive: Boolean = false, +): Long = if (activeLive) { + LIVE_WINDOW_REFRESH_THRESHOLD_US +} else { + (bufferGoalUs / VOD_REFRESH_WATERMARK_DIVISOR).coerceAtLeast(1L) +} + internal fun playbackBufferStartUs( playbackPositionUs: Long, retainedStartPositionUs: Long, @@ -302,6 +322,7 @@ internal fun retainedPlaybackStartUs(trackStartsUs: List): Long = private const val LOCAL_SEEK_END_MARGIN_US = 250_000L private const val BACK_BUFFER_US = 30_000_000L -private const val WINDOW_REFRESH_THRESHOLD_US = 20_000_000L +private const val DEFAULT_VOD_BUFFER_GOAL_US = 10_000_000L +private const val VOD_REFRESH_WATERMARK_DIVISOR = 2L private const val LIVE_WINDOW_REFRESH_THRESHOLD_US = 5_000_000L private const val LIVE_WINDOW_POLL_INTERVAL_MS = 250L diff --git a/player/src/test/java/dev/typetype/player/PlaybackWindowTest.kt b/player/src/test/java/dev/typetype/player/PlaybackWindowTest.kt index 92895d8f..7a422737 100644 --- a/player/src/test/java/dev/typetype/player/PlaybackWindowTest.kt +++ b/player/src/test/java/dev/typetype/player/PlaybackWindowTest.kt @@ -48,12 +48,87 @@ class PlaybackWindowTest { @Test fun playbackWindowRefreshWaitsUntilBufferAndAvailableMediaAreLow() { - assertFalse(shouldRefreshPlaybackWindow(10_000_000, 30_000_000, 30_000_000, false)) - assertFalse(shouldRefreshPlaybackWindow(10_000_000, 29_999_999, 40_000_000, false)) - assertTrue(shouldRefreshPlaybackWindow(10_000_000, 29_999_999, 29_999_999, false)) + assertFalse( + shouldRefreshPlaybackWindow( + playbackPositionUs = 10_000_000, + bufferedEndUs = 15_000_000, + availableEndUs = 15_000_000, + endOfStream = false, + bufferGoalUs = 10_000_000, + ), + ) + assertFalse( + shouldRefreshPlaybackWindow( + playbackPositionUs = 10_000_000, + bufferedEndUs = 14_999_999, + availableEndUs = 20_000_000, + endOfStream = false, + bufferGoalUs = 10_000_000, + ), + ) + assertTrue( + shouldRefreshPlaybackWindow( + playbackPositionUs = 10_000_000, + bufferedEndUs = 14_999_999, + availableEndUs = 14_999_999, + endOfStream = false, + bufferGoalUs = 10_000_000, + ), + ) assertFalse(shouldRefreshPlaybackWindow(30_000_000, 30_000_000, 30_000_000, true)) } + @Test + fun satisfiedBufferGoalDoesNotTriggerRefreshOnEveryPoll() { + val windowEndUs = 10_000_000L + + assertFalse(shouldRefreshPlaybackWindow(0L, windowEndUs, windowEndUs, false, 10_000_000L)) + assertFalse(shouldRefreshPlaybackWindow(4_999_999L, windowEndUs, windowEndUs, false, 10_000_000L)) + assertTrue(shouldRefreshPlaybackWindow(5_000_001L, windowEndUs, windowEndUs, false, 10_000_000L)) + } + + @Test + fun initialBufferGoalUsesItsOwnRefreshWatermark() { + assertFalse(shouldRefreshPlaybackWindow(0L, 2_500_000L, 2_500_000L, false, 2_500_000L)) + assertTrue(shouldRefreshPlaybackWindow(0L, 1_249_999L, 1_249_999L, false, 2_500_000L)) + } + + @Test + fun stagnantWindowsBackOffRefreshPollingAndRecoverWhenProgressResumes() { + val cadence = PlaybackWindowRefreshCadence() + + assertEquals(1_000L, cadence.onWindowAvailable(1_000_000L, 1_000_000L, 5_000_000L)) + assertEquals(2_000L, cadence.onWindowAvailable(1_000_000L, 1_000_000L, 5_000_000L)) + assertEquals(2_000L, cadence.onWindowAvailable(1_000_000L, 1_000_000L, 5_000_000L)) + assertEquals(500L, cadence.onWindowAvailable(1_000_000L, 2_000_000L, 5_000_000L)) + assertEquals(500L, cadence.onWindowAvailable(2_000_000L, 5_000_000L, 5_000_000L)) + } + + @Test + fun availableWindowAheadIsMeasuredFromTheCurrentPlaybackPosition() { + val window = PlaybackWindow( + generation = 0L, + durationUs = 60_000_000L, + startPositionUs = 10_000_000L, + endOfStream = false, + audio = track(PlaybackTrackKind.Audio, "140", 30_000_000L), + video = track(PlaybackTrackKind.Video, "137", 30_000_000L), + ) + + assertEquals(20_000_000L, window.availableWindowAheadUs(20_000_000L)) + } + + @Test + fun seekResetsTheRefreshPollingBackoff() { + val cadence = PlaybackWindowRefreshCadence() + cadence.onWindowAvailable(1_000_000L, 1_000_000L, 5_000_000L) + cadence.onWindowAvailable(1_000_000L, 1_000_000L, 5_000_000L) + + cadence.reset() + + assertEquals(500L, cadence.pollIntervalMs) + } + @Test fun activeLiveRefreshUsesTheSmallerLiveBufferGoal() { assertFalse( From eb48e4712ef0d2deebf0035562af766e8a259406 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 23 Oct 2026 21:46:36 +0200 Subject: [PATCH 3/6] fix: bound SABR segment retry duration --- .../services/SabrLoadErrorHandlingPolicy.kt | 10 +++-- .../services/SabrSegmentRetryBudget.kt | 32 ++++++++++++++ .../SabrLoadErrorHandlingPolicyTest.kt | 6 +-- .../services/SabrSegmentRetryBudgetTest.kt | 42 +++++++++++++++++++ 4 files changed, 83 insertions(+), 7 deletions(-) create mode 100644 app/src/main/java/dev/typetype/android/services/SabrSegmentRetryBudget.kt create mode 100644 app/src/test/java/dev/typetype/android/services/SabrSegmentRetryBudgetTest.kt diff --git a/app/src/main/java/dev/typetype/android/services/SabrLoadErrorHandlingPolicy.kt b/app/src/main/java/dev/typetype/android/services/SabrLoadErrorHandlingPolicy.kt index c6e10900..3d5c5c7e 100644 --- a/app/src/main/java/dev/typetype/android/services/SabrLoadErrorHandlingPolicy.kt +++ b/app/src/main/java/dev/typetype/android/services/SabrLoadErrorHandlingPolicy.kt @@ -18,6 +18,7 @@ internal class SabrLoadErrorHandlingPolicy( network: PlaybackNetworkObserver = AlwaysAvailablePlaybackNetworkObserver, ) : LoadErrorHandlingPolicy { private val transportRetryBudget = PlaybackLoadRetryBudget(network) + private val segmentRetryBudget = SabrSegmentRetryBudget() override fun getFallbackSelectionFor( fallbackOptions: LoadErrorHandlingPolicy.FallbackOptions, @@ -34,10 +35,11 @@ internal class SabrLoadErrorHandlingPolicy( response.responseCode == SABR_RETRY_RESPONSE_CODE && response.dataSpec.uri.pathSegments.isSabrPlaybackPayloadPath() ) { - return sabrSegmentRetryDelayMs( + return segmentRetryBudget.retryDelayMs( + loadTaskId = loadErrorInfo.loadEventInfo.loadTaskId, errorCount = loadErrorInfo.errorCount, requestedDelayMs = response.responseBody.sabrResponseRetryAfterMs(), - ) + ) ?: C.TIME_UNSET } if (isTransientHttpStatus(response.responseCode)) { return transientHttpRetryDelayMs( @@ -60,6 +62,7 @@ internal class SabrLoadErrorHandlingPolicy( override fun onLoadTaskConcluded(loadTaskId: Long) { transportRetryBudget.conclude(loadTaskId) + segmentRetryBudget.conclude(loadTaskId) delegate.onLoadTaskConcluded(loadTaskId) } } @@ -102,7 +105,7 @@ private fun Throwable.hasFailureCode(code: String): Boolean { internal fun sabrSegmentRetryDelayMs(errorCount: Int, requestedDelayMs: Long?): Long = boundedRetryDelayMs( errorCount = errorCount, - maximumRetries = MAX_SEGMENT_RETRIES, + maximumRetries = MAX_SABR_SEGMENT_RETRIES, requestedDelayMs = requestedDelayMs, defaultDelayMs = DEFAULT_SABR_RETRY_MS, minimumDelayMs = MIN_SABR_RETRY_MS, @@ -131,5 +134,4 @@ private const val DEFAULT_SABR_RETRY_MS = 250L private const val MIN_SABR_RETRY_MS = 100L private const val MAX_SEGMENT_RETRY_DELAY_MS = 3_000L private const val MAX_CAUSE_DEPTH = 8 -private const val MAX_SEGMENT_RETRIES = 59 private const val MAX_MEDIA_LOAD_RETRIES = 24 diff --git a/app/src/main/java/dev/typetype/android/services/SabrSegmentRetryBudget.kt b/app/src/main/java/dev/typetype/android/services/SabrSegmentRetryBudget.kt new file mode 100644 index 00000000..77746d8c --- /dev/null +++ b/app/src/main/java/dev/typetype/android/services/SabrSegmentRetryBudget.kt @@ -0,0 +1,32 @@ +package dev.typetype.android.services + +import android.os.SystemClock + +internal class SabrSegmentRetryBudget( + private val elapsedRealtimeMs: () -> Long = SystemClock::elapsedRealtime, + private val maximumRetries: Int = MAX_SABR_SEGMENT_RETRIES, + private val maximumRetryDurationMs: Long = MAX_SEGMENT_RETRY_DURATION_MS, +) { + private val firstFailures = mutableMapOf() + + fun retryDelayMs( + loadTaskId: Long, + errorCount: Int, + requestedDelayMs: Long?, + ): Long? = synchronized(firstFailures) { + if (errorCount > maximumRetries) return null + val nowMs = elapsedRealtimeMs() + val firstFailureMs = firstFailures.getOrPut(loadTaskId) { nowMs } + val retryDelayMs = sabrSegmentRetryDelayMs(errorCount, requestedDelayMs) + retryDelayMs.takeIf { nowMs - firstFailureMs + it <= maximumRetryDurationMs } + } + + fun conclude(loadTaskId: Long) { + synchronized(firstFailures) { + firstFailures.remove(loadTaskId) + } + } +} + +internal const val MAX_SABR_SEGMENT_RETRIES = 8 +private const val MAX_SEGMENT_RETRY_DURATION_MS = 20_000L diff --git a/app/src/test/java/dev/typetype/android/services/SabrLoadErrorHandlingPolicyTest.kt b/app/src/test/java/dev/typetype/android/services/SabrLoadErrorHandlingPolicyTest.kt index 09185f2c..fd25fdd8 100644 --- a/app/src/test/java/dev/typetype/android/services/SabrLoadErrorHandlingPolicyTest.kt +++ b/app/src/test/java/dev/typetype/android/services/SabrLoadErrorHandlingPolicyTest.kt @@ -8,10 +8,10 @@ import org.junit.Test class SabrLoadErrorHandlingPolicyTest { @Test - fun `segment readiness polling is bounded to sixty requests`() { + fun `segment readiness polling is bounded to nine requests`() { assertEquals(250L, sabrSegmentRetryDelayMs(1, 250L)) - assertEquals(250L, sabrSegmentRetryDelayMs(59, 250L)) - assertEquals(C.TIME_UNSET, sabrSegmentRetryDelayMs(60, 250L)) + assertEquals(250L, sabrSegmentRetryDelayMs(8, 250L)) + assertEquals(C.TIME_UNSET, sabrSegmentRetryDelayMs(9, 250L)) } @Test diff --git a/app/src/test/java/dev/typetype/android/services/SabrSegmentRetryBudgetTest.kt b/app/src/test/java/dev/typetype/android/services/SabrSegmentRetryBudgetTest.kt new file mode 100644 index 00000000..34bbed90 --- /dev/null +++ b/app/src/test/java/dev/typetype/android/services/SabrSegmentRetryBudgetTest.kt @@ -0,0 +1,42 @@ +package dev.typetype.android.services + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class SabrSegmentRetryBudgetTest { + @Test + fun retryBudgetAllowsEightRetriesForOneLoadTask() { + var nowMs = 0L + val budget = SabrSegmentRetryBudget(elapsedRealtimeMs = { nowMs }) + + for (errorCount in 1..8) { + assertEquals(250L, budget.retryDelayMs(10L, errorCount, 250L)) + nowMs += 100L + } + assertNull(budget.retryDelayMs(10L, 9, 250L)) + } + + @Test + fun retryBudgetStopsWhenServerRetryDelaysExceedTwentySeconds() { + var nowMs = 0L + val budget = SabrSegmentRetryBudget(elapsedRealtimeMs = { nowMs }) + + assertEquals(250L, budget.retryDelayMs(10L, 1, 250L)) + nowMs = 19_800L + + assertNull(budget.retryDelayMs(10L, 2, 250L)) + } + + @Test + fun completedLoadsDoNotShareRetryTime() { + var nowMs = 0L + val budget = SabrSegmentRetryBudget(elapsedRealtimeMs = { nowMs }) + + assertEquals(250L, budget.retryDelayMs(10L, 1, 250L)) + nowMs = 50_000L + budget.conclude(10L) + + assertEquals(250L, budget.retryDelayMs(11L, 1, 250L)) + } +} From a928c5e2d58791b265bd7dbd4cafc4c1b3dd3d51 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 23 Oct 2026 21:46:50 +0200 Subject: [PATCH 4/6] test: verify requested SABR playback windows --- .../services/SabrPlaybackWindowLoaderTest.kt | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/app/src/test/java/dev/typetype/android/services/SabrPlaybackWindowLoaderTest.kt b/app/src/test/java/dev/typetype/android/services/SabrPlaybackWindowLoaderTest.kt index f04bf149..f759f52e 100644 --- a/app/src/test/java/dev/typetype/android/services/SabrPlaybackWindowLoaderTest.kt +++ b/app/src/test/java/dev/typetype/android/services/SabrPlaybackWindowLoaderTest.kt @@ -15,6 +15,23 @@ import org.junit.Assert.assertTrue import org.junit.Test class SabrPlaybackWindowLoaderTest { + @Test + fun requestedBufferGoalIsPassedToThePlayerWindow() { + val window = SabrPlaybackSession( + sessionId = "session", + manifestUrl = "https://example.test/manifest", + generation = 0, + videoItag = 137, + audioItag = 140, + audioTrackId = null, + requestedBufferGoalMs = 10_000L, + audioWindow = windowTrack(140, "audio/mp4"), + videoWindow = windowTrack(137, "video/mp4"), + ).toPlayerWindow() + + assertEquals(10_000_000L, window.bufferGoalUs) + } + @Test fun terminalWindowRetainsTheLastValidatedTracks() { val previous = PlaybackWindow( From 2f829e51c25803d02237ce329c3d2c5f4c04e1bd Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 23 Oct 2026 21:47:01 +0200 Subject: [PATCH 5/6] fix: recover playback after SABR session loss --- .../components/PlaybackFailureOverlay.kt | 1 + .../player/error/PlaybackErrorClassifier.kt | 3 ++ .../services/SabrLivePlaybackFollower.kt | 35 +++++++++++++++++++ .../services/SabrPlaybackFailureRecovery.kt | 28 ++++++++++++++- .../services/SabrPlaybackServiceBridge.kt | 31 ++++++---------- app/src/main/res/values/strings.xml | 3 +- .../error/PlaybackErrorClassifierTest.kt | 10 ++++++ .../SabrPlaybackFailureRecoveryTest.kt | 20 +++++++++-- 8 files changed, 106 insertions(+), 25 deletions(-) create mode 100644 app/src/main/java/dev/typetype/android/services/SabrLivePlaybackFollower.kt diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/PlaybackFailureOverlay.kt b/app/src/main/java/dev/typetype/android/feature/player/components/PlaybackFailureOverlay.kt index 6ba788ed..e95e305a 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/components/PlaybackFailureOverlay.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/components/PlaybackFailureOverlay.kt @@ -98,6 +98,7 @@ private fun PlaybackFailureKind.messageResource(): Int = when (this) { PlaybackFailureKind.YouTubeSessionRequired -> R.string.state_youtube_session_required_message PlaybackFailureKind.SabrServerContract -> R.string.state_sabr_server_contract_failed PlaybackFailureKind.SabrRecoveryExhausted -> R.string.state_sabr_recovery_exhausted + PlaybackFailureKind.ServerUnavailable -> R.string.state_playback_server_unavailable PlaybackFailureKind.MediaDelivery -> R.string.state_media_delivery_failed PlaybackFailureKind.Network -> R.string.state_playback_network_failed PlaybackFailureKind.UnsupportedFormat -> R.string.state_playback_format_unsupported diff --git a/app/src/main/java/dev/typetype/android/feature/player/error/PlaybackErrorClassifier.kt b/app/src/main/java/dev/typetype/android/feature/player/error/PlaybackErrorClassifier.kt index bd8c08f6..8dd38f01 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/error/PlaybackErrorClassifier.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/error/PlaybackErrorClassifier.kt @@ -13,6 +13,7 @@ enum class PlaybackFailureKind { YouTubeSessionRequired, SabrServerContract, SabrRecoveryExhausted, + ServerUnavailable, MediaDelivery, Network, UnsupportedFormat, @@ -71,6 +72,7 @@ internal fun classifyHttpPlaybackFailure( statusCode == 401 -> PlaybackFailureKind.AuthenticationExpired statusCode == 404 || statusCode == 410 -> PlaybackFailureKind.PlaybackSessionExpired statusCode == 409 -> PlaybackFailureKind.PlaybackGenerationChanged + statusCode in TRANSIENT_SERVER_STATUS_CODES -> PlaybackFailureKind.ServerUnavailable isYouTubeSessionRequiredMessage(responseBody) -> PlaybackFailureKind.YouTubeSessionRequired else -> PlaybackFailureKind.MediaDelivery } @@ -116,6 +118,7 @@ private fun Throwable.findHttpFailure(): HttpDataSource.InvalidResponseCodeExcep private const val MAX_RESPONSE_BODY_BYTES = 8_192 private const val MAX_CAUSE_DEPTH = 8 +private val TRANSIENT_SERVER_STATUS_CODES = setOf(502, 503, 504) private const val REQUEST_ID_HEADER = "X-Request-ID" private const val SABR_CONTRACT_FAILURE_CODE = "youtube_sabr_contract_mismatch" private const val SABR_RECOVERY_EXHAUSTED_FAILURE_CODE = "youtube_sabr_recovery_exhausted" diff --git a/app/src/main/java/dev/typetype/android/services/SabrLivePlaybackFollower.kt b/app/src/main/java/dev/typetype/android/services/SabrLivePlaybackFollower.kt new file mode 100644 index 00000000..68284caa --- /dev/null +++ b/app/src/main/java/dev/typetype/android/services/SabrLivePlaybackFollower.kt @@ -0,0 +1,35 @@ +package dev.typetype.android.services + +import android.os.SystemClock +import androidx.media3.common.Player + +internal class SabrLivePlaybackFollower(private val player: Player) { + private val follower = LivePlaybackFollower() + + fun transition(mediaId: String?) { + follower.transition(mediaId) + } + + fun observeSeek(positionMs: Long) { + follower.observeSeek( + currentMediaId = player.currentMediaItem?.mediaId, + positionMs = positionMs, + targetMs = player.currentLiveTargetPositionMs(), + ) + } + + fun followEdge() { + val mediaId = player.currentMediaItem?.mediaId + val positionMs = player.currentPosition + val targetMs = player.currentLiveTargetPositionMs() + follower.initialize(mediaId, positionMs, targetMs) + follower.nextTarget( + currentMediaId = mediaId, + positionMs = positionMs, + targetMs = targetMs, + playing = player.isPlaying, + busy = player.playbackState != Player.STATE_READY || player.isLoading, + nowMs = SystemClock.elapsedRealtime(), + )?.let(player::seekTo) + } +} diff --git a/app/src/main/java/dev/typetype/android/services/SabrPlaybackFailureRecovery.kt b/app/src/main/java/dev/typetype/android/services/SabrPlaybackFailureRecovery.kt index 9fb81172..19fccddb 100644 --- a/app/src/main/java/dev/typetype/android/services/SabrPlaybackFailureRecovery.kt +++ b/app/src/main/java/dev/typetype/android/services/SabrPlaybackFailureRecovery.kt @@ -131,6 +131,30 @@ internal fun Throwable.isRecoverableSabrSessionFailure(): Boolean { return false } +internal fun Throwable.isRecoverableSabrInstanceInterruption(): Boolean { + var current: Throwable? = this + repeat(MAX_CAUSE_DEPTH) { + val coded = current as? CodedFailure + if ( + coded?.failureCode == MISSING_SABR_SESSION_CODE || + coded?.statusCode in TRANSIENT_SERVER_STATUS_CODES + ) { + return true + } + val response = current as? HttpDataSource.InvalidResponseCodeException + if (response?.isTransientSabrPayloadFailure() == true) { + return true + } + current = current?.cause?.takeUnless { it === current } + } + return false +} + +@androidx.annotation.OptIn(markerClass = [UnstableApi::class]) +private fun HttpDataSource.InvalidResponseCodeException.isTransientSabrPayloadFailure(): Boolean = + responseCode in TRANSIENT_SERVER_STATUS_CODES && + dataSpec.uri.pathSegments.isSabrPlaybackPayloadPath() + private fun Throwable.sabrPlaybackRecoveryExhaustedFailure(): SabrPlaybackRecoveryExhaustedException? { var current: Throwable? = this @@ -180,11 +204,13 @@ internal fun Throwable.sabrSessionReplacementFailure(): internal fun Int.isRecoverableSabrSessionStatus(): Boolean = this in RECOVERABLE_SESSION_STATUS_CODES -private val RECOVERABLE_SESSION_STATUS_CODES = setOf(202, 404, 409, 410) +private val RECOVERABLE_SESSION_STATUS_CODES = setOf(202, 404, 409, 410, 502, 503, 504) +private val TRANSIENT_SERVER_STATUS_CODES = setOf(502, 503, 504) private val RECOVERABLE_WINDOW_ACTIONS = setOf( "retry_fresh_session", "retry_fresh_session_lower_video_itag", ) +private const val MISSING_SABR_SESSION_CODE = "sabr_playback_not_found" private const val MAX_SESSION_RECOVERIES = 2 private const val MAX_CAUSE_DEPTH = 8 private const val STABLE_PLAYBACK_RESET_MS = 30_000L diff --git a/app/src/main/java/dev/typetype/android/services/SabrPlaybackServiceBridge.kt b/app/src/main/java/dev/typetype/android/services/SabrPlaybackServiceBridge.kt index ff0aad59..a18e6792 100644 --- a/app/src/main/java/dev/typetype/android/services/SabrPlaybackServiceBridge.kt +++ b/app/src/main/java/dev/typetype/android/services/SabrPlaybackServiceBridge.kt @@ -38,7 +38,7 @@ internal class SabrPlaybackServiceBridge( ) private val recoveryGate = SabrPlaybackRecoveryGate() private val networkRecoveryGate = PlaybackNetworkRecoveryGate() - private val liveFollower = LivePlaybackFollower() + private val liveFollower = SabrLivePlaybackFollower(player) private var activeBinding: SabrPlaybackBinding? = null private var networkRetryJob: Job? = null @@ -90,7 +90,7 @@ internal class SabrPlaybackServiceBridge( nowMs = SystemClock.elapsedRealtime(), ) } - followLiveEdge() + liveFollower.followEdge() delay(PLAYBACK_CLOCK_INTERVAL_MS) } } @@ -127,6 +127,13 @@ internal class SabrPlaybackServiceBridge( } override fun onPlayerError(error: PlaybackException) { + if (error.isRecoverableSabrInstanceInterruption()) { + val mediaId = player.currentMediaItem?.mediaId ?: return + scheduleNetworkRecovery( + networkRecoveryGate.failed(mediaId, networkMonitor.snapshot()), + ) + return + } if (!error.isRecoverableSabrSessionFailure()) { if (error.isNetworkPlaybackFailure()) { val mediaId = player.currentMediaItem?.mediaId ?: return @@ -195,11 +202,7 @@ internal class SabrPlaybackServiceBridge( reason: Int, ) { if (reason != Player.DISCONTINUITY_REASON_SEEK) return - liveFollower.observeSeek( - currentMediaId = player.currentMediaItem?.mediaId, - positionMs = newPosition.positionMs, - targetMs = player.currentLiveTargetPositionMs(), - ) + liveFollower.observeSeek(newPosition.positionMs) } override fun onRecoveryRequired( @@ -299,20 +302,6 @@ internal class SabrPlaybackServiceBridge( player.playWhenReady = playWhenReady } - private fun followLiveEdge() { - val mediaId = player.currentMediaItem?.mediaId - val targetMs = player.currentLiveTargetPositionMs() - liveFollower.initialize(mediaId, player.currentPosition, targetMs) - liveFollower.nextTarget( - currentMediaId = mediaId, - positionMs = player.currentPosition, - targetMs = targetMs, - playing = player.isPlaying, - busy = player.playbackState != Player.STATE_READY || player.isLoading, - nowMs = SystemClock.elapsedRealtime(), - )?.let(player::seekTo) - } - override fun close() { player.removeListener(this) recoveryDispatcher.setListener(null) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0f429046..29f8c02d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1013,8 +1013,9 @@ This instance returned an invalid SABR playback window. Update the server to continue playback. The server stopped providing media for this video. TypeType tried lower compatible qualities, but playback could not continue. This instance could not deliver the media. Retry to request a fresh source. - The playback session expired or was lost. Retry to request a fresh stream. + The TypeType instance no longer has this playback session. TypeType is reconnecting from your current position. Choose Retry to request a fresh stream now. The playback selection changed. Retry to reload the current stream. + The TypeType instance is temporarily unavailable. TypeType will keep retrying and resume from your current position when it is back. The connection was interrupted while loading the media. This device could not decode the selected media format. Android blocked an insecure media connection. diff --git a/app/src/test/java/dev/typetype/android/feature/player/error/PlaybackErrorClassifierTest.kt b/app/src/test/java/dev/typetype/android/feature/player/error/PlaybackErrorClassifierTest.kt index 9924138e..714bcb5b 100644 --- a/app/src/test/java/dev/typetype/android/feature/player/error/PlaybackErrorClassifierTest.kt +++ b/app/src/test/java/dev/typetype/android/feature/player/error/PlaybackErrorClassifierTest.kt @@ -49,6 +49,16 @@ class PlaybackErrorClassifierTest { assertEquals(PlaybackFailureKind.PlaybackGenerationChanged, result) } + @Test + fun `classifies gateway failures as temporary instance outage`() { + listOf(502, 503, 504).forEach { status -> + assertEquals( + PlaybackFailureKind.ServerUnavailable, + classifyHttpPlaybackFailure(responseBody = null, statusCode = status), + ) + } + } + @Test fun `reads a safe request id from response headers`() { val result = requestIdFromHeaders(mapOf("x-request-id" to listOf("req-123:retry"))) diff --git a/app/src/test/java/dev/typetype/android/services/SabrPlaybackFailureRecoveryTest.kt b/app/src/test/java/dev/typetype/android/services/SabrPlaybackFailureRecoveryTest.kt index 68adc797..d0aff067 100644 --- a/app/src/test/java/dev/typetype/android/services/SabrPlaybackFailureRecoveryTest.kt +++ b/app/src/test/java/dev/typetype/android/services/SabrPlaybackFailureRecoveryTest.kt @@ -15,14 +15,26 @@ import org.junit.Test class SabrPlaybackFailureRecoveryTest { @Test fun `only session recovery HTTP statuses are accepted`() { - listOf(202, 404, 409, 410).forEach { status -> + listOf(202, 404, 409, 410, 502, 503, 504).forEach { status -> assertTrue(status.isRecoverableSabrSessionStatus()) } - listOf(400, 401, 403, 429, 500, 503).forEach { status -> + listOf(400, 401, 403, 429, 500).forEach { status -> assertFalse(status.isRecoverableSabrSessionStatus()) } } + @Test + fun `gateway failures and missing SABR session use reconnect recovery`() { + listOf(502, 503, 504).forEach { status -> + assertTrue(serverFailure(status).isRecoverableSabrInstanceInterruption()) + } + assertTrue( + serverFailure(404, "sabr_playback_not_found") + .isRecoverableSabrInstanceInterruption(), + ) + assertFalse(serverFailure(404, "video_not_found").isRecoverableSabrInstanceInterruption()) + } + @Test fun `automatic recovery is deduplicated and bounded twice per active media`() { val gate = SabrPlaybackRecoveryGate() @@ -221,6 +233,10 @@ class SabrPlaybackFailureRecoveryTest { audioTrackId = "en.0", ) + private fun serverFailure(status: Int, code: String? = null) = ServerResponseException( + ServerError("server failure", code, status), + ) + private fun exhaustedGate() = SabrPlaybackRecoveryGate().apply { assertEquals(SabrPlaybackRecoveryDecision.Recover, begin("video", "session-1")) assertTrue(takeAttempt()) From 40121ec21d4f80692c3f414341320cdcad685cc4 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 23 Oct 2026 21:47:10 +0200 Subject: [PATCH 6/6] chore: prepare 1.11.0 beta 3 --- app/build.gradle.kts | 4 ++-- release-notes/v1.11.0-beta.3.md | 5 +++++ 2 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 release-notes/v1.11.0-beta.3.md diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d389a16d..ff7f64ba 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -26,8 +26,8 @@ android { applicationId = "dev.typetype.android" minSdk = 23 targetSdk = 37 - versionCode = 11102 - versionName = "1.11.0-beta.2" + versionCode = 11103 + versionName = "1.11.0-beta.3" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" resValue("string", "app_name", "TypeType") } diff --git a/release-notes/v1.11.0-beta.3.md b/release-notes/v1.11.0-beta.3.md new file mode 100644 index 00000000..7b355119 --- /dev/null +++ b/release-notes/v1.11.0-beta.3.md @@ -0,0 +1,5 @@ +TypeType Android 1.11.0-beta.3 + +- Reduce repeated SABR window refreshes by matching refresh timing to the requested playback buffer. +- Recover from temporary server interruptions and lost playback sessions without requiring an app restart. +- Limit retries for unavailable media segments to avoid prolonged stalls.