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, ) } }