vc=47: queue — Play next + Add to queue from long-press menu

Now you can line up videos. Long-press → "Play next" inserts right
after the current playing item; "Add to queue" appends to the back.
Media3 auto-advances through the queue when each item ends.

Implementation:

  * feature/player/Queue.kt — process-wide MutableStateFlow<List<
    NowPlayingItem>> that mirrors the controller's MediaItem list
    1:1 by index. Append, insertAt, setAll mutators match how the
    controller's media items get mutated.

  * StrawMediaController.enqueueNext / enqueueLast — build the
    MediaItem (same shape as setPlayingFrom), insert into Queue at
    the corresponding index, then controller.addMediaItem. Empty-
    queue fallback: route through setPlayingFrom (starts playback
    immediately) so the user doesn't get a silent no-op.

  * PlaybackService Player.Listener.onMediaItemTransition — on auto-
    advance, look up the new index in Queue, push the matching
    NowPlayingItem into NowPlaying via claim(). Minibar + the SB
    skip-loop (both reactive to NowPlaying.current) reflect the
    new track without any extra wiring.

  * feature/detail/StreamResolution.kt — extracted the
    resolveStreamPlayback function out of VideoDetailViewModel so
    the queue path can call it for each new item.

  * VideoActionsSheet wires Play next / Add to queue rows that
    launch into StrawApp.globalScope (process-scoped) — sheet
    dismisses immediately for snappy UX, but the strawcore network
    resolve lives in a scope that outlives the sheet. Toast on
    completion.

  * StrawApp exposes appScope via a companion `globalScope` for
    fire-and-forget work that needs to outlive Composition.

Known limitation:
  * SponsorBlock segments are not fetched for queued items — they
    play through without skips. The originally-played item (added
    via setPlayingFrom) still gets SB. Folding in lazy per-item SB
    fetch on transition is a follow-up.
This commit is contained in:
Kayos 2026-05-26 05:39:19 -07:00
parent 406fd8924a
commit 02381edf03
8 changed files with 278 additions and 41 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 = 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"

View file

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

View file

@ -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<SbSegment> = emptyList(),
): ResolvedPlayback {
val maxRes = Settings.get().maxResolution.value.ceiling
fun pickVideo(streams: List<uniffi.strawcore.VideoStreamItem>): 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,
)
}

View file

@ -280,33 +280,5 @@ class VideoDetailViewModel : ViewModel() {
private fun resolvePlayback(
info: uniffi.strawcore.StreamInfo,
segments: List<SbSegment>,
): 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<uniffi.strawcore.VideoStreamItem>): 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)
}

View file

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

View file

@ -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<List<NowPlayingItem>>(emptyList())
val items: StateFlow<List<NowPlayingItem>> = _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()
}
}

View file

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

View file

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