From ee6a3718c75a17f0ea91cf419a27a76a0619ef1d Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Sat, 14 Nov 2026 20:01:31 +0200 Subject: [PATCH] feat(music): select songs in the queue --- .../2026-11-14-feat-queue-select.md | 3 + .../components/music/sheet/SaveSongSheet.kt | 50 ++ .../full/FullMusicPlayerContent.kt | 27 +- .../musicplayer/full/FullPlayerSheets.kt | 9 + .../musicplayer/full/PlayerQueueActions.kt | 47 ++ .../musicplayer/queue/QueueHeader.kt | 146 ++++++ .../components/musicplayer/queue/QueueList.kt | 453 ++++++++---------- .../musicplayer/queue/QueueRowMenu.kt | 3 + .../musicplayer/queue/QueueRowMenuAction.kt | 2 + .../musicplayer/queue/QueueSelection.kt | 149 ++++++ .../musicplayer/queue/QueueSheet.kt | 4 + .../musicplayer/queue/QueueTrackRow.kt | 21 +- .../screens/music/MusicPlayerTrackActions.kt | 26 + .../ui/screens/music/MusicPlayerViewModel.kt | 2 + .../ui/screens/music/SaveSongViewModel.kt | 26 + app/src/main/res/values/strings.xml | 9 + .../queue/QueueRowMenuActionsTest.kt | 3 +- .../musicplayer/queue/QueueSelectionTest.kt | 65 +++ 18 files changed, 777 insertions(+), 268 deletions(-) create mode 100644 .github/nightly-notes/2026-11-14-feat-queue-select.md create mode 100644 app/src/main/java/io/github/aedev/flow/ui/components/musicplayer/full/PlayerQueueActions.kt create mode 100644 app/src/main/java/io/github/aedev/flow/ui/components/musicplayer/queue/QueueHeader.kt create mode 100644 app/src/main/java/io/github/aedev/flow/ui/components/musicplayer/queue/QueueSelection.kt create mode 100644 app/src/test/java/io/github/aedev/flow/ui/components/musicplayer/queue/QueueSelectionTest.kt diff --git a/.github/nightly-notes/2026-11-14-feat-queue-select.md b/.github/nightly-notes/2026-11-14-feat-queue-select.md new file mode 100644 index 000000000..cd1ad7d32 --- /dev/null +++ b/.github/nightly-notes/2026-11-14-feat-queue-select.md @@ -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) diff --git a/app/src/main/java/io/github/aedev/flow/ui/components/music/sheet/SaveSongSheet.kt b/app/src/main/java/io/github/aedev/flow/ui/components/music/sheet/SaveSongSheet.kt index f88f43f0f..2cf1735ad 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/components/music/sheet/SaveSongSheet.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/components/music/sheet/SaveSongSheet.kt @@ -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, + 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 + }, + ) + } +} diff --git a/app/src/main/java/io/github/aedev/flow/ui/components/musicplayer/full/FullMusicPlayerContent.kt b/app/src/main/java/io/github/aedev/flow/ui/components/musicplayer/full/FullMusicPlayerContent.kt index 33b9a6cce..a7350404d 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/components/musicplayer/full/FullMusicPlayerContent.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/components/musicplayer/full/FullMusicPlayerContent.kt @@ -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 @@ -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 @@ -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 @@ -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 @@ -410,6 +386,7 @@ internal fun FullMusicPlayerContent( swipes = queueSwipes, actions = queueActions, clearNavigationBar = false, + selectEntry = true, ) }, lyrics = { diff --git a/app/src/main/java/io/github/aedev/flow/ui/components/musicplayer/full/FullPlayerSheets.kt b/app/src/main/java/io/github/aedev/flow/ui/components/musicplayer/full/FullPlayerSheets.kt index 0bfb453f6..a0f333d5b 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/components/musicplayer/full/FullPlayerSheets.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/components/musicplayer/full/FullPlayerSheets.kt @@ -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 @@ -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()) var audioSettings by mutableStateOf(false) var sleepTimer by mutableStateOf(false) var artists by mutableStateOf(emptyList()) @@ -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() diff --git a/app/src/main/java/io/github/aedev/flow/ui/components/musicplayer/full/PlayerQueueActions.kt b/app/src/main/java/io/github/aedev/flow/ui/components/musicplayer/full/PlayerQueueActions.kt new file mode 100644 index 000000000..840cfc36b --- /dev/null +++ b/app/src/main/java/io/github/aedev/flow/ui/components/musicplayer/full/PlayerQueueActions.kt @@ -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 }, + ) + } +} diff --git a/app/src/main/java/io/github/aedev/flow/ui/components/musicplayer/queue/QueueHeader.kt b/app/src/main/java/io/github/aedev/flow/ui/components/musicplayer/queue/QueueHeader.kt new file mode 100644 index 000000000..5fe06cab7 --- /dev/null +++ b/app/src/main/java/io/github/aedev/flow/ui/components/musicplayer/queue/QueueHeader.kt @@ -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), + ) + } + } + } +} diff --git a/app/src/main/java/io/github/aedev/flow/ui/components/musicplayer/queue/QueueList.kt b/app/src/main/java/io/github/aedev/flow/ui/components/musicplayer/queue/QueueList.kt index ec9e11050..c54b59c67 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/components/musicplayer/queue/QueueList.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/components/musicplayer/queue/QueueList.kt @@ -1,5 +1,6 @@ package io.github.aedev.flow.ui.components.musicplayer.queue +import androidx.activity.compose.BackHandler import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.spring @@ -10,6 +11,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsPadding @@ -30,7 +32,9 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.Immutable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -55,6 +59,9 @@ import androidx.compose.ui.unit.sp import io.github.aedev.flow.R import io.github.aedev.flow.data.music.model.MusicTrack import io.github.aedev.flow.player.RepeatMode +import io.github.aedev.flow.ui.components.layout.FlowBottomInsets +import io.github.aedev.flow.ui.components.layout.LocalFlowBottomInsets +import io.github.aedev.flow.ui.components.shared.FlowSelectionToolbar import io.github.aedev.flow.ui.components.shared.FlowSwitch import sh.calvin.reorderable.ReorderableItem import sh.calvin.reorderable.rememberReorderableLazyListState @@ -73,11 +80,16 @@ internal class QueueActions( val onToggleEndlessRadio: (Boolean) -> Unit, val onShuffleQueue: () -> Unit, val onCycleRepeat: () -> Unit, + val onRemoveTracks: (List) -> Unit, + val onDownloadTracks: (List) -> Unit, + val onSaveTracks: (List) -> Unit, ) /** * The queue itself: hold and drag a row to reorder, swipe a row either way for what [swipes] sets - * for that side, then the endless-radio toggle and its suggestions. + * for that side, then the endless-radio toggle and its suggestions. While [selection] is active a + * tap picks a row instead, and the picked songs can be moved, saved, downloaded or removed together. + * [selectEntry] adds a Select row for a host with no header of its own. */ @Composable internal fun QueueList( @@ -92,6 +104,8 @@ internal fun QueueList( actions: QueueActions, modifier: Modifier = Modifier, clearNavigationBar: Boolean = true, + selection: QueueSelection = remember { QueueSelection() }, + selectEntry: Boolean = false, ) { val listState = rememberLazyListState() val haptics = LocalHapticFeedback.current @@ -101,14 +115,17 @@ internal fun QueueList( LaunchedEffect(queue) { localQueue.clear() localQueue.addAll(queue) - val seen = HashMap() localKeys.clear() - queue.forEach { track -> - val occurrence = (seen[track.videoId] ?: 0) + 1 - seen[track.videoId] = occurrence - localKeys.add("${track.videoId}#$occurrence") - } + localKeys.addAll(queueKeys(queue)) + selection.retain(localKeys) } + val selecting = selection.active + BackHandler(enabled = selecting) { selection.end() } + val picked = selection.keys.orEmpty() + + fun pickedIndices(): List = localKeys.indices.filter { localKeys[it] in picked } + + fun pickedTracks(): List = pickedIndices().mapNotNull { localQueue.getOrNull(it) } var dragInfo by remember { mutableStateOf?>(null) } val reorderableState = @@ -179,6 +196,10 @@ internal fun QueueList( QueueRowMenuAction.REMOVE -> { actions.onRemoveFromQueue(index) } + + QueueRowMenuAction.SELECT -> { + selection.start(localKeys.getOrNull(index)) + } } } @@ -186,140 +207,197 @@ internal fun QueueList( val moveToEndLabel = stringResource(R.string.queue_move_to_end) val removeLabel = stringResource(R.string.remove_from_queue) - LazyColumn( - state = listState, - modifier = modifier.fillMaxWidth(), - contentPadding = PaddingValues(horizontal = 14.dp), - verticalArrangement = Arrangement.spacedBy(6.dp), - ) { - itemsIndexed(localQueue, key = { index, _ -> localKeys.getOrNull(index) ?: index }) { index, track -> - ReorderableItem( - state = reorderableState, - key = localKeys.getOrNull(index) ?: index, - ) { isDragging -> - val scale by animateFloatAsState( - targetValue = if (isDragging) 1.02f else 1f, - animationSpec = - spring( - dampingRatio = Spring.DampingRatioNoBouncy, - stiffness = Spring.StiffnessMediumLow, - ), - label = "queueRowLift", - ) - val isCurrent = index == currentIndex - QueueTrackRow( - track = track, - isCurrent = isCurrent, - isPlaying = isPlaying, - isRadioItem = false, - isDragging = isDragging, - swipeEnabled = !isCurrent && !isDragging, - isDownloaded = downloadedTrackIds.contains(track.videoId), - leftSwipe = swipes.left.rowSwipe, - rightSwipe = swipes.right.rowSwipe, - rowKey = localKeys.getOrNull(index) ?: track.videoId, - onClick = { actions.onTrackClick(index) }, - onSwipe = { swipe -> onQueueSwipe(swipe, index) }, - onMenu = if (isCurrent) null else ({ menuTarget = QueueMenuTarget(track, index, isRadioItem = false) }), - modifier = - Modifier - .graphicsLayer { - scaleX = scale - scaleY = scale - }.semantics { - if (!isCurrent) { - customActions = - listOf( - playNextLabel to QueueRowSwipe.PLAY_NEXT, - moveToEndLabel to QueueRowSwipe.MOVE_TO_END, - removeLabel to QueueRowSwipe.REMOVE, - ).map { (label, swipe) -> - CustomAccessibilityAction(label) { - onQueueSwipe(swipe, index) - true - } - } - } - }.longPressDraggableHandle( - onDragStarted = { - haptics.performHapticFeedback(HapticFeedbackType.LongPress) - }, - onDragStopped = { - haptics.performHapticFeedback(HapticFeedbackType.GestureEnd) - }, - ), + Box(modifier = modifier.fillMaxWidth()) { + Column(modifier = Modifier.fillMaxSize()) { + if (selecting) { + QueueSelectionBar( + count = picked.size, + allPicked = localKeys.isNotEmpty() && picked.containsAll(localKeys), + onToggleAll = { selection.toggleAll(localKeys.toList()) }, + onClose = selection::end, + modifier = Modifier.padding(horizontal = 14.dp), ) + } else if (selectEntry) { + Box(modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp), contentAlignment = Alignment.CenterEnd) { + TextButton(onClick = { selection.start() }) { Text(stringResource(R.string.select_songs)) } + } } - } - - item(key = "endless_radio_toggle") { - Row( - modifier = - Modifier - .fillMaxWidth() - .animateItem() - .padding(top = 10.dp, bottom = 4.dp) - .clip(RoundedCornerShape(16.dp)) - .clickable { actions.onToggleEndlessRadio(!endlessRadioEnabled) } - .padding(horizontal = 14.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), + LazyColumn( + state = listState, + modifier = Modifier.fillMaxWidth().weight(1f), + contentPadding = PaddingValues(horizontal = 14.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), ) { - Icon( - imageVector = Icons.Rounded.Sensors, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(22.dp), - ) - Text( - text = stringResource(R.string.music_endless_radio_title), - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.weight(1f), - ) - FlowSwitch( - checked = endlessRadioEnabled, - onCheckedChange = actions.onToggleEndlessRadio, - ) - } - } + itemsIndexed(localQueue, key = { index, _ -> localKeys.getOrNull(index) ?: index }) { index, track -> + ReorderableItem( + state = reorderableState, + key = localKeys.getOrNull(index) ?: index, + enabled = !selecting, + ) { isDragging -> + val scale by animateFloatAsState( + targetValue = if (isDragging) 1.02f else 1f, + animationSpec = + spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, + ), + label = "queueRowLift", + ) + val isCurrent = index == currentIndex + val key = localKeys.getOrNull(index) ?: track.videoId + QueueTrackRow( + track = track, + isCurrent = isCurrent, + isPlaying = isPlaying, + isRadioItem = false, + isDragging = isDragging, + swipeEnabled = !isCurrent && !isDragging && !selecting, + isDownloaded = downloadedTrackIds.contains(track.videoId), + leftSwipe = swipes.left.rowSwipe, + rightSwipe = swipes.right.rowSwipe, + rowKey = localKeys.getOrNull(index) ?: track.videoId, + onClick = { if (selecting) selection.toggle(key) else actions.onTrackClick(index) }, + onSwipe = { swipe -> onQueueSwipe(swipe, index) }, + onMenu = if (isCurrent || selecting) null else ({ menuTarget = QueueMenuTarget(track, index, false) }), + selected = if (selecting) key in picked else null, + modifier = + Modifier + .graphicsLayer { + scaleX = scale + scaleY = scale + }.semantics { + if (!isCurrent) { + customActions = + listOf( + playNextLabel to QueueRowSwipe.PLAY_NEXT, + moveToEndLabel to QueueRowSwipe.MOVE_TO_END, + removeLabel to QueueRowSwipe.REMOVE, + ).map { (label, swipe) -> + CustomAccessibilityAction(label) { + onQueueSwipe(swipe, index) + true + } + } + } + }.longPressDraggableHandle( + onDragStarted = { + haptics.performHapticFeedback(HapticFeedbackType.LongPress) + }, + onDragStopped = { + haptics.performHapticFeedback(HapticFeedbackType.GestureEnd) + }, + ), + ) + } + } - if (endlessRadioEnabled) { - itemsIndexed(radioTracks, key = { _, track -> "radio:${track.videoId}" }) { _, track -> - Box(modifier = Modifier.animateItem()) { - QueueTrackRow( - track = track, - isCurrent = false, - isPlaying = isPlaying, - isRadioItem = true, - isDragging = false, - swipeEnabled = true, - isDownloaded = downloadedTrackIds.contains(track.videoId), - leftSwipe = QueueRowSwipe.PLAY_NEXT, - rightSwipe = QueueRowSwipe.ADD_TO_QUEUE, - rowKey = "radio:${track.videoId}", - onClick = { actions.onRadioTrackClick(track) }, - onSwipe = { swipe -> - if (swipe == QueueRowSwipe.PLAY_NEXT) actions.onPlayNextRadio(track) else actions.onAddRadioToQueue(track) - }, - onMenu = { menuTarget = QueueMenuTarget(track, index = -1, isRadioItem = true) }, - ) + if (!selecting) { + item(key = "endless_radio_toggle") { + Row( + modifier = + Modifier + .fillMaxWidth() + .animateItem() + .padding(top = 10.dp, bottom = 4.dp) + .clip(RoundedCornerShape(16.dp)) + .clickable { actions.onToggleEndlessRadio(!endlessRadioEnabled) } + .padding(horizontal = 14.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + imageVector = Icons.Rounded.Sensors, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(22.dp), + ) + Text( + text = stringResource(R.string.music_endless_radio_title), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + ) + FlowSwitch( + checked = endlessRadioEnabled, + onCheckedChange = actions.onToggleEndlessRadio, + ) + } + } + + if (endlessRadioEnabled) { + itemsIndexed(radioTracks, key = { _, track -> "radio:${track.videoId}" }) { _, track -> + Box(modifier = Modifier.animateItem()) { + QueueTrackRow( + track = track, + isCurrent = false, + isPlaying = isPlaying, + isRadioItem = true, + isDragging = false, + swipeEnabled = true, + isDownloaded = downloadedTrackIds.contains(track.videoId), + leftSwipe = QueueRowSwipe.PLAY_NEXT, + rightSwipe = QueueRowSwipe.ADD_TO_QUEUE, + rowKey = "radio:${track.videoId}", + onClick = { actions.onRadioTrackClick(track) }, + onSwipe = { swipe -> + if (swipe == QueueRowSwipe.PLAY_NEXT) { + actions.onPlayNextRadio(track) + } else { + actions.onAddRadioToQueue(track) + } + }, + onMenu = { menuTarget = QueueMenuTarget(track, index = -1, isRadioItem = true) }, + ) + } + } + if (isRadioLoading && radioTracks.isEmpty()) { + item(key = "radio_loading") { + RadioLoadingRow() + } + } + } } - } - if (isRadioLoading && radioTracks.isEmpty()) { - item(key = "radio_loading") { - RadioLoadingRow() + + item(key = "bottom_spacer") { + Spacer( + modifier = + Modifier + .then(if (clearNavigationBar) Modifier.navigationBarsPadding() else Modifier) + .height(if (selecting) SelectionToolbarRoom else 28.dp), + ) } } } - - item(key = "bottom_spacer") { - Spacer( + // The toolbar floats over the queue's own bottom edge, not above the app's bars under the player. + CompositionLocalProvider(LocalFlowBottomInsets provides FlowBottomInsets.None) { + FlowSelectionToolbar( + visible = selecting && picked.isNotEmpty(), + summary = pluralStringResource(R.plurals.selected_count_template, picked.size, picked.size), + actions = + queueSelectionActions( + onPlayNext = { + val moves = playNextMoves(pickedIndices(), currentIndex, localQueue.size) + moves.forEach { (from, to) -> actions.onMoveTrack(from, to) } + selection.end() + }, + onAddToPlaylist = { + actions.onSaveTracks(pickedTracks()) + selection.end() + }, + onDownload = { + actions.onDownloadTracks(pickedTracks()) + selection.end() + }, + onRemove = { + actions.onRemoveTracks(removalOrder(pickedIndices(), currentIndex)) + selection.end() + }, + ), modifier = Modifier - .then(if (clearNavigationBar) Modifier.navigationBarsPadding() else Modifier) - .height(28.dp), + .align(Alignment.BottomCenter) + .then(if (clearNavigationBar) Modifier.navigationBarsPadding() else Modifier), ) } } @@ -334,119 +412,12 @@ internal fun QueueList( } } +/** Room under the last row so the selection toolbar never covers it. */ +private val SelectionToolbarRoom = 96.dp + /** The row a queue menu is open for; a radio row has no queue index. */ private class QueueMenuTarget( val track: MusicTrack, val index: Int, val isRadioItem: Boolean, ) - -/** Title, track count, 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, - 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, - ) - } - 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), - ) - } - } - } -} diff --git a/app/src/main/java/io/github/aedev/flow/ui/components/musicplayer/queue/QueueRowMenu.kt b/app/src/main/java/io/github/aedev/flow/ui/components/musicplayer/queue/QueueRowMenu.kt index 55d80694d..3f504b045 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/components/musicplayer/queue/QueueRowMenu.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/components/musicplayer/queue/QueueRowMenu.kt @@ -5,6 +5,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.PlaylistPlay import androidx.compose.material.icons.automirrored.outlined.QueueMusic import androidx.compose.material.icons.outlined.PlaylistRemove +import androidx.compose.material.icons.rounded.Checklist import androidx.compose.material.icons.rounded.KeyboardArrowDown import androidx.compose.material.icons.rounded.KeyboardArrowUp import androidx.compose.material3.MaterialTheme @@ -69,6 +70,7 @@ private val QueueRowMenuAction.icon: ImageVector QueueRowMenuAction.MOVE_UP -> Icons.Rounded.KeyboardArrowUp QueueRowMenuAction.MOVE_DOWN -> Icons.Rounded.KeyboardArrowDown QueueRowMenuAction.REMOVE -> Icons.Outlined.PlaylistRemove + QueueRowMenuAction.SELECT -> Icons.Rounded.Checklist } private val QueueRowMenuAction.labelRes: Int @@ -80,4 +82,5 @@ private val QueueRowMenuAction.labelRes: Int QueueRowMenuAction.MOVE_DOWN -> R.string.move_down QueueRowMenuAction.MOVE_TO_END -> R.string.queue_move_to_end QueueRowMenuAction.REMOVE -> R.string.remove_from_queue + QueueRowMenuAction.SELECT -> R.string.select_songs } diff --git a/app/src/main/java/io/github/aedev/flow/ui/components/musicplayer/queue/QueueRowMenuAction.kt b/app/src/main/java/io/github/aedev/flow/ui/components/musicplayer/queue/QueueRowMenuAction.kt index a373e8d6c..e6ac31023 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/components/musicplayer/queue/QueueRowMenuAction.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/components/musicplayer/queue/QueueRowMenuAction.kt @@ -8,6 +8,7 @@ internal enum class QueueRowMenuAction { MOVE_DOWN, MOVE_TO_END, REMOVE, + SELECT, } /** A radio suggestion can only join the queue; a queued row moves within it or leaves it. */ @@ -27,5 +28,6 @@ internal fun queueRowMenuActions( add(QueueRowMenuAction.MOVE_TO_END) } add(QueueRowMenuAction.REMOVE) + add(QueueRowMenuAction.SELECT) } } diff --git a/app/src/main/java/io/github/aedev/flow/ui/components/musicplayer/queue/QueueSelection.kt b/app/src/main/java/io/github/aedev/flow/ui/components/musicplayer/queue/QueueSelection.kt new file mode 100644 index 000000000..90ab56526 --- /dev/null +++ b/app/src/main/java/io/github/aedev/flow/ui/components/musicplayer/queue/QueueSelection.kt @@ -0,0 +1,149 @@ +package io.github.aedev.flow.ui.components.musicplayer.queue + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.PlaylistAdd +import androidx.compose.material.icons.rounded.CheckBox +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.Download +import androidx.compose.material.icons.rounded.QueuePlayNext +import androidx.compose.material.icons.rounded.RemoveCircleOutline +import androidx.compose.material.icons.rounded.SelectAll +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import io.github.aedev.flow.R +import io.github.aedev.flow.data.music.model.MusicTrack +import io.github.aedev.flow.ui.components.shared.FlowSelectionAction + +/** Which queue rows are picked, by their [queueKeys]; null while the queue isn't in select mode. */ +@Stable +internal class QueueSelection { + var keys by mutableStateOf?>(null) + private set + + val active: Boolean get() = keys != null + + fun start(key: String? = null) { + keys = setOfNotNull(key) + } + + fun toggle(key: String) { + keys = keys?.let { if (key in it) it - key else it + key } + } + + /** Picks every row, or none when every row already is. */ + fun toggleAll(all: List) { + keys = if (keys.orEmpty().containsAll(all)) emptySet() else all.toSet() + } + + /** Drops picks whose rows have left the queue. */ + fun retain(present: List) { + keys = keys?.intersect(present.toSet()) + } + + fun end() { + keys = null + } +} + +/** A key per queued song that survives reordering: its id, told apart for duplicates. */ +internal fun queueKeys(queue: List): List { + val seen = HashMap() + return queue.map { track -> + val occurrence = (seen[track.videoId] ?: 0) + 1 + seen[track.videoId] = occurrence + "${track.videoId}#$occurrence" + } +} + +/** + * The moves, each as (from, to) for `add(to, removeAt(from))`, that put the picked rows straight + * after the playing one in their queue order. The playing row itself is never moved. + */ +internal fun playNextMoves( + picked: List, + current: Int, + size: Int, +): List> { + val order = MutableList(size) { it } + val moves = mutableListOf>() + picked + .filter { it != current && it in 0 until size } + .sorted() + .forEachIndexed { placed, original -> + val from = order.indexOf(original) + val item = order.removeAt(from) + val to = order.indexOf(current) + 1 + placed + order.add(to, item) + if (from != to) moves += from to to + } + return moves +} + +/** The picked rows to remove, last first so each index still points at its row; never the playing one. */ +internal fun removalOrder( + picked: List, + current: Int, +): List = picked.filter { it != current }.sortedDescending() + +/** Count, Select all and Close, above the rows while the queue is in select mode. */ +@Composable +internal fun QueueSelectionBar( + count: Int, + allPicked: Boolean, + onToggleAll: () -> Unit, + onClose: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.fillMaxWidth().padding(start = 4.dp, bottom = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + IconButton(onClick = onClose) { + Icon(Icons.Rounded.Close, contentDescription = stringResource(R.string.close)) + } + Text( + text = pluralStringResource(R.plurals.selected_count_template, count, count), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + ) + IconButton(onClick = onToggleAll) { + Icon( + imageVector = if (allPicked) Icons.Rounded.CheckBox else Icons.Rounded.SelectAll, + contentDescription = stringResource(if (allPicked) R.string.deselect_all else R.string.select_all), + ) + } + } +} + +/** What the queue can do with its picked songs. */ +@Composable +internal fun queueSelectionActions( + onPlayNext: () -> Unit, + onAddToPlaylist: () -> Unit, + onDownload: () -> Unit, + onRemove: () -> Unit, +): List = + listOf( + FlowSelectionAction(Icons.Rounded.QueuePlayNext, stringResource(R.string.play_next), onClick = onPlayNext), + FlowSelectionAction(Icons.AutoMirrored.Rounded.PlaylistAdd, stringResource(R.string.add_to_playlist), onClick = onAddToPlaylist), + FlowSelectionAction(Icons.Rounded.Download, stringResource(R.string.download), onClick = onDownload), + FlowSelectionAction(Icons.Rounded.RemoveCircleOutline, stringResource(R.string.remove_from_queue), onClick = onRemove), + ) diff --git a/app/src/main/java/io/github/aedev/flow/ui/components/musicplayer/queue/QueueSheet.kt b/app/src/main/java/io/github/aedev/flow/ui/components/musicplayer/queue/QueueSheet.kt index 90ec8ce3e..4cbf179a7 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/components/musicplayer/queue/QueueSheet.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/components/musicplayer/queue/QueueSheet.kt @@ -15,6 +15,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -41,6 +42,7 @@ internal fun QueueSheet( modifier: Modifier = Modifier, dragHandleModifier: Modifier = Modifier, ) { + val selection = remember { QueueSelection() } Surface( modifier = modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surfaceContainerHigh, @@ -77,6 +79,7 @@ internal fun QueueSheet( repeatMode = repeatMode, onShuffleQueue = actions.onShuffleQueue, onCycleRepeat = actions.onCycleRepeat, + onSelect = { selection.start() }, ) } @@ -91,6 +94,7 @@ internal fun QueueSheet( swipes = swipes, actions = actions, modifier = Modifier.weight(1f), + selection = selection, ) } } diff --git a/app/src/main/java/io/github/aedev/flow/ui/components/musicplayer/queue/QueueTrackRow.kt b/app/src/main/java/io/github/aedev/flow/ui/components/musicplayer/queue/QueueTrackRow.kt index ce9de3073..f8b40eae3 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/components/musicplayer/queue/QueueTrackRow.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/components/musicplayer/queue/QueueTrackRow.kt @@ -27,6 +27,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.CheckCircle import androidx.compose.material.icons.outlined.Explicit +import androidx.compose.material.icons.rounded.Check import androidx.compose.material.icons.rounded.Sensors import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon @@ -99,6 +100,7 @@ internal fun QueueTrackRow( onSwipe: (QueueRowSwipe) -> Unit, modifier: Modifier = Modifier, onMenu: (() -> Unit)? = null, + selected: Boolean? = null, ) { val scope = rememberCoroutineScope() val dpad = LocalDpadNavigation.current @@ -185,7 +187,9 @@ internal fun QueueTrackRow( ), shape = RoundedCornerShape(rowCorner), color = - if (isCurrent) { + if (selected == true) { + MaterialTheme.colorScheme.primaryContainer + } else if (isCurrent) { MaterialTheme.colorScheme.secondaryContainer } else { MaterialTheme.colorScheme.surfaceContainerLowest.copy(alpha = 0.55f) @@ -228,6 +232,21 @@ internal fun QueueTrackRow( ) } } + if (selected == true) { + Box( + modifier = + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.primary), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = stringResource(R.string.queue_row_selected), + tint = MaterialTheme.colorScheme.onPrimary, + ) + } + } } Column(modifier = Modifier.weight(1f)) { Text( diff --git a/app/src/main/java/io/github/aedev/flow/ui/screens/music/MusicPlayerTrackActions.kt b/app/src/main/java/io/github/aedev/flow/ui/screens/music/MusicPlayerTrackActions.kt index a97b70dbb..7558c1ddf 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/screens/music/MusicPlayerTrackActions.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/screens/music/MusicPlayerTrackActions.kt @@ -14,6 +14,7 @@ import io.github.aedev.flow.data.recommendation.music.MusicBrainEngine import io.github.aedev.flow.data.scrobble.Scrobbler import io.github.aedev.flow.player.EnhancedMusicPlayerManager import io.github.aedev.flow.utils.PerformanceDispatcher +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first @@ -122,6 +123,31 @@ internal class MusicPlayerTrackActions( Toast.makeText(context, context.getString(R.string.added_to_queue_toast), Toast.LENGTH_SHORT).show() } + /** Downloads the songs not downloaded yet, with one message for all of them. */ + fun downloadTracks(tracks: List) { + val fresh = tracks.filter { it.videoId !in uiState.value.downloadedTrackIds }.distinctBy { it.videoId } + scope.launch { + withContext(kotlinx.coroutines.Dispatchers.Main) { + val text = + if (fresh.isEmpty()) { + context.getString(R.string.songs_download_nothing_new) + } else { + context.resources.getQuantityString(R.plurals.songs_downloading, fresh.size, fresh.size) + } + Toast.makeText(context, text, Toast.LENGTH_SHORT).show() + } + fresh.forEach { track -> + try { + downloadManager.downloadTrack(track) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + android.util.Log.e("MusicDownload", "Download start exception", e) + } + } + } + } + fun downloadTrack(track: MusicTrack? = null) { val trackToDownload = track ?: uiState.value.currentTrack ?: return diff --git a/app/src/main/java/io/github/aedev/flow/ui/screens/music/MusicPlayerViewModel.kt b/app/src/main/java/io/github/aedev/flow/ui/screens/music/MusicPlayerViewModel.kt index 2fb6ecd0e..5e57c3ce9 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/screens/music/MusicPlayerViewModel.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/screens/music/MusicPlayerViewModel.kt @@ -568,6 +568,8 @@ class MusicPlayerViewModel fun downloadTrack(track: MusicTrack? = null) = trackActions.downloadTrack(track) + fun downloadTracks(tracks: List) = trackActions.downloadTracks(tracks) + fun ensureLyricsLoaded(track: MusicTrack) = lyrics.ensureLoaded(track) fun warmLyrics(track: MusicTrack) = lyrics.warm(track) diff --git a/app/src/main/java/io/github/aedev/flow/ui/screens/music/SaveSongViewModel.kt b/app/src/main/java/io/github/aedev/flow/ui/screens/music/SaveSongViewModel.kt index c537e1370..0572e5b45 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/screens/music/SaveSongViewModel.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/screens/music/SaveSongViewModel.kt @@ -83,6 +83,32 @@ class SaveSongViewModel } } + /** Adds [tracks] to the playlist in their order; songs already in it stay where they are. */ + fun addAll( + tracks: List, + playlistId: String, + ) { + viewModelScope.launch { + val now = System.currentTimeMillis() + repository.addVideosToPlaylist(playlistId, tracks.mapIndexed { i, track -> track.toPlaylistVideo(now + i) }) + _savedIds.update { it + playlistId } + } + } + + fun createAndAddAll( + tracks: List, + name: String, + description: String, + ) { + viewModelScope.launch { + val playlistId = UUID.randomUUID().toString() + repository.createPlaylist(playlistId, name, description, isPrivate = false, isMusic = true) + val now = System.currentTimeMillis() + repository.addVideosToPlaylist(playlistId, tracks.mapIndexed { i, track -> track.toPlaylistVideo(now + i) }) + _savedIds.update { it + playlistId } + } + } + fun createAndAdd( track: MusicTrack, name: String, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0d72b480d..1a281ce2d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -4194,6 +4194,15 @@ More results Remove from queue Removed from queue + + Removed %d song from the queue + Removed %d songs from the queue + + Selected + + Add %d song to a playlist + Add %d songs to a playlist + %1$s after this Reorder queue item Couldn\'t load %1$s diff --git a/app/src/test/java/io/github/aedev/flow/ui/components/musicplayer/queue/QueueRowMenuActionsTest.kt b/app/src/test/java/io/github/aedev/flow/ui/components/musicplayer/queue/QueueRowMenuActionsTest.kt index d40a2c662..139f5362a 100644 --- a/app/src/test/java/io/github/aedev/flow/ui/components/musicplayer/queue/QueueRowMenuActionsTest.kt +++ b/app/src/test/java/io/github/aedev/flow/ui/components/musicplayer/queue/QueueRowMenuActionsTest.kt @@ -20,6 +20,7 @@ class QueueRowMenuActionsTest { QueueRowMenuAction.MOVE_DOWN, QueueRowMenuAction.MOVE_TO_END, QueueRowMenuAction.REMOVE, + QueueRowMenuAction.SELECT, ).inOrder() } @@ -27,7 +28,7 @@ class QueueRowMenuActionsTest { fun `the first row can't move up and the last can't move down`() { assertThat(queueRowMenuActions(isRadioItem = false, index = 0, lastIndex = 3)).doesNotContain(QueueRowMenuAction.MOVE_UP) assertThat(queueRowMenuActions(isRadioItem = false, index = 3, lastIndex = 3)) - .containsExactly(QueueRowMenuAction.PLAY_NEXT, QueueRowMenuAction.MOVE_UP, QueueRowMenuAction.REMOVE) + .containsExactly(QueueRowMenuAction.PLAY_NEXT, QueueRowMenuAction.MOVE_UP, QueueRowMenuAction.REMOVE, QueueRowMenuAction.SELECT) .inOrder() } } diff --git a/app/src/test/java/io/github/aedev/flow/ui/components/musicplayer/queue/QueueSelectionTest.kt b/app/src/test/java/io/github/aedev/flow/ui/components/musicplayer/queue/QueueSelectionTest.kt new file mode 100644 index 000000000..92112acc5 --- /dev/null +++ b/app/src/test/java/io/github/aedev/flow/ui/components/musicplayer/queue/QueueSelectionTest.kt @@ -0,0 +1,65 @@ +package io.github.aedev.flow.ui.components.musicplayer.queue + +import com.google.common.truth.Truth.assertThat +import io.github.aedev.flow.data.music.model.MusicTrack +import io.mockk.every +import io.mockk.mockk +import org.junit.Test + +class QueueSelectionTest { + private fun track(id: String): MusicTrack = mockk { every { videoId } returns id } + + /** Applies [moves] the way the player does, to see where every row ends up. */ + private fun apply( + size: Int, + moves: List>, + ): List { + val rows = MutableList(size) { it } + moves.forEach { (from, to) -> rows.add(to, rows.removeAt(from)) } + return rows + } + + @Test + fun `a song queued twice gets two keys`() { + assertThat(queueKeys(listOf(track("a"), track("b"), track("a")))).containsExactly("a#1", "b#1", "a#2").inOrder() + } + + @Test + fun `picked songs after the playing one move up behind it in order`() { + val moves = playNextMoves(picked = listOf(5, 3), current = 1, size = 7) + + assertThat(apply(7, moves)).containsExactly(0, 1, 3, 5, 2, 4, 6).inOrder() + } + + @Test + fun `picked songs before the playing one move behind it too`() { + val moves = playNextMoves(picked = listOf(0, 4), current = 2, size = 6) + + assertThat(apply(6, moves)).containsExactly(1, 2, 0, 4, 3, 5).inOrder() + } + + @Test + fun `the playing song is never moved or removed`() { + assertThat(apply(4, playNextMoves(picked = listOf(1), current = 1, size = 4))).containsExactly(0, 1, 2, 3).inOrder() + assertThat(removalOrder(picked = listOf(0, 1, 3), current = 1)).containsExactly(3, 0).inOrder() + } + + @Test + fun `select all picks everything, then nothing`() { + val selection = QueueSelection() + selection.start() + selection.toggleAll(listOf("a#1", "b#1")) + assertThat(selection.keys).containsExactly("a#1", "b#1") + selection.toggleAll(listOf("a#1", "b#1")) + assertThat(selection.keys).isEmpty() + } + + @Test + fun `picks of songs that left the queue are dropped`() { + val selection = QueueSelection() + selection.start("a#1") + selection.toggle("b#1") + selection.retain(listOf("b#1", "c#1")) + assertThat(selection.keys).containsExactly("b#1") + } +}