diff --git a/buildSrc/src/main/kotlin/ProjectConfig.kt b/buildSrc/src/main/kotlin/ProjectConfig.kt index 9ec76ca08..b1d852b9b 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 = 61 -const val STRAW_VERSION_NAME = "0.1.0-BU" +const val STRAW_VERSION_CODE = 62 +const val STRAW_VERSION_NAME = "0.1.0-BV" 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 447c7f79e..02c86e3b9 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 @@ -67,6 +67,7 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -741,8 +742,14 @@ private fun InlinePlayer( // Push the resolved stream into the shared controller if it isn't // already playing this URL. We don't kick off a new fetch — the // outer VideoDetailScreen already called vm.load(streamUrl). + // + // retryVersion lets the user manually re-fire setPlayingFrom after + // a playback error. Without it, the screen used to lock into the + // thumbnail+spinner branch once NowPlaying.clear() fired from + // onPlayerError. vc=62 audit BUG-2. val resolved = state.resolved - LaunchedEffect(controller, resolved, streamUrl) { + var retryVersion by remember(streamUrl) { mutableIntStateOf(0) } + LaunchedEffect(controller, resolved, streamUrl, retryVersion) { val c = controller ?: return@LaunchedEffect val r = resolved ?: return@LaunchedEffect // Optimization, not safety. claim() guards the race. @@ -792,11 +799,24 @@ private fun InlinePlayer( color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(16.dp), ) - playbackError != null -> Text( - "playback error: $playbackError", - color = MaterialTheme.colorScheme.error, + playbackError != null -> Column( modifier = Modifier.padding(16.dp), - ) + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + "playback error: $playbackError", + color = MaterialTheme.colorScheme.error, + ) + Spacer(modifier = Modifier.height(12.dp)) + OutlinedButton(onClick = { + // Clear the error AND nudge the LaunchedEffect to + // re-attempt setPlayingFrom. vc=62 audit BUG-2 — + // without this the screen used to lock on the + // error forever after NowPlaying.clear(). + playbackError = null + retryVersion += 1 + }) { Text("Retry") } + } resolved?.isPlayable != true -> Text( "no playable stream", color = Color.White, diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlaybackService.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlaybackService.kt index 692aebd04..ea0e89f50 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlaybackService.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlaybackService.kt @@ -158,12 +158,32 @@ class PlaybackService : MediaSessionService() { override fun onMediaItemTransition(item: MediaItem?, reason: Int) { if (item == null) return val idx = player.currentMediaItemIndex - val queued = Queue.at(idx) ?: return - NowPlaying.claim(queued) - if (queued.segments.isEmpty()) { - val videoId = com.sulkta.straw.feature.detail.extractYtVideoId(queued.streamUrl) - if (!videoId.isNullOrBlank()) fetchSbForQueued(queued, videoId) + val queued = Queue.at(idx) + if (queued != null) { + NowPlaying.claim(queued) + if (queued.segments.isEmpty()) { + val videoId = + com.sulkta.straw.feature.detail.extractYtVideoId(queued.streamUrl) + if (!videoId.isNullOrBlank()) fetchSbForQueued(queued, videoId) + } + return } + // Queue desync — MediaItem was added by a path that + // bypassed enqueueInternal, OR the queue was cleared + // while a transition was pending. Fall back to the + // MediaItem's own metadata so NowPlaying doesn't stay + // stuck on the previous video forever (would freeze + // VideoDetail's controllerOnThisVideo guard at false + // and lock the inline player into thumbnail+spinner). + // vc=62 audit BUG-5. + val uri = item.localConfiguration?.uri?.toString() ?: return + val fallback = NowPlayingItem( + streamUrl = uri, + title = item.mediaMetadata.title?.toString().orEmpty(), + uploader = item.mediaMetadata.artist?.toString().orEmpty(), + thumbnail = item.mediaMetadata.artworkUri?.toString(), + ) + NowPlaying.claim(fallback) } override fun onIsPlayingChanged(isPlaying: Boolean) { @@ -205,10 +225,17 @@ class PlaybackService : MediaSessionService() { * ResumePositionsStore. Bails on idle/ended states and unknown * durations (live streams). The store itself enforces minimum- * position + near-end-clear thresholds. + * + * Gates STRICTLY on STATE_READY. STATE_BUFFERING during a fresh + * setMediaItem still reports the PREVIOUS item's position via + * currentPosition until prepare finishes and the new timeline + * lands — without the gate we'd record A's tail position under + * B's videoId and auto-resume the user mid-A on next open. + * vc=62 audit BUG-4. */ private fun captureResumePosition(player: Player) { val state = player.playbackState - if (state == Player.STATE_IDLE || state == Player.STATE_ENDED) return + if (state != Player.STATE_READY) return val item = NowPlaying.current.value ?: return val videoId = com.sulkta.straw.feature.detail.extractYtVideoId(item.streamUrl) ?: return val pos = player.currentPosition diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/StrawMediaController.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/StrawMediaController.kt index 923dcd095..8e277eac2 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/StrawMediaController.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/StrawMediaController.kt @@ -121,9 +121,22 @@ fun Player.setPlayingFrom( // an app update / process death. The store skips trivial // positions and clears near-end so we don't auto-resume to 0:03 // or to the credits. + // + // Clamp the resume position against the RECORDED duration with a + // safety margin. vc=62 audit BUG-1: YouTube can replace a video + // at the same videoId with a shorter cut (live→VOD trim, premiere + // edit, channel replace) — without the clamp, setMediaItem seeks + // past the new end, ExoPlayer fires onPlayerError, the screen + // ends up stuck on the thumbnail+spinner (BUG-2 cascade). val effectiveStart = if (startPositionMs == 0L && Settings.get().autoResume.value) { val videoId = extractYtVideoId(streamUrl) - videoId?.let { Resume.get().get(it)?.positionMs } ?: 0L + val saved = videoId?.let { Resume.get().get(it) } + if (saved == null) { + 0L + } else { + val safeCeiling = saved.durationMs - 5_000L + if (saved.positionMs in 1L..safeCeiling) saved.positionMs else 0L + } } else { startPositionMs }