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:
parent
406fd8924a
commit
02381edf03
8 changed files with 278 additions and 41 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 = 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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue