From 9ac3423c36aa3f42cedc5552bafdb95320091dc4 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Tue, 27 Oct 2026 18:16:00 +0100 Subject: [PATCH 1/5] ci: tag stable releases at the dispatched commit --- .github/workflows/android.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 915eba15..1aa6de3e 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -242,6 +242,7 @@ jobs: uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2 with: tag_name: ${{ env.RELEASE_TAG }} + target_commitish: ${{ github.sha }} name: ${{ env.RELEASE_TAG }} body_path: release-notes/${{ env.RELEASE_TAG }}.md files: | From 84a7c02e0951c894c3db715cb93a864eed8132cd Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 30 Oct 2026 16:59:31 +0100 Subject: [PATCH 2/5] fix: report playback progress only while the video plays --- .../services/ActivePlaybackReporter.kt | 13 ++-- .../services/PlaybackProgressHeartbeat.kt | 34 +++++++++ .../services/PlaybackProgressHeartbeatTest.kt | 72 +++++++++++++++++++ 3 files changed, 110 insertions(+), 9 deletions(-) create mode 100644 core/playback/src/main/java/dev/typetype/android/services/PlaybackProgressHeartbeat.kt create mode 100644 core/playback/src/test/java/dev/typetype/android/services/PlaybackProgressHeartbeatTest.kt diff --git a/core/playback/src/main/java/dev/typetype/android/services/ActivePlaybackReporter.kt b/core/playback/src/main/java/dev/typetype/android/services/ActivePlaybackReporter.kt index 51a72f75..dda9f084 100644 --- a/core/playback/src/main/java/dev/typetype/android/services/ActivePlaybackReporter.kt +++ b/core/playback/src/main/java/dev/typetype/android/services/ActivePlaybackReporter.kt @@ -11,7 +11,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel -import kotlinx.coroutines.delay import kotlinx.coroutines.launch internal class ActivePlaybackReporter( @@ -21,16 +20,11 @@ internal class ActivePlaybackReporter( private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) private var startedIdentity: PlaybackIdentity? = null private var startJob: Job? = null - private val progressJob: Job + private val heartbeat = PlaybackProgressHeartbeat(scope, PROGRESS_INTERVAL_MILLIS, report = ::reportProgress) init { player.addListener(this) - progressJob = scope.launch { - while (true) { - delay(PROGRESS_INTERVAL_MILLIS) - reportProgress() - } - } + heartbeat.onPlayingChanged(player.isPlaying) } override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { @@ -47,6 +41,7 @@ internal class ActivePlaybackReporter( } override fun onIsPlayingChanged(isPlaying: Boolean) { + heartbeat.onPlayingChanged(isPlaying) if (isPlaying) startIfNeeded() else reportProgress() } @@ -70,7 +65,7 @@ internal class ActivePlaybackReporter( override fun close() { player.removeListener(this) - progressJob.cancel() + heartbeat.stop() startJob?.cancel() val identity = startedIdentity startedIdentity = null diff --git a/core/playback/src/main/java/dev/typetype/android/services/PlaybackProgressHeartbeat.kt b/core/playback/src/main/java/dev/typetype/android/services/PlaybackProgressHeartbeat.kt new file mode 100644 index 00000000..397abb79 --- /dev/null +++ b/core/playback/src/main/java/dev/typetype/android/services/PlaybackProgressHeartbeat.kt @@ -0,0 +1,34 @@ +package dev.typetype.android.services + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +internal class PlaybackProgressHeartbeat( + private val scope: CoroutineScope, + private val intervalMillis: Long, + private val wait: suspend (Long) -> Unit = { delay(it) }, + private val report: () -> Unit, +) { + private var job: Job? = null + + fun onPlayingChanged(isPlaying: Boolean) { + if (!isPlaying) { + stop() + return + } + if (job?.isActive == true) return + job = scope.launch { + while (true) { + wait(intervalMillis) + report() + } + } + } + + fun stop() { + job?.cancel() + job = null + } +} diff --git a/core/playback/src/test/java/dev/typetype/android/services/PlaybackProgressHeartbeatTest.kt b/core/playback/src/test/java/dev/typetype/android/services/PlaybackProgressHeartbeatTest.kt new file mode 100644 index 00000000..112fbe99 --- /dev/null +++ b/core/playback/src/test/java/dev/typetype/android/services/PlaybackProgressHeartbeatTest.kt @@ -0,0 +1,72 @@ +package dev.typetype.android.services + +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.yield +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class PlaybackProgressHeartbeatTest { + @Test + fun `reports once per interval while playing`() = runBlocking { + val ticks = Channel() + var reports = 0 + val heartbeat = PlaybackProgressHeartbeat(this, INTERVAL, { ticks.receive() }) { reports++ } + + heartbeat.onPlayingChanged(true) + ticks.send(Unit) + ticks.send(Unit) + yield() + + assertEquals(2, reports) + heartbeat.stop() + } + + @Test + fun `sends nothing while paused`() = runBlocking { + val ticks = Channel() + var reports = 0 + val heartbeat = PlaybackProgressHeartbeat(this, INTERVAL, { ticks.receive() }) { reports++ } + + heartbeat.onPlayingChanged(true) + ticks.send(Unit) + yield() + heartbeat.onPlayingChanged(false) + yield() + + assertTrue(ticks.trySend(Unit).isFailure) + assertEquals(1, reports) + } + + @Test + fun `sends nothing before playback starts`() = runBlocking { + val ticks = Channel() + var reports = 0 + PlaybackProgressHeartbeat(this, INTERVAL, { ticks.receive() }) { reports++ } + yield() + + assertTrue(ticks.trySend(Unit).isFailure) + assertEquals(0, reports) + } + + @Test + fun `resumes reporting after a pause`() = runBlocking { + val ticks = Channel() + var reports = 0 + val heartbeat = PlaybackProgressHeartbeat(this, INTERVAL, { ticks.receive() }) { reports++ } + + heartbeat.onPlayingChanged(true) + heartbeat.onPlayingChanged(false) + heartbeat.onPlayingChanged(true) + ticks.send(Unit) + yield() + + assertEquals(1, reports) + heartbeat.stop() + } + + private companion object { + const val INTERVAL = 15_000L + } +} From 7d68d9c7ffb4dedbab33ecdeb495c03bb9f533d1 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 30 Oct 2026 16:59:38 +0100 Subject: [PATCH 3/5] perf: prepare playback only for settled or pressed cards --- .../java/dev/typetype/android/AppNavHost.kt | 8 +++ .../core/ui/components/RelatedVideoCard.kt | 15 ++-- .../android/core/ui/components/VideoCard.kt | 15 ++-- .../core/ui/prewarm/CardPlaybackPrewarm.kt | 72 +++++++++++++++++++ .../core/ui/prewarm/LocalPlaybackPrewarm.kt | 18 ++++- .../core/ui/prewarm/SettledCardPrefetch.kt | 69 ++++++++++++++++++ .../feature/player/PlaybackPreheater.kt | 21 ++++-- .../feature/player/PlaybackPrewarmLauncher.kt | 71 ++++++++++++++---- .../feature/player/PlayerSabrPlayback.kt | 4 +- .../player/SabrPlaybackPreloadStore.kt | 6 ++ 10 files changed, 257 insertions(+), 42 deletions(-) create mode 100644 core/ui/src/main/java/dev/typetype/android/core/ui/prewarm/CardPlaybackPrewarm.kt create mode 100644 core/ui/src/main/java/dev/typetype/android/core/ui/prewarm/SettledCardPrefetch.kt diff --git a/app/src/main/java/dev/typetype/android/AppNavHost.kt b/app/src/main/java/dev/typetype/android/AppNavHost.kt index f535c1cf..d9cff32a 100644 --- a/app/src/main/java/dev/typetype/android/AppNavHost.kt +++ b/app/src/main/java/dev/typetype/android/AppNavHost.kt @@ -6,6 +6,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavHostController @@ -37,7 +38,9 @@ import dev.typetype.android.core.ui.navigation.ShortsRoute import dev.typetype.android.core.ui.navigation.StorageSettingsRoute import dev.typetype.android.core.ui.navigation.SubscriptionsRoute import dev.typetype.android.core.ui.prewarm.LocalPlaybackPrewarm +import dev.typetype.android.core.ui.prewarm.LocalSettledCardPrefetch import dev.typetype.android.core.ui.prewarm.PlaybackPrewarm +import dev.typetype.android.core.ui.prewarm.SettledCardPrefetch import dev.typetype.android.core.ui.share.LocalServerBaseUrl import dev.typetype.android.domain.playback.toPlaybackQueueEntry import dev.typetype.android.feature.home.HomeRoute as HomeRouteScreen @@ -72,6 +75,10 @@ fun AppNavHost( ) { val navController: NavHostController = rememberNavController() val playerHostController = remember { mainViewModel.playerHostController } + val prefetchScope = rememberCoroutineScope() + val settledCardPrefetch = remember(playbackPrewarm, prefetchScope) { + playbackPrewarm?.let { SettledCardPrefetch(prefetchScope, it) } + } val currentServer by mainViewModel.currentServer.collectAsStateWithLifecycle() val currentProfile by mainViewModel.currentProfile.collectAsStateWithLifecycle() val appPreferences by mainViewModel.preferences.collectAsStateWithLifecycle() @@ -98,6 +105,7 @@ fun AppNavHost( LocalServerBaseUrl provides currentServer?.baseUrl, LocalDeArrowBranding provides deArrowEnvironment, LocalPlaybackPrewarm provides playbackPrewarm, + LocalSettledCardPrefetch provides settledCardPrefetch, LocalPlaybackRecovery provides (playbackRecovery ?: LocalPlaybackRecovery.current), ) { AppShell( diff --git a/core/ui/src/main/java/dev/typetype/android/core/ui/components/RelatedVideoCard.kt b/core/ui/src/main/java/dev/typetype/android/core/ui/components/RelatedVideoCard.kt index fea1dac7..5cbf18d9 100644 --- a/core/ui/src/main/java/dev/typetype/android/core/ui/components/RelatedVideoCard.kt +++ b/core/ui/src/main/java/dev/typetype/android/core/ui/components/RelatedVideoCard.kt @@ -17,7 +17,6 @@ import androidx.compose.foundation.layout.width import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -34,15 +33,14 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import dev.typetype.android.R import dev.typetype.android.core.ui.branding.rememberVideoBranding -import dev.typetype.android.core.ui.prewarm.CARD_PREWARM_DELAY_MILLIS -import dev.typetype.android.core.ui.prewarm.LocalPlaybackPrewarm +import dev.typetype.android.core.ui.prewarm.cardPlaybackPrewarm +import dev.typetype.android.core.ui.prewarm.rememberCardPlaybackPrewarm import dev.typetype.android.domain.feed.Video import dev.typetype.android.domain.feed.VideoAvailability import dev.typetype.android.domain.feed.VideoBadgeStatus import dev.typetype.android.domain.feed.availabilityAt import dev.typetype.android.domain.feed.badgeStatusAt import dev.typetype.android.domain.feed.releaseTimeMillis -import kotlinx.coroutines.delay @OptIn(ExperimentalFoundationApi::class) @Composable @@ -56,13 +54,7 @@ fun RelatedVideoCard( ) { var menuVisible by remember { mutableStateOf(false) } var availabilityVisible by remember { mutableStateOf(false) } - val playbackPrewarm = LocalPlaybackPrewarm.current - - LaunchedEffect(video.url, playbackPrewarm) { - if (playbackPrewarm == null) return@LaunchedEffect - delay(CARD_PREWARM_DELAY_MILLIS) - playbackPrewarm.prewarm(video.url, video.isLive) - } + val playbackPrewarm = rememberCardPlaybackPrewarm(video.url, video.isLive) val availability = video.availabilityAt(System.currentTimeMillis()) val branding = rememberVideoBranding( @@ -73,6 +65,7 @@ fun RelatedVideoCard( ) Row( modifier = modifier + .cardPlaybackPrewarm(playbackPrewarm) .fillMaxWidth() .combinedClickable( onClick = { diff --git a/core/ui/src/main/java/dev/typetype/android/core/ui/components/VideoCard.kt b/core/ui/src/main/java/dev/typetype/android/core/ui/components/VideoCard.kt index 20c62eb5..2871a2ea 100644 --- a/core/ui/src/main/java/dev/typetype/android/core/ui/components/VideoCard.kt +++ b/core/ui/src/main/java/dev/typetype/android/core/ui/components/VideoCard.kt @@ -21,7 +21,6 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -41,15 +40,14 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import dev.typetype.android.R import dev.typetype.android.core.ui.branding.rememberVideoBranding -import dev.typetype.android.core.ui.prewarm.CARD_PREWARM_DELAY_MILLIS -import dev.typetype.android.core.ui.prewarm.LocalPlaybackPrewarm +import dev.typetype.android.core.ui.prewarm.cardPlaybackPrewarm +import dev.typetype.android.core.ui.prewarm.rememberCardPlaybackPrewarm import dev.typetype.android.domain.feed.Video import dev.typetype.android.domain.feed.VideoAvailability import dev.typetype.android.domain.feed.VideoBadgeStatus import dev.typetype.android.domain.feed.availabilityAt import dev.typetype.android.domain.feed.badgeStatusAt import dev.typetype.android.domain.feed.releaseTimeMillis -import kotlinx.coroutines.delay @OptIn(ExperimentalFoundationApi::class) @Composable @@ -72,16 +70,11 @@ fun VideoCard( durationSeconds = video.durationSeconds, ) val openDescription = stringResource(R.string.video_open_accessibility, branding.title) - val playbackPrewarm = LocalPlaybackPrewarm.current - - LaunchedEffect(video.url, playbackPrewarm) { - if (playbackPrewarm == null) return@LaunchedEffect - delay(CARD_PREWARM_DELAY_MILLIS) - playbackPrewarm.prewarm(video.url, video.isLive) - } + val playbackPrewarm = rememberCardPlaybackPrewarm(video.url, video.isLive) Column( modifier = modifier + .cardPlaybackPrewarm(playbackPrewarm) .fillMaxWidth() .semantics { contentDescription = openDescription } .combinedClickable( diff --git a/core/ui/src/main/java/dev/typetype/android/core/ui/prewarm/CardPlaybackPrewarm.kt b/core/ui/src/main/java/dev/typetype/android/core/ui/prewarm/CardPlaybackPrewarm.kt new file mode 100644 index 00000000..344d878b --- /dev/null +++ b/core/ui/src/main/java/dev/typetype/android/core/ui/prewarm/CardPlaybackPrewarm.kt @@ -0,0 +1,72 @@ +package dev.typetype.android.core.ui.prewarm + +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.AwaitPointerEventScope +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.PointerInputChange +import androidx.compose.ui.input.pointer.changedToUpIgnoreConsumed +import androidx.compose.ui.input.pointer.isOutOfBounds +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned + +class CardPlaybackPrewarm internal constructor( + internal val prewarm: PlaybackPrewarm, + internal val settled: SettledCardPrefetch?, + internal val request: PrewarmRequest, + internal val key: Any, +) + +@Composable +fun rememberCardPlaybackPrewarm(videoUrl: String, isLive: Boolean): CardPlaybackPrewarm? { + val prewarm = LocalPlaybackPrewarm.current ?: return null + val settled = LocalSettledCardPrefetch.current + val key = remember { Any() } + DisposableEffect(settled, key) { + onDispose { settled?.remove(key) } + } + return remember(prewarm, settled, videoUrl, isLive) { + CardPlaybackPrewarm(prewarm, settled, PrewarmRequest(videoUrl, isLive), key) + } +} + +fun Modifier.cardPlaybackPrewarm(card: CardPlaybackPrewarm?): Modifier { + if (card == null) return this + val placed = card.settled?.let { settled -> + onGloballyPositioned { coordinates -> + settled.place(card.key, card.request, coordinates.visibleFraction(), coordinates.boundsInWindow().top) + } + } ?: this + return placed.pointerInput(card) { + val confirmAfterMillis = viewConfiguration.longPressTimeoutMillis + awaitEachGesture { + awaitFirstDown(requireUnconsumed = false) + val session = card.prewarm.pressSession(card.request) + val released = withTimeoutOrNull(confirmAfterMillis) { awaitReleaseOrScroll() } + if (released == false) session.cancel() else session.confirm() + } + } +} + +private suspend fun AwaitPointerEventScope.awaitReleaseOrScroll(): Boolean { + while (true) { + val changes = awaitPointerEvent(PointerEventPass.Final).changes + if (changes.all { it.changedToUpIgnoreConsumed() }) return true + if (changes.any { it.isScrollMove() || it.isOutOfBounds(size, extendedTouchPadding) }) return false + } +} + +private fun PointerInputChange.isScrollMove(): Boolean = pressed && previousPressed && isConsumed + +private fun LayoutCoordinates.visibleFraction(): Float { + val area = size.width.toFloat() * size.height + if (area <= 0f || !isAttached) return 0f + val visible = boundsInWindow() + return (visible.width * visible.height / area).coerceIn(0f, 1f) +} diff --git a/core/ui/src/main/java/dev/typetype/android/core/ui/prewarm/LocalPlaybackPrewarm.kt b/core/ui/src/main/java/dev/typetype/android/core/ui/prewarm/LocalPlaybackPrewarm.kt index eda7045f..a8e48926 100644 --- a/core/ui/src/main/java/dev/typetype/android/core/ui/prewarm/LocalPlaybackPrewarm.kt +++ b/core/ui/src/main/java/dev/typetype/android/core/ui/prewarm/LocalPlaybackPrewarm.kt @@ -2,10 +2,22 @@ package dev.typetype.android.core.ui.prewarm import androidx.compose.runtime.staticCompositionLocalOf -fun interface PlaybackPrewarm { - fun prewarm(videoUrl: String, knownLive: Boolean) +data class PrewarmRequest(val videoUrl: String, val knownLive: Boolean) + +interface PlaybackPrewarm { + fun prefetchStreams(requests: List) + + fun cancelStreamPrefetch() + + fun pressSession(request: PrewarmRequest): PressedSession +} + +interface PressedSession { + fun confirm() + + fun cancel() } val LocalPlaybackPrewarm = staticCompositionLocalOf { null } -const val CARD_PREWARM_DELAY_MILLIS = 200L +val LocalSettledCardPrefetch = staticCompositionLocalOf { null } diff --git a/core/ui/src/main/java/dev/typetype/android/core/ui/prewarm/SettledCardPrefetch.kt b/core/ui/src/main/java/dev/typetype/android/core/ui/prewarm/SettledCardPrefetch.kt new file mode 100644 index 00000000..4260ea2d --- /dev/null +++ b/core/ui/src/main/java/dev/typetype/android/core/ui/prewarm/SettledCardPrefetch.kt @@ -0,0 +1,69 @@ +package dev.typetype.android.core.ui.prewarm + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +class SettledCardPrefetch( + private val scope: CoroutineScope, + private val prewarm: PlaybackPrewarm, + private val now: () -> Long = { System.nanoTime() / NANOS_PER_MILLI }, + private val wait: suspend (Long) -> Unit = { delay(it) }, +) { + private val cards = linkedMapOf() + private var settleJob: Job? = null + private var prefetching = false + private var movedAtMillis = 0L + + fun place(key: Any, request: PrewarmRequest, visibleFraction: Float, top: Float) { + val placement = CardPlacement(request, visibleFraction, top) + if (cards.put(key, placement) == placement) return + onMoved() + } + + fun remove(key: Any) { + if (cards.remove(key) != null) onMoved() + } + + private fun onMoved() { + movedAtMillis = now() + if (prefetching) { + prewarm.cancelStreamPrefetch() + prefetching = false + } + if (settleJob?.isActive == true) return + settleJob = scope.launch { + var quietMillis = now() - movedAtMillis + while (quietMillis < SETTLE_DELAY_MILLIS) { + wait(SETTLE_DELAY_MILLIS - quietMillis) + quietMillis = now() - movedAtMillis + } + prefetchMostVisible() + } + } + + private fun prefetchMostVisible() { + val requests = cards.values + .filter { it.visibleFraction >= MIN_VISIBLE_FRACTION } + .sortedWith(compareByDescending { it.visibleFraction }.thenBy { it.top }) + .take(MAX_SETTLED_CARDS) + .map { it.request } + if (requests.isEmpty()) return + prefetching = true + prewarm.prefetchStreams(requests) + } + + private data class CardPlacement( + val request: PrewarmRequest, + val visibleFraction: Float, + val top: Float, + ) + + private companion object { + const val SETTLE_DELAY_MILLIS = 600L + const val MIN_VISIBLE_FRACTION = 0.5f + const val MAX_SETTLED_CARDS = 2 + const val NANOS_PER_MILLI = 1_000_000L + } +} diff --git a/feature/player/src/main/java/dev/typetype/android/feature/player/PlaybackPreheater.kt b/feature/player/src/main/java/dev/typetype/android/feature/player/PlaybackPreheater.kt index 03540ec8..73fb2b71 100644 --- a/feature/player/src/main/java/dev/typetype/android/feature/player/PlaybackPreheater.kt +++ b/feature/player/src/main/java/dev/typetype/android/feature/player/PlaybackPreheater.kt @@ -15,6 +15,7 @@ import dev.typetype.android.playback.initialQuality import dev.typetype.android.playback.sabrSelection import javax.inject.Inject import javax.inject.Singleton +import kotlinx.coroutines.CancellationException @Singleton class PlaybackPreheater @Inject constructor( @@ -25,16 +26,20 @@ class PlaybackPreheater @Inject constructor( private val codecSupport: DevicePlaybackCodecSupport, ) { - suspend fun preheat(videoUrl: String, knownLive: Boolean = false) { - if (knownLive) return + suspend fun prefetchStream(videoUrl: String) { + streams.prefetchPlaybackStream(videoUrl) + } + + suspend fun preheat(videoUrl: String, beforeCreate: suspend () -> Unit) { val settings = userSettingsRepository.current().getOrNull() ?: return - preheat(videoUrl, settings, codecSupport, { true }) + preheat(videoUrl, settings, codecSupport, beforeCreate) { true } } suspend fun preheat( videoUrl: String, settings: UserSettings, codecSupport: PlaybackCodecSupport, + beforeCreate: suspend () -> Unit = {}, canPrepare: () -> Boolean, ) { val stream = streams.prefetchPlaybackStream(videoUrl).getOrNull() ?: return @@ -53,7 +58,15 @@ class PlaybackPreheater @Inject constructor( ) ?: return val target = stream.sabrPlaybackTarget(selection) val reservation = preloads.reserve(target) - if (reservation.owner) reservation.result.complete(sabr.prewarm(target, 0L)) + if (!reservation.owner) return + try { + beforeCreate() + reservation.result.complete(sabr.prewarm(target, 0L)) + } catch (cancelled: CancellationException) { + preloads.discard(target, reservation.result) + reservation.result.complete(Result.failure(cancelled)) + throw cancelled + } } } diff --git a/feature/player/src/main/java/dev/typetype/android/feature/player/PlaybackPrewarmLauncher.kt b/feature/player/src/main/java/dev/typetype/android/feature/player/PlaybackPrewarmLauncher.kt index e5840f2f..54b80747 100644 --- a/feature/player/src/main/java/dev/typetype/android/feature/player/PlaybackPrewarmLauncher.kt +++ b/feature/player/src/main/java/dev/typetype/android/feature/player/PlaybackPrewarmLauncher.kt @@ -1,10 +1,14 @@ package dev.typetype.android.feature.player import dev.typetype.android.core.ui.prewarm.PlaybackPrewarm +import dev.typetype.android.core.ui.prewarm.PressedSession +import dev.typetype.android.core.ui.prewarm.PrewarmRequest import javax.inject.Inject import javax.inject.Singleton +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch @@ -13,20 +17,63 @@ class PlaybackPrewarmLauncher @Inject constructor( private val preheater: PlaybackPreheater, ) : PlaybackPrewarm { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - private var inFlight = false + private val lock = Any() + private var streamJob: Job? = null + private var pendingPress: LaunchedPress? = null - override fun prewarm(videoUrl: String, knownLive: Boolean) { - if (videoUrl.isBlank()) return - synchronized(this) { - if (inFlight) return - inFlight = true + override fun prefetchStreams(requests: List) { + val playable = requests.filter { it.isPreparable() } + if (playable.isEmpty()) return + val job = scope.launch { + playable.forEach { preheater.prefetchStream(it.videoUrl) } } - scope.launch { - try { - preheater.preheat(videoUrl, knownLive) - } finally { - synchronized(this@PlaybackPrewarmLauncher) { inFlight = false } - } + synchronized(lock) { + streamJob?.cancel() + streamJob = job } } + + override fun cancelStreamPrefetch() { + synchronized(lock) { + streamJob?.cancel() + streamJob = null + } + } + + override fun pressSession(request: PrewarmRequest): PressedSession { + if (!request.isPreparable()) return IgnoredPress + val confirmed = CompletableDeferred() + val press = LaunchedPress( + job = scope.launch { preheater.preheat(request.videoUrl) { confirmed.await() } }, + confirmed = confirmed, + ) + synchronized(lock) { + pendingPress?.cancelUnlessConfirmed() + pendingPress = press + } + return press + } + + private class LaunchedPress( + private val job: Job, + private val confirmed: CompletableDeferred, + ) : PressedSession { + override fun confirm() { + confirmed.complete(Unit) + } + + override fun cancel() = cancelUnlessConfirmed() + + fun cancelUnlessConfirmed() { + if (!confirmed.isCompleted) job.cancel() + } + } + + private object IgnoredPress : PressedSession { + override fun confirm() = Unit + + override fun cancel() = Unit + } } + +private fun PrewarmRequest.isPreparable(): Boolean = !knownLive && videoUrl.isNotBlank() diff --git a/feature/player/src/main/java/dev/typetype/android/feature/player/PlayerSabrPlayback.kt b/feature/player/src/main/java/dev/typetype/android/feature/player/PlayerSabrPlayback.kt index bd26ffc4..33f92cf7 100644 --- a/feature/player/src/main/java/dev/typetype/android/feature/player/PlayerSabrPlayback.kt +++ b/feature/player/src/main/java/dev/typetype/android/feature/player/PlayerSabrPlayback.kt @@ -6,6 +6,7 @@ import dev.typetype.android.domain.stream.SabrPlaybackSession import dev.typetype.android.domain.stream.Stream import dev.typetype.android.domain.stream.binding import dev.typetype.android.domain.stream.sabrPlaybackTarget +import kotlinx.coroutines.CancellationException internal class PlayerSabrPlayback( private val repository: SabrPlaybackRepository, @@ -19,7 +20,8 @@ internal class PlayerSabrPlayback( startTimeMs: Long, ): SabrPlaybackSession? { val target = stream.sabrPlaybackTarget(selection) - preloads.take(target)?.await()?.let { preloaded -> + val preload = preloads.take(target)?.await()?.takeUnless { it.exceptionOrNull() is CancellationException } + preload?.let { preloaded -> val positioned = preloaded.resolveFor(stream)?.let { session -> if (startTimeMs > 0L) { repository.seek(target, session.binding, startTimeMs).resolveFor(stream) diff --git a/feature/player/src/main/java/dev/typetype/android/feature/player/SabrPlaybackPreloadStore.kt b/feature/player/src/main/java/dev/typetype/android/feature/player/SabrPlaybackPreloadStore.kt index 21603877..32e98cdd 100644 --- a/feature/player/src/main/java/dev/typetype/android/feature/player/SabrPlaybackPreloadStore.kt +++ b/feature/player/src/main/java/dev/typetype/android/feature/player/SabrPlaybackPreloadStore.kt @@ -42,6 +42,12 @@ class SabrPlaybackPreloadStore( } } + fun discard(target: SabrPlaybackTarget, result: CompletableDeferred>) { + synchronized(lock) { + if (entries[target]?.result === result) entries.remove(target) + } + } + private fun removeExpired(now: Long) { entries.entries.removeAll { it.value.expiresAtNanos <= now } } From 639ec5184e89d10aff3439dfc8d8565673d83bb6 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 30 Oct 2026 16:59:38 +0100 Subject: [PATCH 4/5] test: cover settled and pressed card prewarm --- .../ui/prewarm/CardPlaybackPrewarmTest.kt | 121 ++++++++++++++++++ .../ui/prewarm/SettledCardPrefetchTest.kt | 94 ++++++++++++++ .../feature/player/PlayerSabrPlaybackTest.kt | 22 ++++ .../player/SabrPlaybackPreloadStoreTest.kt | 21 +++ 4 files changed, 258 insertions(+) create mode 100644 core/ui/src/androidTest/java/dev/typetype/android/core/ui/prewarm/CardPlaybackPrewarmTest.kt create mode 100644 core/ui/src/test/java/dev/typetype/android/core/ui/prewarm/SettledCardPrefetchTest.kt diff --git a/core/ui/src/androidTest/java/dev/typetype/android/core/ui/prewarm/CardPlaybackPrewarmTest.kt b/core/ui/src/androidTest/java/dev/typetype/android/core/ui/prewarm/CardPlaybackPrewarmTest.kt new file mode 100644 index 00000000..dd3d048c --- /dev/null +++ b/core/ui/src/androidTest/java/dev/typetype/android/core/ui/prewarm/CardPlaybackPrewarmTest.kt @@ -0,0 +1,121 @@ +package dev.typetype.android.core.ui.prewarm + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.Text +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipeUp +import androidx.compose.ui.unit.dp +import androidx.test.ext.junit.runners.AndroidJUnit4 +import java.util.Collections +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class CardPlaybackPrewarmTest { + @get:Rule + val composeRule = createComposeRule() + + private val prewarm = RecordingPrewarm() + + @Test + fun tappingACardConfirmsItsSession() { + showCards() + + composeRule.onNodeWithText("video-0").performClick() + composeRule.waitForIdle() + + assertEquals(listOf("confirm:video-0"), prewarm.pressEvents) + } + + @Test + fun scrollingPastACardCancelsItsSession() { + showCards() + + composeRule.onNodeWithTag(LIST_TAG).performTouchInput { swipeUp() } + composeRule.waitForIdle() + + assertTrue(prewarm.pressEvents.isNotEmpty()) + assertTrue(prewarm.pressEvents.all { it.startsWith("cancel:") }) + } + + @Test + fun aSettledListPrefetchesOnlyTheMostVisibleStreams() { + showCards() + + composeRule.waitUntil(timeoutMillis = 5_000) { prewarm.batches.isNotEmpty() } + + assertEquals(listOf(listOf(url(0), url(1))), prewarm.batches.map { batch -> batch.map { it.videoUrl } }) + assertTrue(prewarm.pressEvents.isEmpty()) + } + + private fun showCards() { + composeRule.setContent { + val scope = rememberCoroutineScope() + val settled = remember { SettledCardPrefetch(scope, prewarm) } + CompositionLocalProvider( + LocalPlaybackPrewarm provides prewarm, + LocalSettledCardPrefetch provides settled, + ) { + LazyColumn(Modifier.testTag(LIST_TAG)) { + items(30) { index -> + Box( + Modifier + .cardPlaybackPrewarm(rememberCardPlaybackPrewarm(url(index), isLive = false)) + .fillMaxWidth() + .height(160.dp) + .clickable {}, + ) { + Text("video-$index") + } + } + } + } + } + } + + private fun url(index: Int) = "https://www.youtube.com/watch?v=video-$index" + + private class RecordingPrewarm : PlaybackPrewarm { + val batches: MutableList> = Collections.synchronizedList(mutableListOf()) + val pressEvents: MutableList = Collections.synchronizedList(mutableListOf()) + + override fun prefetchStreams(requests: List) { + batches += requests + } + + override fun cancelStreamPrefetch() = Unit + + override fun pressSession(request: PrewarmRequest): PressedSession { + val id = request.videoUrl.substringAfter("v=") + return object : PressedSession { + override fun confirm() { + pressEvents += "confirm:$id" + } + + override fun cancel() { + pressEvents += "cancel:$id" + } + } + } + } + + private companion object { + const val LIST_TAG = "cards" + } +} diff --git a/core/ui/src/test/java/dev/typetype/android/core/ui/prewarm/SettledCardPrefetchTest.kt b/core/ui/src/test/java/dev/typetype/android/core/ui/prewarm/SettledCardPrefetchTest.kt new file mode 100644 index 00000000..198b25a0 --- /dev/null +++ b/core/ui/src/test/java/dev/typetype/android/core/ui/prewarm/SettledCardPrefetchTest.kt @@ -0,0 +1,94 @@ +package dev.typetype.android.core.ui.prewarm + +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.yield +import org.junit.Assert.assertEquals +import org.junit.Test + +class SettledCardPrefetchTest { + private var clock = 0L + private val ticks = Channel() + private val prewarm = RecordingPrewarm() + + @Test + fun `prefetches the two most visible cards once the list settles`() = runBlocking { + val prefetch = prefetch() + prefetch.place("a", request("a"), visibleFraction = 0.6f, top = 0f) + prefetch.place("b", request("b"), visibleFraction = 1f, top = 300f) + prefetch.place("c", request("c"), visibleFraction = 1f, top = 600f) + + settle() + + assertEquals(listOf(listOf(request("b"), request("c"))), prewarm.batches) + } + + @Test + fun `waits for the list to stay still`() = runBlocking { + val prefetch = prefetch() + prefetch.place("a", request("a"), visibleFraction = 1f, top = 0f) + yield() + clock += 400L + prefetch.place("a", request("a"), visibleFraction = 1f, top = -40f) + clock += 400L + ticks.send(Unit) + yield() + + assertEquals(emptyList>(), prewarm.batches) + settle() + assertEquals(listOf(listOf(request("a"))), prewarm.batches) + } + + @Test + fun `moving again cancels the running prefetch`() = runBlocking { + val prefetch = prefetch() + prefetch.place("a", request("a"), visibleFraction = 1f, top = 0f) + settle() + + prefetch.place("a", request("a"), visibleFraction = 1f, top = -80f) + + assertEquals(1, prewarm.cancellations) + settle() + } + + @Test + fun `skips cards that are mostly off screen`() = runBlocking { + val prefetch = prefetch() + prefetch.place("a", request("a"), visibleFraction = 0.3f, top = 900f) + + settle() + + assertEquals(emptyList>(), prewarm.batches) + } + + private fun kotlinx.coroutines.CoroutineScope.prefetch() = + SettledCardPrefetch(this, prewarm, now = { clock }, wait = { ticks.receive() }) + + private suspend fun settle() { + yield() + clock += 600L + ticks.send(Unit) + yield() + } + + private fun request(id: String) = PrewarmRequest("https://www.youtube.com/watch?v=$id", knownLive = false) + + private class RecordingPrewarm : PlaybackPrewarm { + val batches = mutableListOf>() + var cancellations = 0 + + override fun prefetchStreams(requests: List) { + batches += requests + } + + override fun cancelStreamPrefetch() { + cancellations++ + } + + override fun pressSession(request: PrewarmRequest): PressedSession = object : PressedSession { + override fun confirm() = Unit + + override fun cancel() = Unit + } + } +} diff --git a/feature/player/src/test/java/dev/typetype/android/feature/player/PlayerSabrPlaybackTest.kt b/feature/player/src/test/java/dev/typetype/android/feature/player/PlayerSabrPlaybackTest.kt index 26ba4ff9..8941951f 100644 --- a/feature/player/src/test/java/dev/typetype/android/feature/player/PlayerSabrPlaybackTest.kt +++ b/feature/player/src/test/java/dev/typetype/android/feature/player/PlayerSabrPlaybackTest.kt @@ -14,6 +14,7 @@ import dev.typetype.android.domain.stream.StreamVideoSource import dev.typetype.android.domain.stream.sabrPlaybackTarget import dev.typetype.android.playback.PlayableSource import dev.typetype.android.playback.playbackSubtitles +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.Test @@ -67,6 +68,27 @@ class PlayerSabrPlaybackTest { assertEquals(0, repository.prepareCount) } + @Test + fun `cancelled press preload falls back to a new session`() = runBlocking { + val repository = RecordingRepository() + val stream = stream() + val selection = selection() + val preloads = SabrPlaybackPreloadStore { 0L } + preloads.reserve(stream.sabrPlaybackTarget(selection)).result.complete( + Result.failure(CancellationException("press became a scroll")), + ) + val playback = PlayerSabrPlayback( + repository = repository, + preloads = preloads, + onFailure = { _, _ -> }, + ) + + val result = playback.prepare(stream, selection, 0L) + + assertEquals(repository.session, result) + assertEquals(1, repository.prepareCount) + } + @Test fun `resume position repositions the matching preload`() = runBlocking { val repository = RecordingRepository() diff --git a/feature/player/src/test/java/dev/typetype/android/feature/player/SabrPlaybackPreloadStoreTest.kt b/feature/player/src/test/java/dev/typetype/android/feature/player/SabrPlaybackPreloadStoreTest.kt index 0b861184..c5e218bb 100644 --- a/feature/player/src/test/java/dev/typetype/android/feature/player/SabrPlaybackPreloadStoreTest.kt +++ b/feature/player/src/test/java/dev/typetype/android/feature/player/SabrPlaybackPreloadStoreTest.kt @@ -3,6 +3,7 @@ package dev.typetype.android.feature.player import dev.typetype.android.domain.stream.SabrPlaybackSession import dev.typetype.android.domain.stream.SabrPlaybackTarget import dev.typetype.android.domain.stream.StreamRequestScope +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.runBlocking import org.junit.Assert.assertNull import org.junit.Assert.assertSame @@ -21,6 +22,26 @@ class SabrPlaybackPreloadStoreTest { assertNull(store.take(target())) } + @Test + fun `discarded reservation is not consumed`() { + val store = SabrPlaybackPreloadStore { 0L } + val reservation = store.reserve(target()) + + store.discard(target(), reservation.result) + + assertNull(store.take(target())) + } + + @Test + fun `discarding an older reservation keeps the current one`() { + val store = SabrPlaybackPreloadStore { 0L } + val current = store.reserve(target()) + + store.discard(target(), CompletableDeferred()) + + assertSame(current.result, store.take(target())) + } + @Test fun `expired preload is not consumed`() { var now = 0L From 0204cd5b61bfc8040c1cc39d36e1acc0ee06a774 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 30 Oct 2026 17:28:08 +0100 Subject: [PATCH 5/5] chore: prepare 1.12.0 beta 1 --- app/build.gradle.kts | 4 ++-- release-notes/v1.12.0-beta.1.md | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) create mode 100644 release-notes/v1.12.0-beta.1.md diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 30cf90ed..f06a4ebb 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -22,8 +22,8 @@ android { defaultConfig { applicationId = "dev.typetype.android" - versionCode = 11110 - versionName = "1.11.0-beta.10" + versionCode = 11112 + versionName = "1.12.0-beta.1" } signingConfigs { diff --git a/release-notes/v1.12.0-beta.1.md b/release-notes/v1.12.0-beta.1.md new file mode 100644 index 00000000..10ef7eb4 --- /dev/null +++ b/release-notes/v1.12.0-beta.1.md @@ -0,0 +1,4 @@ +TypeType Android 1.12.0-beta.1 + +- Stop reporting playback progress while a video is paused: the app now reports every 15 seconds only while it plays, plus once when you pause, seek or stop. +- Stop preparing playback for every video card that scrolls past: the app loads the stream details of the one or two cards you stop on, and prepares playback when you tap or long-press a card.