vc=63: fix stale-state nav-bug (new page shows, old video plays)

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.
This commit is contained in:
Kayos 2026-05-26 12:47:31 -07:00
parent 6775f8252f
commit 7bd2740055
3 changed files with 24 additions and 3 deletions

View file

@ -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"

View file

@ -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.

View file

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