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:
parent
6775f8252f
commit
7bd2740055
3 changed files with 24 additions and 3 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue