From e6e8e4a9a94696361872d571fa40656be5f82340 Mon Sep 17 00:00:00 2001 From: Zion Huang Date: Tue, 27 Aug 2024 22:58:13 +0800 Subject: [PATCH] feat(online playlist): multi-select --- .../music/ui/menu/SongSelectionMenu.kt | 2 - .../music/ui/menu/YouTubeSongSelectionMenu.kt | 101 +++++++++++ .../com/zionhuang/music/ui/player/Queue.kt | 4 + .../screens/playlist/LocalPlaylistScreen.kt | 5 + .../screens/playlist/OnlinePlaylistScreen.kt | 169 ++++++++++++++---- 5 files changed, 247 insertions(+), 34 deletions(-) create mode 100644 app/src/main/java/com/zionhuang/music/ui/menu/YouTubeSongSelectionMenu.kt diff --git a/app/src/main/java/com/zionhuang/music/ui/menu/SongSelectionMenu.kt b/app/src/main/java/com/zionhuang/music/ui/menu/SongSelectionMenu.kt index 01afa4d23..2613d740b 100644 --- a/app/src/main/java/com/zionhuang/music/ui/menu/SongSelectionMenu.kt +++ b/app/src/main/java/com/zionhuang/music/ui/menu/SongSelectionMenu.kt @@ -1,6 +1,5 @@ package com.zionhuang.music.ui.menu -import android.annotation.SuppressLint import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues @@ -37,7 +36,6 @@ import com.zionhuang.music.ui.component.GridMenu import com.zionhuang.music.ui.component.GridMenuItem import java.time.LocalDateTime -@SuppressLint("MutableCollectionMutableState") @Composable fun SongSelectionMenu( selection: List, diff --git a/app/src/main/java/com/zionhuang/music/ui/menu/YouTubeSongSelectionMenu.kt b/app/src/main/java/com/zionhuang/music/ui/menu/YouTubeSongSelectionMenu.kt new file mode 100644 index 000000000..cfa84d389 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/menu/YouTubeSongSelectionMenu.kt @@ -0,0 +1,101 @@ +package com.zionhuang.music.ui.menu + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.systemBars +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.unit.dp +import com.zionhuang.innertube.models.SongItem +import com.zionhuang.music.LocalDatabase +import com.zionhuang.music.LocalPlayerConnection +import com.zionhuang.music.R +import com.zionhuang.music.extensions.toMediaItem +import com.zionhuang.music.models.toMediaMetadata +import com.zionhuang.music.playback.queues.ListQueue +import com.zionhuang.music.ui.component.GridMenu +import com.zionhuang.music.ui.component.GridMenuItem + +@Composable +fun YouTubeSongSelectionMenu( + selection: List, + onDismiss: () -> Unit, + onExitSelectionMode: () -> Unit, +) { + val database = LocalDatabase.current + val playerConnection = LocalPlayerConnection.current ?: return + + var showChoosePlaylistDialog by rememberSaveable { + mutableStateOf(false) + } + + AddToPlaylistDialog( + isVisible = showChoosePlaylistDialog, + onGetSong = { + val mediaMetadatas = selection.map { + it.toMediaMetadata() + } + database.transaction { + mediaMetadatas.forEach(::insert) + } + selection.map { it.id } + }, + onDismiss = { showChoosePlaylistDialog = false }, + ) + + GridMenu( + contentPadding = + PaddingValues( + start = 8.dp, + top = 8.dp, + end = 8.dp, + bottom = 8.dp + WindowInsets.systemBars.asPaddingValues().calculateBottomPadding(), + ), + ) { + GridMenuItem( + icon = R.drawable.play, + title = R.string.play, + ) { + onDismiss() + playerConnection.playQueue( + ListQueue( + items = selection.map { it.toMediaItem() }, + ), + ) + onExitSelectionMode() + } + + GridMenuItem( + icon = R.drawable.shuffle, + title = R.string.shuffle, + ) { + onDismiss() + playerConnection.playQueue( + ListQueue( + items = selection.shuffled().map { it.toMediaItem() }, + ), + ) + onExitSelectionMode() + } + + GridMenuItem( + icon = R.drawable.queue_music, + title = R.string.add_to_queue, + ) { + onDismiss() + playerConnection.addToQueue(selection.map { it.toMediaItem() }) + onExitSelectionMode() + } + + GridMenuItem( + icon = R.drawable.playlist_add, + title = R.string.add_to_playlist, + ) { + showChoosePlaylistDialog = true + } + } +} diff --git a/app/src/main/java/com/zionhuang/music/ui/player/Queue.kt b/app/src/main/java/com/zionhuang/music/ui/player/Queue.kt index f3842629d..49f4bf960 100644 --- a/app/src/main/java/com/zionhuang/music/ui/player/Queue.kt +++ b/app/src/main/java/com/zionhuang/music/ui/player/Queue.kt @@ -60,9 +60,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource @@ -115,6 +117,7 @@ fun Queue( navController: NavController, modifier: Modifier = Modifier, ) { + val haptic = LocalHapticFeedback.current val menuState = LocalMenuState.current val playerConnection = LocalPlayerConnection.current ?: return @@ -408,6 +411,7 @@ fun Queue( }, onLongClick = { if (!inSelectMode) { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) inSelectMode = true onCheckedChange(true) } diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/playlist/LocalPlaylistScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/playlist/LocalPlaylistScreen.kt index 15db27f2f..ace5a88eb 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/playlist/LocalPlaylistScreen.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/playlist/LocalPlaylistScreen.kt @@ -55,8 +55,10 @@ import androidx.compose.runtime.toMutableStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource @@ -130,10 +132,12 @@ fun LocalPlaylistScreen( scrollBehavior: TopAppBarScrollBehavior, viewModel: LocalPlaylistViewModel = hiltViewModel(), ) { + val haptic = LocalHapticFeedback.current val context = LocalContext.current val menuState = LocalMenuState.current val database = LocalDatabase.current val playerConnection = LocalPlayerConnection.current ?: return + val isPlaying by playerConnection.isPlaying.collectAsState() val mediaMetadata by playerConnection.mediaMetadata.collectAsState() @@ -441,6 +445,7 @@ fun LocalPlaylistScreen( }, onLongClick = { if (!inSelectMode) { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) inSelectMode = true onCheckedChange(true) } diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/playlist/OnlinePlaylistScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/playlist/OnlinePlaylistScreen.kt index 6e356848d..76cafc33a 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/playlist/OnlinePlaylistScreen.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/playlist/OnlinePlaylistScreen.kt @@ -1,7 +1,9 @@ package com.zionhuang.music.ui.screens.playlist +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background -import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -15,11 +17,12 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Checkbox import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -34,14 +37,23 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.listSaver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.toMutableStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip +import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.LinkAnnotation import androidx.compose.ui.text.buildAnnotatedString @@ -79,18 +91,20 @@ import com.zionhuang.music.ui.component.shimmer.ShimmerHost import com.zionhuang.music.ui.component.shimmer.TextPlaceholder import com.zionhuang.music.ui.menu.YouTubePlaylistMenu import com.zionhuang.music.ui.menu.YouTubeSongMenu +import com.zionhuang.music.ui.menu.YouTubeSongSelectionMenu import com.zionhuang.music.ui.utils.backToMain import com.zionhuang.music.utils.rememberPreference import com.zionhuang.music.viewmodels.OnlinePlaylistViewModel import kotlinx.coroutines.launch -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable fun OnlinePlaylistScreen( navController: NavController, scrollBehavior: TopAppBarScrollBehavior, viewModel: OnlinePlaylistViewModel = hiltViewModel(), ) { + val haptic = LocalHapticFeedback.current val context = LocalContext.current val menuState = LocalMenuState.current val database = LocalDatabase.current @@ -113,6 +127,21 @@ fun OnlinePlaylistScreen( } } + var inSelectMode by rememberSaveable { mutableStateOf(false) } + val selection = rememberSaveable( + saver = listSaver, Int>( + save = { it.toList() }, + restore = { it.toMutableStateList() } + ) + ) { mutableStateListOf() } + val onExitSelectionMode = { + inSelectMode = false + selection.clear() + } + if (inSelectMode) { + BackHandler(onBack = onExitSelectionMode) + } + Box( modifier = Modifier.fillMaxSize() ) { @@ -272,39 +301,66 @@ fun OnlinePlaylistScreen( } } - items( + itemsIndexed( items = songs - ) { song -> + ) { index, song -> + val onCheckedChange: (Boolean) -> Unit = { + if (it) { + selection.add(index) + } else { + selection.remove(index) + } + } + YouTubeListItem( item = song, isActive = mediaMetadata?.id == song.id, isPlaying = isPlaying, trailingContent = { - IconButton( - onClick = { - menuState.show { - YouTubeSongMenu( - song = song, - navController = navController, - onDismiss = menuState::dismiss - ) + if (inSelectMode) { + Checkbox( + checked = index in selection, + onCheckedChange = onCheckedChange + ) + } else { + IconButton( + onClick = { + menuState.show { + YouTubeSongMenu( + song = song, + navController = navController, + onDismiss = menuState::dismiss + ) + } } + ) { + Icon( + painter = painterResource(R.drawable.more_vert), + contentDescription = null + ) } - ) { - Icon( - painter = painterResource(R.drawable.more_vert), - contentDescription = null - ) } }, modifier = Modifier - .clickable(enabled = !hideExplicit || !song.explicit) { - if (song.id == mediaMetadata?.id) { - playerConnection.player.togglePlayPause() - } else { - playerConnection.playQueue(YouTubeQueue(song.endpoint ?: WatchEndpoint(videoId = song.id), song.toMediaMetadata())) + .combinedClickable( + enabled = !hideExplicit || !song.explicit, + onClick = { + if (inSelectMode) { + onCheckedChange(index !in selection) + } else if (song.id == mediaMetadata?.id) { + playerConnection.player.togglePlayPause() + } else { + playerConnection.playQueue(YouTubeQueue(song.endpoint ?: WatchEndpoint(videoId = song.id), song.toMediaMetadata())) + } + }, + onLongClick = { + if (!inSelectMode) { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + inSelectMode = true + onCheckedChange(true) + } } - } + ) .alpha(if (hideExplicit && song.explicit) 0.3f else 1f) .animateItem() ) @@ -353,16 +409,65 @@ fun OnlinePlaylistScreen( } TopAppBar( - title = { if (showTopBarTitle) Text(playlist?.title.orEmpty()) }, + title = { + if (inSelectMode) { + Text(pluralStringResource(R.plurals.n_selected, selection.size, selection.size)) + } else if (showTopBarTitle) { + Text(playlist?.title.orEmpty()) + } + }, navigationIcon = { - IconButton( - onClick = navController::navigateUp, - onLongClick = navController::backToMain - ) { - Icon( - painterResource(R.drawable.arrow_back), - contentDescription = null + if (inSelectMode) { + IconButton(onClick = onExitSelectionMode) { + Icon( + painter = painterResource(R.drawable.close), + contentDescription = null, + ) + } + } else { + IconButton( + onClick = navController::navigateUp, + onLongClick = navController::backToMain + ) { + Icon( + painterResource(R.drawable.arrow_back), + contentDescription = null + ) + } + } + }, + actions = { + if (inSelectMode) { + Checkbox( + checked = selection.size == songs.size, + onCheckedChange = { + if (selection.size == songs.size) { + selection.clear() + } else { + selection.clear() + selection.addAll(songs.mapIndexedNotNull { index, song -> + if (hideExplicit && song.explicit) null + else index + }) + } + } ) + IconButton( + onClick = { + menuState.show { + YouTubeSongSelectionMenu( + selection = selection.mapNotNull { songs.getOrNull(it) }, + onDismiss = menuState::dismiss, + onExitSelectionMode = onExitSelectionMode + ) + } + } + ) { + Icon( + painterResource(R.drawable.more_vert), + contentDescription = null + ) + } } }, scrollBehavior = scrollBehavior