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
|
// vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via
|
||||||
// strawcore-core (Sulkta-Coop/strawcore) via the UniFFI wrapper; no
|
// strawcore-core (Sulkta-Coop/strawcore) via the UniFFI wrapper; no
|
||||||
// NewPipeExtractor in the runtime path.
|
// NewPipeExtractor in the runtime path.
|
||||||
const val STRAW_VERSION_CODE = 46
|
const val STRAW_VERSION_CODE = 47
|
||||||
const val STRAW_VERSION_NAME = "0.1.0-BF"
|
const val STRAW_VERSION_NAME = "0.1.0-BG"
|
||||||
const val STRAW_APPLICATION_ID = "com.sulkta.straw"
|
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() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
// Path C-7: route Rust `log::*` calls into Android logcat under tag
|
// 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(
|
private fun resolvePlayback(
|
||||||
info: uniffi.strawcore.StreamInfo,
|
info: uniffi.strawcore.StreamInfo,
|
||||||
segments: List<SbSegment>,
|
segments: List<SbSegment>,
|
||||||
): ResolvedPlayback {
|
): ResolvedPlayback = resolveStreamPlayback(info, segments)
|
||||||
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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,21 @@ class PlaybackService : MediaSessionService() {
|
||||||
.setId(MEDIA_SESSION_ID)
|
.setId(MEDIA_SESSION_ID)
|
||||||
.setSessionActivity(sessionActivityIntent)
|
.setSessionActivity(sessionActivityIntent)
|
||||||
.build()
|
.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(
|
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,
|
startPositionMs: Long = 0L,
|
||||||
) {
|
) {
|
||||||
val mediaItem = buildMediaItem(title, uploader, thumbnail, resolved) ?: return
|
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
|
// Atomic claim BEFORE any controller mutation. If a concurrent
|
||||||
// caller already set this URL (inline player + fullscreen Player
|
// caller already set this URL (inline player + fullscreen Player
|
||||||
// racing each other on the same transition), we bail before
|
// racing each other on the same transition), we bail before
|
||||||
// double-priming the player. vc=35 audit HIGH-C6.
|
// double-priming the player. vc=35 audit HIGH-C6.
|
||||||
val claimed = NowPlaying.claim(
|
val claimed = NowPlaying.claim(nowPlayingItem)
|
||||||
NowPlayingItem(
|
|
||||||
streamUrl = streamUrl,
|
|
||||||
title = title,
|
|
||||||
uploader = uploader,
|
|
||||||
thumbnail = thumbnail,
|
|
||||||
segments = resolved.segments,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if (!claimed) return
|
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)
|
setMediaItem(mediaItem, startPositionMs)
|
||||||
prepare()
|
prepare()
|
||||||
playWhenReady = true
|
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
|
@UnstableApi
|
||||||
private fun buildMediaItem(
|
private fun buildMediaItem(
|
||||||
title: String,
|
title: String,
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,8 @@ import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.PlaylistAdd
|
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.material.icons.filled.Share
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
|
|
@ -51,8 +53,18 @@ import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.unit.dp
|
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.PlaylistItem
|
||||||
import com.sulkta.straw.data.Playlists
|
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
|
* Minimal video descriptor for the actions sheet. Avoids dragging
|
||||||
|
|
@ -67,16 +79,59 @@ data class VideoActionTarget(
|
||||||
val thumbnail: String?,
|
val thumbnail: String?,
|
||||||
)
|
)
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class, UnstableApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun VideoActionsSheet(
|
fun VideoActionsSheet(
|
||||||
target: VideoActionTarget,
|
target: VideoActionTarget,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
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()
|
val sheetState = rememberModalBottomSheetState()
|
||||||
var showSaveDialog by remember { mutableStateOf(false) }
|
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) {
|
if (showSaveDialog) {
|
||||||
SaveToPlaylistDialog(
|
SaveToPlaylistDialog(
|
||||||
item = PlaylistItem(
|
item = PlaylistItem(
|
||||||
|
|
@ -116,6 +171,16 @@ fun VideoActionsSheet(
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
HorizontalDivider()
|
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(
|
ActionRow(
|
||||||
icon = Icons.Filled.PlaylistAdd,
|
icon = Icons.Filled.PlaylistAdd,
|
||||||
label = "Save to playlist",
|
label = "Save to playlist",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue