From 7bd2740055826fe64b5b488f00502252c6e95a25 Mon Sep 17 00:00:00 2001 From: Kayos Date: Tue, 26 May 2026 12:47:31 -0700 Subject: [PATCH] vc=63: fix stale-state nav-bug (new page shows, old video plays) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cobb reproduced 2026-05-26: clicking video B from detail A's related section opens detail B (title, description correct), minibar/media notification show A's title, and AUDIO plays A. The bug everyone was hitting as 'video bugs are back'. Root cause — VideoDetailViewModel is activity-scoped, so navigating A→B shows ONE composition frame with the previous video's state before vm.load(B)'s reset propagates. During that frame: 1. VideoDetailScreen body runs with streamUrl=B but state.detail=A and state.resolved=A's playback URLs (stale). 2. InlinePlayer is called with title=A, streamUrl=B, resolved=A's. 3. Its LaunchedEffect launches a coroutine. Body is synchronous (no suspend), runs to completion before cancellation can interrupt. 4. setPlayingFrom(streamUrl=B, resolved=A's URLs) fires. claim() succeeds → NowPlaying = {streamUrl=B, title=A's title}. setMediaItem with A's playback URIs → player loads + plays A. 5. State reset propagates. InlinePlayer disposes. 6. After vm.load completes with B's data, InlinePlayer recomposes with B's resolved. Its NEW LaunchedEffect fires. The check 'NowPlaying.streamUrl == streamUrl' returns true (because step 4 already stamped streamUrl=B). RETURN EARLY. setPlayingFrom(B) NEVER fires with the correct B data. Fix — add a loadedUrl field to VideoDetailUiState that tracks which streamUrl the current detail/resolved actually belong to. Gate VideoDetailScreen's player composition on state.loadedUrl == streamUrl, so the stale-state frame can't fire setPlayingFrom with mismatched data. vm.load sets loadedUrl in the initial reset AND the success/error updates — every state transition carries the URL that owns it. --- buildSrc/src/main/kotlin/ProjectConfig.kt | 4 ++-- .../straw/feature/detail/VideoDetailScreen.kt | 9 +++++++++ .../straw/feature/detail/VideoDetailViewModel.kt | 14 +++++++++++++- 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/buildSrc/src/main/kotlin/ProjectConfig.kt b/buildSrc/src/main/kotlin/ProjectConfig.kt index b1d852b9b..409599050 100644 --- a/buildSrc/src/main/kotlin/ProjectConfig.kt +++ b/buildSrc/src/main/kotlin/ProjectConfig.kt @@ -55,6 +55,6 @@ const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app" // vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via // strawcore-core (Sulkta-Coop/strawcore) via the UniFFI wrapper; no // NewPipeExtractor in the runtime path. -const val STRAW_VERSION_CODE = 62 -const val STRAW_VERSION_NAME = "0.1.0-BV" +const val STRAW_VERSION_CODE = 63 +const val STRAW_VERSION_NAME = "0.1.0-BW" const val STRAW_APPLICATION_ID = "com.sulkta.straw" diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt index 02c86e3b9..4d4e67411 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt @@ -258,6 +258,15 @@ fun VideoDetailScreen( else -> { val d = state.detail ?: return@Column + // Guard against vm's activity-scoped staleness — on a + // fresh navigation A → B, the shared VM still holds + // A's detail/resolved for one composition frame before + // vm.load(B)'s reset propagates. Without this gate, the + // InlinePlayer's LaunchedEffect would fire with + // streamUrl=B but resolved=A's URLs and play A under + // B's chrome (Cobb-reported 2026-05-26: detail page + // shows new video, audio is the old one). + if (state.loadedUrl != streamUrl) return@Column // Player surface — edge-to-edge, NewPipe/YouTube style. // Lives outside the 16dp horizontal padding so the // thumbnail fills the screen width with no gutters. diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt index f20864efa..76a12d77e 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt @@ -93,6 +93,16 @@ data class VideoDetailUiState( val error: String? = null, /** Raw extractor result — kept around for the Download dialog. */ val streamInfo: uniffi.strawcore.StreamInfo? = null, + /** + * Tracks which URL the current `detail`/`resolved` belong to. + * vm is activity-scoped, so a fresh navigation to detail B sees + * the PREVIOUS video's state for one composition frame before + * vm.load(B) clears it. Without this field, the InlinePlayer's + * setPlayingFrom would fire with streamUrl=B but resolved=A's + * playback URLs — claiming NowPlaying with B's streamUrl but + * playing A's video under it. vc=63 audit. + */ + val loadedUrl: String? = null, ) class VideoDetailViewModel : ViewModel() { @@ -127,7 +137,7 @@ class VideoDetailViewModel : ViewModel() { } inFlight?.cancel() loadedUrl = streamUrl - _ui.update { VideoDetailUiState(loading = true) } + _ui.update { VideoDetailUiState(loading = true, loadedUrl = streamUrl) } inFlight = viewModelScope.launch { try { // strawcore.streamInfo is suspend on tokio; no Dispatchers.IO wrap. @@ -273,6 +283,7 @@ class VideoDetailViewModel : ViewModel() { ), resolved = resolved, streamInfo = info, + loadedUrl = streamUrl, ) } } catch (t: Throwable) { @@ -284,6 +295,7 @@ class VideoDetailViewModel : ViewModel() { error = com.sulkta.straw.util.LogDump.scrubLine( t.message ?: t.javaClass.simpleName, ), + loadedUrl = streamUrl, ) } }