Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/nightly-notes/2026-11-14-feat-queue-select.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
## New

- Music queue: select several songs to play them next, add them to a playlist, download them or remove them together (#2084)
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,53 @@ fun SaveSongSheet(
)
}
}

/**
* Adds several songs to a playlist at once. Each playlist picked gets them all, once; a playlist that
* already had some keeps those where they were.
*/
@Composable
fun SaveSongsSheet(
tracks: List<MusicTrack>,
onDismiss: () -> Unit,
viewModel: SaveSongViewModel = hiltViewModel(),
) {
val playlists by viewModel.playlists.collectAsStateWithLifecycle()
val savedIds by viewModel.savedIds.collectAsStateWithLifecycle()
var showCreateDialog by remember { mutableStateOf(false) }

val entries =
playlists.map { playlist ->
CollectionSheetEntry(
id = playlist.id,
name = playlist.name,
supporting = pluralStringResource(R.plurals.songs_count_template, playlist.videoCount, playlist.videoCount),
thumbnailUrl = playlist.thumbnailUrl,
isSaved = playlist.id in savedIds,
)
}

SaveToCollectionSheet(
title = pluralStringResource(R.plurals.add_songs_to_playlist_title, tracks.size, tracks.size),
entries = entries,
placeholderIcon = Icons.Default.MusicNote,
createLabel = stringResource(R.string.create_new_playlist),
emptyLabel = stringResource(R.string.empty_playlists_dialog),
onToggle = { entry -> if (!entry.isSaved) viewModel.addAll(tracks, entry.id) },
onCreateNew = { showCreateDialog = true },
onDismiss = onDismiss,
)

if (showCreateDialog) {
CollectionEditDialog(
title = stringResource(R.string.title_create_playlist),
confirmLabel = stringResource(R.string.action_create),
icon = Icons.AutoMirrored.Filled.PlaylistAdd,
onDismiss = { showCreateDialog = false },
onConfirm = { name, description ->
viewModel.createAndAddAll(tracks, name, description)
showCreateDialog = false
},
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,9 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.min
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import io.github.aedev.flow.R
import io.github.aedev.flow.data.local.MusicPlayerBackgroundStyle
import io.github.aedev.flow.data.localmedia.LocalMediaIds
import io.github.aedev.flow.data.music.model.MusicTrack
Expand All @@ -44,7 +42,6 @@ import io.github.aedev.flow.ui.components.musicplayer.lyrics.lyricsBackdrop
import io.github.aedev.flow.ui.components.musicplayer.motion.MusicSideSwipeHud
import io.github.aedev.flow.ui.components.musicplayer.motion.musicSideSwipes
import io.github.aedev.flow.ui.components.musicplayer.motion.rememberMusicSideSwipes
import io.github.aedev.flow.ui.components.musicplayer.queue.QueueActions
import io.github.aedev.flow.ui.components.musicplayer.queue.QueueList
import io.github.aedev.flow.ui.components.musicplayer.queue.QueuePullUpSheet
import io.github.aedev.flow.ui.components.musicplayer.queue.QueueSheet
Expand All @@ -54,8 +51,6 @@ import io.github.aedev.flow.ui.components.shared.MediaPalette
import io.github.aedev.flow.ui.components.shared.MediaPlaybackControls
import io.github.aedev.flow.ui.components.shared.MediaSleepTimerSheet
import io.github.aedev.flow.ui.components.shared.SkipDirection
import io.github.aedev.flow.ui.components.shared.quickactions.QuickActionUndo
import io.github.aedev.flow.ui.components.shared.quickactions.sharedQuickActionsViewModel
import io.github.aedev.flow.ui.screens.music.MusicPlayerViewModel
import io.github.aedev.flow.ui.screens.music.MusicVideoSwitchViewModel
import io.github.aedev.flow.ui.screens.music.sharedMusicPlayerViewModel
Expand Down Expand Up @@ -153,27 +148,8 @@ internal fun FullMusicPlayerContent(
}
}

val quickActions = sharedQuickActionsViewModel()
val removedFromQueue = stringResource(R.string.removed_from_queue)
val queueSwipes by viewModel.queueSwipes.collectAsState()
val queueActions =
remember(viewModel, removedFromQueue) {
QueueActions(
onTrackClick = { viewModel.playFromQueue(it) },
onMoveTrack = { from, to -> viewModel.moveTrack(from, to) },
onPlayNextFromQueue = { viewModel.playNextFromQueuePosition(it) },
onSendQueueTrackToEnd = { viewModel.moveQueueTrackToEnd(it) },
onRemoveFromQueue = { index ->
viewModel.removeFromQueue(index)?.let { quickActions.announce(removedFromQueue, QuickActionUndo.MusicQueueRemoval(it)) }
},
onRadioTrackClick = { viewModel.playRadioTrack(it) },
onPlayNextRadio = { viewModel.playNextFromRadio(it) },
onAddRadioToQueue = { viewModel.addRadioTrackToQueue(it) },
onToggleEndlessRadio = { viewModel.setEndlessRadioEnabled(it) },
onShuffleQueue = { viewModel.toggleShuffle() },
onCycleRepeat = { viewModel.toggleRepeat() },
)
}
val queueActions = rememberPlayerQueueActions(viewModel, sheets)

val immersiveBackground = backgroundStyle == MusicPlayerBackgroundStyle.IMMERSIVE
val displayTitle = previewTrack?.title ?: uiState.currentTrack?.title ?: track.title
Expand Down Expand Up @@ -410,6 +386,7 @@ internal fun FullMusicPlayerContent(
swipes = queueSwipes,
actions = queueActions,
clearNavigationBar = false,
selectEntry = true,
)
},
lyrics = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import io.github.aedev.flow.ui.components.music.sheet.CrossfadePauseRow
import io.github.aedev.flow.ui.components.music.sheet.MusicArtistsSheet
import io.github.aedev.flow.ui.components.music.sheet.MusicQuickActionsSheet
import io.github.aedev.flow.ui.components.music.sheet.SaveSongSheet
import io.github.aedev.flow.ui.components.music.sheet.SaveSongsSheet
import io.github.aedev.flow.ui.components.musicplayer.sheet.AudioSettingsSheet
import io.github.aedev.flow.ui.screens.music.CrossfadePauseViewModel

Expand All @@ -22,6 +23,7 @@ import io.github.aedev.flow.ui.screens.music.CrossfadePauseViewModel
internal class FullPlayerSheetState {
var moreOptions by mutableStateOf(false)
var save by mutableStateOf(false)
var saveTracks by mutableStateOf(emptyList<MusicTrack>())
var audioSettings by mutableStateOf(false)
var sleepTimer by mutableStateOf(false)
var artists by mutableStateOf(emptyList<MusicArtist>())
Expand All @@ -40,6 +42,13 @@ internal fun FullPlayerSheets(
)
}

if (state.saveTracks.isNotEmpty()) {
SaveSongsSheet(
tracks = state.saveTracks,
onDismiss = { state.saveTracks = emptyList() },
)
}

if (state.moreOptions && currentTrack != null) {
val crossfade: CrossfadePauseViewModel = hiltViewModel()
val crossfadeOn by crossfade.crossfadeOn.collectAsStateWithLifecycle()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package io.github.aedev.flow.ui.components.musicplayer.full

import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.stringResource
import io.github.aedev.flow.R
import io.github.aedev.flow.ui.components.musicplayer.queue.QueueActions
import io.github.aedev.flow.ui.components.shared.quickactions.QuickActionUndo
import io.github.aedev.flow.ui.components.shared.quickactions.sharedQuickActionsViewModel
import io.github.aedev.flow.ui.screens.music.MusicPlayerViewModel

/** What the player's queue does with its rows; removals are announced, with undo for a single song. */
@Composable
internal fun rememberPlayerQueueActions(
viewModel: MusicPlayerViewModel,
sheets: FullPlayerSheetState,
): QueueActions {
val quickActions = sharedQuickActionsViewModel()
val removedFromQueue = stringResource(R.string.removed_from_queue)
val resources = LocalResources.current
return remember(viewModel, quickActions, removedFromQueue, resources, sheets) {
QueueActions(
onTrackClick = { viewModel.playFromQueue(it) },
onMoveTrack = { from, to -> viewModel.moveTrack(from, to) },
onPlayNextFromQueue = { viewModel.playNextFromQueuePosition(it) },
onSendQueueTrackToEnd = { viewModel.moveQueueTrackToEnd(it) },
onRemoveFromQueue = { index ->
viewModel.removeFromQueue(index)?.let { quickActions.announce(removedFromQueue, QuickActionUndo.MusicQueueRemoval(it)) }
},
onRadioTrackClick = { viewModel.playRadioTrack(it) },
onPlayNextRadio = { viewModel.playNextFromRadio(it) },
onAddRadioToQueue = { viewModel.addRadioTrackToQueue(it) },
onToggleEndlessRadio = { viewModel.setEndlessRadioEnabled(it) },
onShuffleQueue = { viewModel.toggleShuffle() },
onCycleRepeat = { viewModel.toggleRepeat() },
onRemoveTracks = { indices ->
val removed = indices.count { viewModel.removeFromQueue(it) != null }
if (removed > 0) {
quickActions.announce(resources.getQuantityString(R.plurals.songs_removed_from_queue, removed, removed))
}
},
onDownloadTracks = { viewModel.downloadTracks(it) },
onSaveTracks = { sheets.saveTracks = it },
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
package io.github.aedev.flow.ui.components.musicplayer.queue

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Checklist
import androidx.compose.material.icons.rounded.Repeat
import androidx.compose.material.icons.rounded.RepeatOne
import androidx.compose.material.icons.rounded.Shuffle
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.github.aedev.flow.R
import io.github.aedev.flow.player.RepeatMode

/** Title, track count, Select, and the shuffle and repeat pair shared by the sheet and the side pane. */
@Composable
internal fun QueueHeader(
trackCount: Int,
shuffleEnabled: Boolean,
repeatMode: RepeatMode,
onShuffleQueue: () -> Unit,
onCycleRepeat: () -> Unit,
onSelect: () -> Unit,
modifier: Modifier = Modifier,
) {
val haptics = LocalHapticFeedback.current
Row(
modifier =
modifier
.fillMaxWidth()
.padding(start = 22.dp, end = 16.dp, bottom = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(R.string.up_next),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
letterSpacing = 1.sp,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = pluralStringResource(R.plurals.queue_track_count, trackCount, trackCount),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
IconButton(onClick = onSelect) {
Icon(Icons.Rounded.Checklist, contentDescription = stringResource(R.string.select_songs))
}
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
FilledTonalIconButton(
onClick = {
haptics.performHapticFeedback(HapticFeedbackType.ContextClick)
onShuffleQueue()
},
modifier = Modifier.size(width = 52.dp, height = 42.dp),
shape =
RoundedCornerShape(
topStart = 21.dp,
bottomStart = 21.dp,
topEnd = 6.dp,
bottomEnd = 6.dp,
),
colors =
IconButtonDefaults.filledTonalIconButtonColors(
containerColor =
if (shuffleEnabled) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.secondaryContainer
},
contentColor =
if (shuffleEnabled) {
MaterialTheme.colorScheme.onPrimary
} else {
MaterialTheme.colorScheme.onSecondaryContainer
},
),
) {
Icon(
imageVector = Icons.Rounded.Shuffle,
contentDescription = stringResource(R.string.shuffle),
modifier = Modifier.size(20.dp),
)
}
val repeatActive = repeatMode != RepeatMode.OFF
FilledTonalIconButton(
onClick = onCycleRepeat,
modifier = Modifier.size(width = 52.dp, height = 42.dp),
shape =
RoundedCornerShape(
topStart = 6.dp,
bottomStart = 6.dp,
topEnd = 21.dp,
bottomEnd = 21.dp,
),
colors =
IconButtonDefaults.filledTonalIconButtonColors(
containerColor =
if (repeatActive) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.secondaryContainer
},
contentColor =
if (repeatActive) {
MaterialTheme.colorScheme.onPrimary
} else {
MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.6f)
},
),
) {
Icon(
imageVector =
if (repeatMode == RepeatMode.ONE) {
Icons.Rounded.RepeatOne
} else {
Icons.Rounded.Repeat
},
contentDescription = stringResource(R.string.repeat),
modifier = Modifier.size(20.dp),
)
}
}
}
}
Loading
Loading