diff --git a/buildSrc/src/main/kotlin/ProjectConfig.kt b/buildSrc/src/main/kotlin/ProjectConfig.kt index cd111e2a1..d0bd6a3da 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 = 46 -const val STRAW_VERSION_NAME = "0.1.0-BF" +const val STRAW_VERSION_CODE = 47 +const val STRAW_VERSION_NAME = "0.1.0-BG" const val STRAW_APPLICATION_ID = "com.sulkta.straw" diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt index 6634cee17..2868d58ac 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt @@ -35,6 +35,22 @@ class StrawApp : Application() { }, ) + companion object { + /** Process-scoped coroutine scope — survives Composition + ViewModel + * teardown. Use for fire-and-forget work like long-press + * "Add to queue" that needs to outlive the UI surface that + * triggered it. */ + lateinit var globalScope: CoroutineScope + private set + } + + init { + // The companion lateinit guarantees the same StrawApp instance + // is the only one that sets globalScope — Application is a + // process-singleton on Android. + globalScope = appScope + } + override fun onCreate() { super.onCreate() // Path C-7: route Rust `log::*` calls into Android logcat under tag diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/StreamResolution.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/StreamResolution.kt new file mode 100644 index 000000000..05325bffd --- /dev/null +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/StreamResolution.kt @@ -0,0 +1,47 @@ +/* + * SPDX-FileCopyrightText: 2026 Sulkta-Coop + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Pick the playable URLs from a strawcore StreamInfo. Lives outside + * VideoDetailViewModel so the queue path can call it too. + */ + +package com.sulkta.straw.feature.detail + +import com.sulkta.straw.data.Settings +import com.sulkta.straw.net.SbSegment + +/** + * Convert a raw strawcore.StreamInfo into the picked-URL DTO the + * MediaController wants. Honors Settings.maxResolution — cap-fit if + * possible, otherwise the closest-to-cap fallback (lowest height) so + * we don't blow a user's data plan when only above-cap streams exist. + * + * `segments` is the SponsorBlock list to bake into the resulting + * ResolvedPlayback; pass emptyList() when no SB is desired (the queue + * path doesn't pre-fetch SB for queued items). + */ +fun resolveStreamPlayback( + info: uniffi.strawcore.StreamInfo, + segments: List = emptyList(), +): ResolvedPlayback { + val maxRes = Settings.get().maxResolution.value.ceiling + fun pickVideo(streams: List): String? { + if (streams.isEmpty()) return null + val capped = streams.filter { it.height <= maxRes } + return if (capped.isNotEmpty()) { + capped.maxByOrNull { it.bitrate }?.url + } else { + streams.minByOrNull { it.height }?.url + } + } + return ResolvedPlayback( + title = info.title, + videoUrl = pickVideo(info.videoOnly), + audioUrl = info.audioOnly.maxByOrNull { it.bitrate }?.url, + combinedUrl = pickVideo(info.combined), + dashMpdUrl = info.dashMpdUrl?.takeIf { it.isNotBlank() }, + hlsUrl = info.hlsUrl?.takeIf { it.isNotBlank() }, + segments = segments, + ) +} 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 7c11976f9..45336a6bc 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 @@ -280,33 +280,5 @@ class VideoDetailViewModel : ViewModel() { private fun resolvePlayback( info: uniffi.strawcore.StreamInfo, segments: List, - ): ResolvedPlayback { - val maxRes = Settings.get().maxResolution.value.ceiling - // Pick the highest-bitrate stream that still fits the user's - // cap. Fallback: when every available stream EXCEEDS the cap - // (e.g. a 1080p-only upload with the user on a 480p cap), pick - // the LOWEST-height one — that's the closest-to-cap option and - // honors the user's intent ("don't blow my data plan") even - // when their exact target isn't available. vc=34 audit Q-8 — - // previously this fell back to max-bitrate, which was the - // worst possible choice for someone on a 480p cap. - fun pickVideo(streams: List): String? { - if (streams.isEmpty()) return null - val capped = streams.filter { it.height <= maxRes } - return if (capped.isNotEmpty()) { - capped.maxByOrNull { it.bitrate }?.url - } else { - streams.minByOrNull { it.height }?.url - } - } - return ResolvedPlayback( - title = info.title, - videoUrl = pickVideo(info.videoOnly), - audioUrl = info.audioOnly.maxByOrNull { it.bitrate }?.url, - combinedUrl = pickVideo(info.combined), - dashMpdUrl = info.dashMpdUrl?.takeIf { it.isNotBlank() }, - hlsUrl = info.hlsUrl?.takeIf { it.isNotBlank() }, - segments = segments, - ) - } + ): ResolvedPlayback = resolveStreamPlayback(info, segments) } 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 95902fa6d..7af5a161f 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 @@ -98,6 +98,21 @@ class PlaybackService : MediaSessionService() { .setId(MEDIA_SESSION_ID) .setSessionActivity(sessionActivityIntent) .build() + + // Queue auto-advance bridge: when Media3 transitions to the + // next item in the queue, look up the matching NowPlayingItem + // (with original streamUrl, uploader, thumbnail, SB segments) + // and push it into NowPlaying so the minibar + SponsorBlock + // skip-loop reflect the new track. claim() handles concurrent + // setPlayingFrom races — see vc=35 audit HIGH-C6. + player.addListener(object : Player.Listener { + override fun onMediaItemTransition(item: MediaItem?, reason: Int) { + if (item == null) return + val idx = player.currentMediaItemIndex + val queued = Queue.at(idx) ?: return + NowPlaying.claim(queued) + } + }) } override fun onGetSession( diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/Queue.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/Queue.kt new file mode 100644 index 000000000..eb6a68618 --- /dev/null +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/Queue.kt @@ -0,0 +1,61 @@ +/* + * SPDX-FileCopyrightText: 2026 Sulkta-Coop + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Process-wide upcoming-videos queue. Mirrors the MediaController's + * MediaItem list 1:1 by index — Position 0 is "currently playing", + * Position 1+ is "up next". Decoupled from the controller because: + * + * - The controller stores MediaItem (URL + Media3 metadata only). + * We need the original streamUrl, uploader, thumbnail, and + * SponsorBlock segments. NowPlayingItem carries all of that. + * - Media3's onMediaItemTransition fires when the engine auto- + * advances. PlaybackService listens, looks up the new index here, + * and pushes the resolved NowPlayingItem into NowPlaying so the + * minibar + SponsorBlock skip-loop reflect the new track. + * + * Append-only + setAll: no remove/reorder for v1. Mirrors how + * `addMediaItem` / `setMediaItem` mutate the controller. If we ever + * add a queue UI with drag-reorder, that'll need a sync layer. + */ + +package com.sulkta.straw.feature.player + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +object Queue { + private val _items = MutableStateFlow>(emptyList()) + val items: StateFlow> = _items.asStateFlow() + + /** Replace the queue — used by setPlayingFrom when starting fresh. */ + fun setAll(item: NowPlayingItem) { + _items.value = listOf(item) + } + + fun append(item: NowPlayingItem) { + _items.update { it + item } + } + + /** + * Insert at the given position (relative to the controller's + * indices). Used by "Play next" — inserts right after the + * currently-playing item. + */ + fun insertAt(index: Int, item: NowPlayingItem) { + _items.update { current -> + val mut = current.toMutableList() + mut.add(index.coerceIn(0, mut.size), item) + mut.toList() + } + } + + /** Read the item at the given controller index, or null on OOB. */ + fun at(index: Int): NowPlayingItem? = _items.value.getOrNull(index) + + fun clear() { + _items.value = emptyList() + } +} 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 5c3013c5a..127488aa8 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 @@ -83,25 +83,86 @@ fun MediaController.setPlayingFrom( startPositionMs: Long = 0L, ) { val mediaItem = buildMediaItem(title, uploader, thumbnail, resolved) ?: return + val nowPlayingItem = NowPlayingItem( + streamUrl = streamUrl, + title = title, + uploader = uploader, + thumbnail = thumbnail, + segments = resolved.segments, + ) // Atomic claim BEFORE any controller mutation. If a concurrent // caller already set this URL (inline player + fullscreen Player // racing each other on the same transition), we bail before // double-priming the player. vc=35 audit HIGH-C6. - val claimed = NowPlaying.claim( - NowPlayingItem( - streamUrl = streamUrl, - title = title, - uploader = uploader, - thumbnail = thumbnail, - segments = resolved.segments, - ), - ) + val claimed = NowPlaying.claim(nowPlayingItem) if (!claimed) return + // Replace the queue when starting fresh — Queue mirrors the + // controller's MediaItem list 1:1 by index. If the user later + // long-press-enqueues more items, append/insertAt keep them + // synced. + Queue.setAll(nowPlayingItem) setMediaItem(mediaItem, startPositionMs) prepare() playWhenReady = true } +/** + * Add a video to the playback queue right after the currently-playing + * item. If the player is idle (no current item), fall through to a + * setPlayingFrom that starts playback immediately. The caller already + * resolved playback (strawcore.streamInfo → ResolvedPlayback). + * + * Returns true if the item was enqueued or started; false on a build + * failure (no playable stream in `resolved`). + */ +@UnstableApi +fun MediaController.enqueueNext( + streamUrl: String, + title: String, + uploader: String, + thumbnail: String?, + resolved: ResolvedPlayback, +): Boolean = enqueueInternal(streamUrl, title, uploader, thumbnail, resolved, asNext = true) + +/** Append to the back of the queue. Same idle-fallback as enqueueNext. */ +@UnstableApi +fun MediaController.enqueueLast( + streamUrl: String, + title: String, + uploader: String, + thumbnail: String?, + resolved: ResolvedPlayback, +): Boolean = enqueueInternal(streamUrl, title, uploader, thumbnail, resolved, asNext = false) + +@UnstableApi +private fun MediaController.enqueueInternal( + streamUrl: String, + title: String, + uploader: String, + thumbnail: String?, + resolved: ResolvedPlayback, + asNext: Boolean, +): Boolean { + val mediaItem = buildMediaItem(title, uploader, thumbnail, resolved) ?: return false + val item = NowPlayingItem( + streamUrl = streamUrl, + title = title, + uploader = uploader, + thumbnail = thumbnail, + segments = resolved.segments, + ) + // Empty queue — there's nothing to "enqueue" onto. Treat as a + // start-playing-now and route through the normal claim path. + if (mediaItemCount == 0) { + setPlayingFrom(streamUrl, title, uploader, thumbnail, resolved) + return true + } + val insertIndex = if (asNext) currentMediaItemIndex + 1 else mediaItemCount + Queue.insertAt(insertIndex, item) + addMediaItem(insertIndex, mediaItem) + return true +} + @UnstableApi private fun buildMediaItem( title: String, diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/playlist/VideoActions.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/playlist/VideoActions.kt index e57433666..51138d738 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/playlist/VideoActions.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/playlist/VideoActions.kt @@ -28,6 +28,8 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.PlaylistAdd +import androidx.compose.material.icons.filled.PlaylistPlay +import androidx.compose.material.icons.filled.QueueMusic import androidx.compose.material.icons.filled.Share import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button @@ -51,8 +53,18 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp +import androidx.media3.common.util.UnstableApi +import com.sulkta.straw.StrawApp import com.sulkta.straw.data.PlaylistItem import com.sulkta.straw.data.Playlists +import com.sulkta.straw.feature.detail.resolveStreamPlayback +import com.sulkta.straw.feature.player.LocalStrawController +import com.sulkta.straw.feature.player.enqueueLast +import com.sulkta.straw.feature.player.enqueueNext +import com.sulkta.straw.util.runCatchingCancellable +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext /** * Minimal video descriptor for the actions sheet. Avoids dragging @@ -67,16 +79,59 @@ data class VideoActionTarget( val thumbnail: String?, ) -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, UnstableApi::class) @Composable fun VideoActionsSheet( target: VideoActionTarget, onDismiss: () -> Unit, ) { val context = LocalContext.current + val controller = LocalStrawController.current + // Use the process scope — rememberCoroutineScope dies when the + // sheet dismisses, and we dismiss the sheet BEFORE the strawcore + // network round-trip completes (so the user gets immediate + // feedback). Process scope keeps the in-flight resolve alive. val sheetState = rememberModalBottomSheetState() var showSaveDialog by remember { mutableStateOf(false) } + /** Resolve a streamUrl → ResolvedPlayback + call the supplied + * enqueue method. Network resolution is the slow part; wrap it + * in runCatchingCancellable so the rememberCoroutineScope dying + * on sheet-dismiss propagates cleanly. */ + fun enqueue(asNext: Boolean) { + val c = controller + if (c == null) { + Toast.makeText(context, "player not ready yet", Toast.LENGTH_SHORT).show() + return + } + Toast.makeText(context, "Resolving…", Toast.LENGTH_SHORT).show() + val appContext = context.applicationContext + onDismiss() + StrawApp.globalScope.launch { + runCatchingCancellable { + val info = uniffi.strawcore.streamInfo(target.streamUrl) + val resolved = resolveStreamPlayback(info) + withContext(Dispatchers.Main) { + val ok = if (asNext) { + c.enqueueNext(target.streamUrl, target.title, target.uploader, target.thumbnail, resolved) + } else { + c.enqueueLast(target.streamUrl, target.title, target.uploader, target.thumbnail, resolved) + } + val msg = if (ok) { + if (asNext) "Will play next" else "Added to queue" + } else { + "no playable stream" + } + Toast.makeText(appContext, msg, Toast.LENGTH_SHORT).show() + } + }.onFailure { + withContext(Dispatchers.Main) { + Toast.makeText(appContext, "queue failed", Toast.LENGTH_SHORT).show() + } + } + } + } + if (showSaveDialog) { SaveToPlaylistDialog( item = PlaylistItem( @@ -116,6 +171,16 @@ fun VideoActionsSheet( } Spacer(modifier = Modifier.height(8.dp)) HorizontalDivider() + ActionRow( + icon = Icons.Filled.PlaylistPlay, + label = "Play next", + onClick = { enqueue(asNext = true) }, + ) + ActionRow( + icon = Icons.Filled.QueueMusic, + label = "Add to queue", + onClick = { enqueue(asNext = false) }, + ) ActionRow( icon = Icons.Filled.PlaylistAdd, label = "Save to playlist",