diff --git a/app/src/main/java/com/zionhuang/music/MainActivity.kt b/app/src/main/java/com/zionhuang/music/MainActivity.kt index 50b4bfb4e..dcf544ddc 100644 --- a/app/src/main/java/com/zionhuang/music/MainActivity.kt +++ b/app/src/main/java/com/zionhuang/music/MainActivity.kt @@ -54,6 +54,7 @@ import androidx.compose.material3.contentColorFor import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -283,6 +284,7 @@ class MainActivity : ComponentActivity() { val navController = rememberNavController() val navBackStackEntry by navController.currentBackStackEntryAsState() + val inSelectMode = navBackStackEntry?.savedStateHandle?.getStateFlow("inSelectMode", false)?.collectAsState() val navigationItems = remember { Screens.MainScreens } val defaultOpenTab = remember { @@ -340,9 +342,11 @@ class MainActivity : ComponentActivity() { mutableStateOf(intent?.action == ACTION_SEARCH) } - val shouldShowSearchBar = remember(active, navBackStackEntry) { - active || navigationItems.fastAny { it.route == navBackStackEntry?.destination?.route } || - navBackStackEntry?.destination?.route?.startsWith("search/") == true + val shouldShowSearchBar = remember(active, navBackStackEntry, inSelectMode?.value) { + (active || + navigationItems.fastAny { it.route == navBackStackEntry?.destination?.route } || + navBackStackEntry?.destination?.route?.startsWith("search/") == true) && + inSelectMode?.value != true } val shouldShowNavigationBar = remember(navBackStackEntry, active) { navBackStackEntry?.destination?.route == null || @@ -724,7 +728,7 @@ class MainActivity : ComponentActivity() { }, onClick = { if (navBackStackEntry?.destination?.hierarchy?.any { it.route == screen.route } == true) { - navController.currentBackStackEntry?.savedStateHandle?.set("scrollToTop", true) + navBackStackEntry?.savedStateHandle?.set("scrollToTop", true) coroutineScope.launch { searchBarScrollBehavior.state.resetHeightOffset() } 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 2613d740b..10ae80c70 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 @@ -49,10 +49,10 @@ fun SongSelectionMenu( val playerConnection = LocalPlayerConnection.current ?: return val allInLibrary by remember(selection) { - mutableStateOf(selection.all { it.song.inLibrary != null }) + mutableStateOf(selection.isNotEmpty() && selection.all { it.song.inLibrary != null }) } val allLiked by remember(selection) { - mutableStateOf(selection.all { it.song.liked }) + mutableStateOf(selection.isNotEmpty() && selection.all { it.song.liked }) } var downloadState by remember { @@ -60,12 +60,15 @@ fun SongSelectionMenu( } LaunchedEffect(selection) { - if (selection.isEmpty()) return@LaunchedEffect - downloadUtil.downloads.collect { downloads -> - downloadState = when { - selection.all { downloads[it.id]?.state == STATE_COMPLETED } -> STATE_COMPLETED - selection.all { downloads[it.id]?.state in listOf(STATE_QUEUED, STATE_DOWNLOADING, STATE_COMPLETED) } -> STATE_DOWNLOADING - else -> Download.STATE_STOPPED + if (selection.isEmpty()) { + onDismiss() + } else { + downloadUtil.downloads.collect { downloads -> + downloadState = when { + selection.all { downloads[it.id]?.state == STATE_COMPLETED } -> STATE_COMPLETED + selection.all { downloads[it.id]?.state in listOf(STATE_QUEUED, STATE_DOWNLOADING, STATE_COMPLETED) } -> STATE_DOWNLOADING + else -> Download.STATE_STOPPED + } } } } @@ -80,10 +83,6 @@ fun SongSelectionMenu( onDismiss = { showChoosePlaylistDialog = false }, ) - var showRemoveDownloadDialog by remember { - mutableStateOf(false) - } - GridMenu( contentPadding = PaddingValues( @@ -154,7 +153,14 @@ fun SongSelectionMenu( } }, onRemoveDownload = { - showRemoveDownloadDialog = true + selection.forEach { song -> + DownloadService.sendRemoveDownload( + context, + ExoDownloadService::class.java, + song.song.id, + false + ) + } }, ) diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/AlbumScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/AlbumScreen.kt index 515b01b88..07f383b66 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/AlbumScreen.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/AlbumScreen.kt @@ -579,6 +579,7 @@ fun AlbumScreen( } ) IconButton( + enabled = selection.isNotEmpty(), onClick = { menuState.show { SongSelectionMenu( diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/library/LibrarySongsScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/library/LibrarySongsScreen.kt index 6b819573f..40606868e 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/library/LibrarySongsScreen.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/library/LibrarySongsScreen.kt @@ -1,5 +1,6 @@ package com.zionhuang.music.ui.screens.library +import androidx.activity.compose.BackHandler import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Box @@ -12,22 +13,33 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.Checkbox +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +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.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.unit.dp +import androidx.compose.ui.util.fastForEachReversed import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavController import androidx.navigation.compose.currentBackStackEntryAsState @@ -51,19 +63,22 @@ import com.zionhuang.music.ui.component.LocalMenuState import com.zionhuang.music.ui.component.SongListItem import com.zionhuang.music.ui.component.SortHeader import com.zionhuang.music.ui.menu.SongMenu +import com.zionhuang.music.ui.menu.SongSelectionMenu import com.zionhuang.music.utils.rememberEnumPreference import com.zionhuang.music.utils.rememberPreference import com.zionhuang.music.viewmodels.LibrarySongsViewModel -@OptIn(ExperimentalFoundationApi::class) +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) @Composable fun LibrarySongsScreen( navController: NavController, viewModel: LibrarySongsViewModel = hiltViewModel(), ) { + val haptic = LocalHapticFeedback.current val context = LocalContext.current val menuState = LocalMenuState.current val playerConnection = LocalPlayerConnection.current ?: return + val isPlaying by playerConnection.isPlaying.collectAsState() val mediaMetadata by playerConnection.mediaMetadata.collectAsState() @@ -77,6 +92,21 @@ fun LibrarySongsScreen( val backStackEntry by navController.currentBackStackEntryAsState() val scrollToTop = backStackEntry?.savedStateHandle?.getStateFlow("scrollToTop", false)?.collectAsState() + var inSelectMode by rememberSaveable { mutableStateOf(false) } + val selection = rememberSaveable( + saver = listSaver, String>( + save = { it.toList() }, + restore = { it.toMutableStateList() } + ) + ) { mutableStateListOf() } + val onExitSelectionMode = { + inSelectMode = false + selection.clear() + } + if (inSelectMode) { + BackHandler(onBack = onExitSelectionMode) + } + LaunchedEffect(scrollToTop?.value) { if (scrollToTop?.value == true) { lazyListState.animateScrollToItem(0) @@ -84,6 +114,18 @@ fun LibrarySongsScreen( } } + LaunchedEffect(inSelectMode) { + backStackEntry?.savedStateHandle?.set("inSelectMode", inSelectMode) + } + + LaunchedEffect(songs) { + selection.fastForEachReversed { songId -> + if (songs?.find { it.id == songId } == null) { + selection.remove(songId) + } + } + } + Box( modifier = Modifier.fillMaxSize() ) { @@ -157,43 +199,69 @@ fun LibrarySongsScreen( key = { _, item -> item.id }, contentType = { _, _ -> CONTENT_TYPE_SONG } ) { index, song -> + val onCheckedChange: (Boolean) -> Unit = { + if (it) { + selection.add(song.id) + } else { + selection.remove(song.id) + } + } + SongListItem( song = song, isActive = song.id == mediaMetadata?.id, isPlaying = isPlaying, trailingContent = { - IconButton( - onClick = { - menuState.show { - SongMenu( - originalSong = song, - navController = navController, - onDismiss = menuState::dismiss - ) + if (inSelectMode) { + Checkbox( + checked = song.id in selection, + onCheckedChange = onCheckedChange + ) + } else { + IconButton( + onClick = { + menuState.show { + SongMenu( + originalSong = 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 .fillMaxWidth() - .combinedClickable { - if (song.id == mediaMetadata?.id) { - playerConnection.player.togglePlayPause() - } else { - playerConnection.playQueue( - ListQueue( - title = context.getString(R.string.queue_all_songs), - items = songs.map { it.toMediaItem() }, - startIndex = index + .combinedClickable( + onClick = { + if (inSelectMode) { + onCheckedChange(song.id !in selection) + } else if (song.id == mediaMetadata?.id) { + playerConnection.player.togglePlayPause() + } else { + playerConnection.playQueue( + ListQueue( + title = context.getString(R.string.queue_all_songs), + items = songs.map { it.toMediaItem() }, + startIndex = index + ) ) - ) + } + }, + onLongClick = { + if (!inSelectMode) { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + inSelectMode = true + onCheckedChange(true) + } } - } + ) .animateItem() ) } @@ -214,4 +282,52 @@ fun LibrarySongsScreen( } ) } + + if (inSelectMode) { + TopAppBar( + title = { + Text(pluralStringResource(R.plurals.n_selected, selection.size, selection.size)) + }, + navigationIcon = { + IconButton(onClick = onExitSelectionMode) { + Icon( + painter = painterResource(R.drawable.close), + contentDescription = null, + ) + } + }, + actions = { + Checkbox( + checked = selection.size == songs?.size, + onCheckedChange = { + if (selection.size == songs?.size) { + selection.clear() + } else { + selection.clear() + selection.addAll(songs?.map { it.id }.orEmpty()) + } + } + ) + IconButton( + enabled = selection.isNotEmpty(), + onClick = { + menuState.show { + SongSelectionMenu( + selection = selection.mapNotNull { songId -> + songs?.find { it.id == songId } + }, + onDismiss = menuState::dismiss, + onExitSelectionMode = onExitSelectionMode + ) + } + } + ) { + Icon( + painterResource(R.drawable.more_vert), + contentDescription = null + ) + } + } + ) + } } 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 e5e9d5854..127daf358 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 @@ -575,6 +575,7 @@ fun LocalPlaylistScreen( } ) IconButton( + enabled = selection.isNotEmpty(), onClick = { menuState.show { SongSelectionMenu( 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 d3af58134..a3e8525dd 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 @@ -533,6 +533,7 @@ fun OnlinePlaylistScreen( } ) IconButton( + enabled = selection.isNotEmpty(), onClick = { menuState.show { YouTubeSongSelectionMenu( diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 990d486c4..52c617ff2 100755 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -85,7 +85,9 @@ 接著播放 加入待播清單 加入媒體庫 + 全部加入媒體庫 從音樂庫中移除 + 全部從音樂庫中移除 下載 下載中 刪除下載