From 592c5f7ad7432ff019ab4047888cb63edf29ea86 Mon Sep 17 00:00:00 2001 From: juinc <81556297+juinc@users.noreply.github.com> Date: Tue, 26 May 2026 20:12:58 +0300 Subject: [PATCH] Implemented experimental metadata batch editor --- .../components/EditMultipleSongsSheet.kt | 659 ++++++++++++++++++ .../presentation/components/EditSongSheet.kt | 4 +- .../components/MultiSelectionBottomSheet.kt | 39 +- .../presentation/screens/LibraryScreen.kt | 32 + .../presentation/viewmodel/PlayerViewModel.kt | 242 +++++++ app/src/main/res/values/strings.xml | 16 + 6 files changed, 985 insertions(+), 7 deletions(-) create mode 100644 app/src/main/java/com/theveloper/pixelplay/presentation/components/EditMultipleSongsSheet.kt diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/EditMultipleSongsSheet.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/EditMultipleSongsSheet.kt new file mode 100644 index 000000000..63791519f --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/EditMultipleSongsSheet.kt @@ -0,0 +1,659 @@ +package com.theveloper.pixelplay.presentation.components + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.MutableTransitionState +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import com.theveloper.pixelplay.presentation.components.CoverArtCropperDialog +import com.theveloper.pixelplay.presentation.components.CoverArtCropResult +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +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.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.theveloper.pixelplay.R +import com.theveloper.pixelplay.data.media.CoverArtUpdate +import com.theveloper.pixelplay.data.model.Song +import com.theveloper.pixelplay.ui.theme.GoogleSansRounded +import racra.compose.smooth_corner_rect_library.AbsoluteSmoothCornerShape +import androidx.compose.ui.graphics.ImageBitmap +import android.net.Uri +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.layout.ContentScale +import androidx.compose.foundation.Image +import androidx.compose.ui.res.painterResource +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.style.TextAlign + +/** + * Data class representing a field that can have mixed values across multiple songs + */ +private data class MixedValueField( + val value: T?, + val isMixed: Boolean, + val isModified: Boolean = false +) + +private fun List.toMixedValueField(): MixedValueField { + val distinct = this.distinct() + return when { + distinct.isEmpty() -> MixedValueField(null, false) + distinct.size == 1 -> MixedValueField(distinct.first(), false) + else -> MixedValueField(null, true) + } +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun EditMultipleSongsSheet( + visible: Boolean, + songs: List, + onDismiss: () -> Unit, + onSave: ( + selectedSongs: List, + title: String?, + artist: String?, + album: String?, + albumArtist: String?, + composer: String?, + genre: String?, + lyrics: String?, + trackNumber: Int?, + discNumber: Int?, + replayGainTrackGainDb: String?, + replayGainAlbumGainDb: String?, + coverArtUpdate: CoverArtUpdate? + ) -> Unit +) { + val transitionState = remember { MutableTransitionState(false) } + transitionState.targetState = visible + + if (transitionState.currentState || transitionState.targetState) { + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties( + usePlatformDefaultWidth = false, + decorFitsSystemWindows = false + ) + ) { + AnimatedVisibility( + visibleState = transitionState, + enter = slideInVertically(initialOffsetY = { it / 6 }) + fadeIn(animationSpec = tween(220)), + exit = slideOutVertically(targetOffsetY = { it / 6 }) + fadeOut(animationSpec = tween(200)) + ) { + EditMultipleSongsContent( + songs = songs, + onDismiss = onDismiss, + onSave = onSave + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun EditMultipleSongsContent( + songs: List, + onDismiss: () -> Unit, + onSave: ( + selectedSongs: List, + title: String?, + artist: String?, + album: String?, + albumArtist: String?, + composer: String?, + genre: String?, + lyrics: String?, + trackNumber: Int?, + discNumber: Int?, + replayGainTrackGainDb: String?, + replayGainAlbumGainDb: String?, + coverArtUpdate: CoverArtUpdate? + ) -> Unit, +) { + // Initialize mixed value fields + val titleField = remember(songs) { songs.map { it.title }.toMixedValueField() } + val artistField = remember(songs) { songs.map { it.displayArtist }.toMixedValueField() } + val albumField = remember(songs) { songs.map { it.album }.toMixedValueField() } + val albumArtistField = remember(songs) { songs.map { it.albumArtist }.toMixedValueField() } + val genreField = remember(songs) { songs.map { it.genre }.toMixedValueField() } + val lyricsField = remember(songs) { songs.map { it.lyrics }.toMixedValueField() } + val trackNumberField = remember(songs) { songs.map { it.trackNumber }.toMixedValueField() } + val discNumberField = remember(songs) { songs.map { it.discNumber }.toMixedValueField() } + + // Editable state + var title by remember { mutableStateOf(null) } + var artist by remember { mutableStateOf(null) } + var album by remember { mutableStateOf(null) } + var albumArtist by remember { mutableStateOf(null) } + var composer by remember { mutableStateOf(null) } + var genre by remember { mutableStateOf(null) } + var lyrics by remember { mutableStateOf(null) } + var trackNumber by remember { mutableStateOf(null) } + var discNumber by remember { mutableStateOf(null) } + var replayGainTrackGainDb by remember { mutableStateOf(null) } + var replayGainAlbumGainDb by remember { mutableStateOf(null) } + var coverArtUpdate by remember { mutableStateOf(null) } + + var coverArtPreview by remember { mutableStateOf(null) } + var isCoverArtDeleted by remember { mutableStateOf(false) } + var showCoverArtCropper by remember { mutableStateOf(false) } + var pendingCoverArtUri by remember { mutableStateOf(null) } + + val pickCoverArtLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.PickVisualMedia() + ) { uri -> + if (uri != null) { + pendingCoverArtUri = uri + showCoverArtCropper = true + } + } + + val textFieldColors = TextFieldDefaults.colors( + focusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + disabledContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + ) + + val textFieldShape = AbsoluteSmoothCornerShape( + cornerRadiusTL = 10.dp, smoothnessAsPercentBL = 60, + cornerRadiusTR = 10.dp, smoothnessAsPercentBR = 60, + cornerRadiusBL = 10.dp, smoothnessAsPercentTL = 60, + cornerRadiusBR = 10.dp, smoothnessAsPercentTR = 60 + ) + + val density = LocalDensity.current + val imeInsets = WindowInsets.ime + val isKeyboardVisible by remember { derivedStateOf { imeInsets.getBottom(density) > 0 } } + + if (showCoverArtCropper && pendingCoverArtUri != null) { + CoverArtCropperDialog( + sourceUri = pendingCoverArtUri!!, + onDismiss = { + showCoverArtCropper = false + pendingCoverArtUri = null + }, + onConfirm = { result -> + coverArtPreview = result.preview + coverArtUpdate = result.update + isCoverArtDeleted = false + showCoverArtCropper = false + pendingCoverArtUri = null + } + ) + } + + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + modifier = Modifier.padding(start = 10.dp), + text = stringResource(R.string.batch_edit_toolbar_title, songs.size), + fontFamily = GoogleSansRounded, + style = MaterialTheme.typography.displaySmall + ) + } + ) + }, + containerColor = MaterialTheme.colorScheme.surface, + modifier = Modifier.fillMaxSize(), + contentWindowInsets = WindowInsets.statusBars + ) { innerPadding -> + val navBarBottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + + Box(modifier = Modifier.fillMaxSize()) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .imePadding(), + contentPadding = PaddingValues( + top = innerPadding.calculateTopPadding() + 8.dp, + bottom = if (isKeyboardVisible) 8.dp else (navBarBottom + 100.dp), + start = 16.dp, + end = 16.dp + ), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + // Info card + item { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + tonalElevation = 2.dp, + color = MaterialTheme.colorScheme.primaryContainer + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Rounded.Info, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimaryContainer + ) + Spacer(Modifier.width(12.dp)) + Text( + text = stringResource(R.string.batch_edit_info_message), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + } + } + + // Cover Art Editor Card + item { + BatchCoverArtEditorCard( + modifier = Modifier.fillMaxWidth(), + songsCount = songs.size, + preview = coverArtPreview, + isDeleted = isCoverArtDeleted, + onPickNewArt = { + pickCoverArtLauncher.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) + ) + }, + onDelete = { + coverArtPreview = null + isCoverArtDeleted = true + coverArtUpdate = CoverArtUpdate(isDeletion = true) + }, + onReset = { + coverArtPreview = null + coverArtUpdate = null + isCoverArtDeleted = false + } + ) + } + + // Artist field + item { + BatchEditField( + value = artist ?: "", + onValueChange = { artist = it.ifBlank { null } }, + label = stringResource(R.string.song_field_artist), + placeholder = if (artistField.isMixed) + stringResource(R.string.batch_edit_mixed_values) + else + artistField.value ?: "", + icon = Icons.Rounded.Person, + tint = MaterialTheme.colorScheme.primary, + textFieldColors = textFieldColors, + textFieldShape = textFieldShape + ) + } + + // Album field + item { + BatchEditField( + value = album ?: "", + onValueChange = { album = it.ifBlank { null } }, + label = stringResource(R.string.song_field_album), + placeholder = if (albumField.isMixed) + stringResource(R.string.batch_edit_mixed_values) + else + albumField.value ?: "", + icon = Icons.Rounded.Album, + tint = MaterialTheme.colorScheme.tertiary, + textFieldColors = textFieldColors, + textFieldShape = textFieldShape + ) + } + + // Album Artist field + item { + BatchEditField( + value = albumArtist ?: "", + onValueChange = { albumArtist = it.ifBlank { null } }, + label = stringResource(R.string.song_field_album_artist), + placeholder = if (albumArtistField.isMixed) + stringResource(R.string.batch_edit_mixed_values) + else + albumArtistField.value ?: "", + icon = Icons.Rounded.Person, + tint = MaterialTheme.colorScheme.secondary, + textFieldColors = textFieldColors, + textFieldShape = textFieldShape + ) + } + + // Genre field + item { + BatchEditField( + value = genre ?: "", + onValueChange = { genre = it.ifBlank { null } }, + label = stringResource(R.string.song_field_genre), + placeholder = if (genreField.isMixed) + stringResource(R.string.batch_edit_mixed_values) + else + genreField.value ?: "", + icon = Icons.Rounded.Category, + tint = MaterialTheme.colorScheme.secondary, + textFieldColors = textFieldColors, + textFieldShape = textFieldShape + ) + } + + // Composer field + item { + BatchEditField( + value = composer ?: "", + onValueChange = { composer = it.ifBlank { null } }, + label = stringResource(R.string.song_field_composer), + placeholder = stringResource(R.string.batch_edit_optional), + icon = Icons.Rounded.MusicNote, + tint = MaterialTheme.colorScheme.tertiary, + textFieldColors = textFieldColors, + textFieldShape = textFieldShape + ) + } + + // ReplayGain Track + item { + BatchEditField( + value = replayGainTrackGainDb ?: "", + onValueChange = { replayGainTrackGainDb = it.ifBlank { null } }, + label = stringResource(R.string.song_field_replaygain_track_db), + placeholder = stringResource(R.string.placeholder_replaygain_track_example), + icon = Icons.Rounded.RepeatOne, + tint = MaterialTheme.colorScheme.primary, + textFieldColors = textFieldColors, + textFieldShape = textFieldShape, + keyboardType = KeyboardType.Decimal + ) + } + + // ReplayGain Album + item { + BatchEditField( + value = replayGainAlbumGainDb ?: "", + onValueChange = { replayGainAlbumGainDb = it.ifBlank { null } }, + label = stringResource(R.string.song_field_replaygain_album_db), + placeholder = stringResource(R.string.placeholder_replaygain_album_example), + icon = Icons.Rounded.Repeat, + tint = MaterialTheme.colorScheme.tertiary, + textFieldColors = textFieldColors, + textFieldShape = textFieldShape, + keyboardType = KeyboardType.Decimal + ) + } + } + + AnimatedVisibility( + visible = !isKeyboardVisible, + enter = slideInVertically { it } + fadeIn(), + exit = slideOutVertically { it } + fadeOut(), + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = innerPadding.calculateBottomPadding() + 24.dp) + ) { + HorizontalFloatingToolbar( + expandedShadowElevation = 0.dp, + colors = FloatingToolbarDefaults.standardFloatingToolbarColors( + toolbarContainerColor = MaterialTheme.colorScheme.surfaceContainerLow + ), + expanded = true, + scrollBehavior = FloatingToolbarDefaults.exitAlwaysScrollBehavior( + exitDirection = FloatingToolbarExitDirection.Bottom + ), + content = { + FilledTonalButton( + onClick = onDismiss, + modifier = Modifier.height(48.dp), + colors = ButtonDefaults.filledTonalButtonColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer + ) + ) { + Text(stringResource(R.string.cancel), maxLines = 1, overflow = TextOverflow.Ellipsis) + } + Spacer(Modifier.width(8.dp)) + Button( + onClick = { + onSave( + songs, + title, + artist, + album, + albumArtist, + composer, + genre, + lyrics, + trackNumber?.toIntOrNull(), + discNumber?.toIntOrNull(), + replayGainTrackGainDb, + replayGainAlbumGainDb, + coverArtUpdate + ) + }, + modifier = Modifier.height(48.dp) + ) { + Text(stringResource(R.string.action_save)) + } + } + ) + } + } + } +} + +@Composable +private fun BatchEditField( + value: String, + onValueChange: (String) -> Unit, + label: String, + placeholder: String, + icon: androidx.compose.ui.graphics.vector.ImageVector, + tint: Color, + textFieldColors: TextFieldColors, + textFieldShape: androidx.compose.ui.graphics.Shape, + keyboardType: KeyboardType = KeyboardType.Text +) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + modifier = Modifier.padding(start = 4.dp), + text = label, + color = tint, + style = MaterialTheme.typography.labelLarge + ) + OutlinedTextField( + value = value, + shape = textFieldShape, + colors = textFieldColors, + onValueChange = onValueChange, + placeholder = { Text(placeholder) }, + leadingIcon = { Icon(icon, tint = tint, contentDescription = label) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = keyboardType) + ) + } +} + +@Composable +private fun BatchCoverArtEditorCard( + modifier: Modifier = Modifier, + songsCount: Int, + preview: ImageBitmap?, + isDeleted: Boolean, + onPickNewArt: () -> Unit, + onDelete: () -> Unit, + onReset: () -> Unit, +) { + Surface( + modifier = modifier, + shape = AbsoluteSmoothCornerShape( + cornerRadiusTL = 12.dp, smoothnessAsPercentBL = 60, + cornerRadiusTR = 12.dp, smoothnessAsPercentBR = 60, + cornerRadiusBL = 12.dp, smoothnessAsPercentTL = 60, + cornerRadiusBR = 12.dp, smoothnessAsPercentTR = 60, + ), + tonalElevation = 6.dp, + color = MaterialTheme.colorScheme.surfaceContainerHigh + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(14.dp) + ) { + Text( + text = stringResource(R.string.batch_edit_cover_art_heading), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + + BoxWithConstraints( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + val cropSize = minOf(maxWidth, 220.dp) + Box( + modifier = Modifier + .size(cropSize) + .clip(RoundedCornerShape(22.dp)) + .background(MaterialTheme.colorScheme.surfaceContainerHighest), + contentAlignment = Alignment.Center + ) { + when { + isDeleted -> { + Icon( + painter = painterResource(id = R.drawable.rounded_music_note_24), + contentDescription = null, + modifier = Modifier.size(72.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + preview != null -> { + Image( + bitmap = preview, + contentDescription = stringResource(R.string.cd_cover_art_preview), + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } + + else -> { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + imageVector = Icons.Rounded.PhotoLibrary, + contentDescription = null, + modifier = Modifier.size(72.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = stringResource(R.string.batch_edit_multiple_covers), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + } + } + } + Box( + modifier = Modifier + .matchParentSize() + .background( + Brush.verticalGradient( + listOf( + Color.Transparent, + MaterialTheme.colorScheme.surface.copy(alpha = 0.25f), + ) + ) + ) + ) + } + } + + Text( + text = stringResource(R.string.batch_edit_cover_art_hint, songsCount), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically) + ) { + FilledTonalButton(onClick = onPickNewArt) { + Icon(Icons.Rounded.Image, contentDescription = null) + Spacer(modifier = Modifier.width(8.dp)) + Text( + stringResource(R.string.batch_edit_set_cover_art), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally) + ) { + if (preview != null || isDeleted) { + TextButton(onClick = onReset) { + Icon(Icons.Rounded.Restore, contentDescription = null) + Spacer(modifier = Modifier.width(4.dp)) + Text( + stringResource(R.string.action_reset), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } else { + FilledTonalButton( + onClick = onDelete, + colors = ButtonDefaults.filledTonalButtonColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer + ) + ) { + Icon(Icons.Rounded.Delete, contentDescription = null) + Spacer(modifier = Modifier.width(8.dp)) + Text( + stringResource(R.string.batch_edit_remove_all_art), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/EditSongSheet.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/EditSongSheet.kt index 5f52efe43..6040ebc3a 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/EditSongSheet.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/EditSongSheet.kt @@ -843,14 +843,14 @@ private fun CoverArtEditorCard( } } -private data class CoverArtCropResult( +data class CoverArtCropResult( val preview: ImageBitmap, val update: CoverArtUpdate, ) @OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class) @Composable -private fun CoverArtCropperDialog( +fun CoverArtCropperDialog( sourceUri: Uri, onDismiss: () -> Unit, onConfirm: (CoverArtCropResult) -> Unit, diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/MultiSelectionBottomSheet.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/MultiSelectionBottomSheet.kt index 0492d4354..5fefda0b9 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/MultiSelectionBottomSheet.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/MultiSelectionBottomSheet.kt @@ -29,6 +29,7 @@ import androidx.compose.material.icons.automirrored.filled.QueueMusic import androidx.compose.material.icons.automirrored.rounded.PlaylistAdd import androidx.compose.material.icons.automirrored.rounded.QueueMusic import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material.icons.rounded.Edit import androidx.compose.material.icons.rounded.Favorite import androidx.compose.material.icons.rounded.FavoriteBorder import androidx.compose.material.icons.rounded.FolderZip @@ -62,6 +63,7 @@ import androidx.compose.ui.draw.shadow import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.compose.ui.zIndex import coil.size.Size import com.theveloper.pixelplay.data.model.Song @@ -86,6 +88,7 @@ import racra.compose.smooth_corner_rect_library.AbsoluteSmoothCornerShape import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import com.theveloper.pixelplay.R +import com.theveloper.pixelplay.presentation.components.subcomps.AutoSizingTextToFill @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable @@ -99,7 +102,8 @@ fun MultiSelectionBottomSheet( onAddToPlaylist: () -> Unit, onToggleLikeAll: (shouldLike: Boolean) -> Unit, onShareAll: () -> Unit, - onDeleteAll: (activity: Activity, onResult: (Boolean) -> Unit) -> Unit + onDeleteAll: (activity: Activity, onResult: (Boolean) -> Unit) -> Unit, + onBatchEdit: () -> Unit ) { val context = LocalContext.current val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) @@ -182,13 +186,18 @@ fun MultiSelectionBottomSheet( Spacer(modifier = Modifier.width(16.dp)) // Song count and label - Column { - Text( + Column( + modifier = Modifier + .weight(1f) + .fillMaxHeight(), + ){ + AutoSizingTextToFill( text = stringResource(R.string.multi_selection_songs_count_upper, selectedSongs.size), style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, fontFamily = GoogleSansRounded, - color = MaterialTheme.colorScheme.onSurface + modifier = Modifier.padding(end = 4.dp), + maxFontSizeLimit = 30.sp, ) Spacer( modifier = Modifier @@ -198,8 +207,28 @@ fun MultiSelectionBottomSheet( Text( text = stringResource(R.string.multi_selection_selected), style = MaterialTheme.typography.bodyLarge, + fontFamily = GoogleSansRounded, color = MaterialTheme.colorScheme.onSurfaceVariant, - fontFamily = GoogleSansRounded + ) + } + + //Batch edit button + FilledTonalIconButton( + modifier = Modifier + .fillMaxHeight() + .padding(vertical = 6.dp), + colors = IconButtonDefaults.filledTonalIconButtonColors( + containerColor = MaterialTheme.colorScheme.surfaceBright, + contentColor = MaterialTheme.colorScheme.onSurface + ), + onClick = { + onBatchEdit() + }, + ) { + Icon( + modifier = Modifier.padding(horizontal = 8.dp), + imageVector = Icons.Rounded.Edit, + contentDescription = stringResource(R.string.cd_edit_song_metadata) ) } } diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibraryScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibraryScreen.kt index 97817275a..8a4ff0a67 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibraryScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibraryScreen.kt @@ -148,6 +148,7 @@ import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.ui.res.stringResource import com.theveloper.pixelplay.presentation.components.PlaylistArtCollage import com.theveloper.pixelplay.presentation.components.ReorderTabsSheet +import com.theveloper.pixelplay.presentation.components.EditMultipleSongsSheet import com.theveloper.pixelplay.presentation.components.SongInfoBottomSheet import com.theveloper.pixelplay.presentation.components.subcomps.LibraryActionRow import com.theveloper.pixelplay.presentation.navigation.Screen @@ -524,6 +525,7 @@ fun LibraryScreen( val selectedAlbumIds = remember(selectedAlbums) { selectedAlbums.map { it.id }.toSet() } val isAlbumSelectionMode = selectedAlbums.isNotEmpty() var showAlbumMultiSelectionSheet by remember { mutableStateOf(false) } + var showBatchEditSheet by remember { mutableStateOf(false) } var songsShowLocateButton by remember { mutableStateOf(false) } var likedShowLocateButton by remember { mutableStateOf(false) } @@ -1990,6 +1992,10 @@ fun LibraryScreen( onComplete(true) } } + }, + onBatchEdit = { + showMultiSelectionSheet = false + showBatchEditSheet = true } ) } @@ -2152,6 +2158,32 @@ fun LibraryScreen( } ) } + + // Batch Edit Sheet + if (showBatchEditSheet && selectedSongs.isNotEmpty()) { + EditMultipleSongsSheet( + visible = showBatchEditSheet, + songs = selectedSongs, + onDismiss = { showBatchEditSheet = false }, + onSave = { songs, title, artist, album, albumArtist, composer, genre, lyrics, trackNumber, discNumber, replayGainTrackGainDb, replayGainAlbumGainDb, coverArtUpdate -> + playerViewModel.saveBatchMetadata( + songs = songs, + title = title, + artist = artist, + album = album, + albumArtist = albumArtist, + composer = composer, + genre = genre, + lyrics = lyrics, + trackNumber = trackNumber, + discNumber = discNumber, + replayGainTrackGainDb = replayGainTrackGainDb, + replayGainAlbumGainDb = replayGainAlbumGainDb, + coverArtUpdate = coverArtUpdate + ) + } + ) + } } @Composable diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModel.kt index e5da21076..d3b91daf3 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModel.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModel.kt @@ -6,6 +6,7 @@ import android.net.Uri import android.os.Trace import android.media.MediaMetadataRetriever import android.util.Log +import kotlinx.coroutines.withContext import androidx.compose.animation.core.Animatable import androidx.core.content.ContextCompat import com.theveloper.pixelplay.data.model.LibraryTabId @@ -204,6 +205,22 @@ private data class PendingMetadataEdit( val coverArtUpdate: CoverArtUpdate? ) +private data class PendingBatchMetadataEdit( + val songs: List, + val title: String?, + val artist: String?, + val album: String?, + val albumArtist: String?, + val composer: String?, + val genre: String?, + val lyrics: String?, + val trackNumber: Int?, + val discNumber: Int?, + val replayGainTrackGainDb: String?, + val replayGainAlbumGainDb: String?, + val coverArtUpdate: CoverArtUpdate? +) + private data class PendingLyricsSave( val song: Song, val lyrics: Lyrics, @@ -687,6 +704,7 @@ class PlayerViewModel @Inject constructor( val deletePermissionRequest: SharedFlow = _deletePermissionRequest.asSharedFlow() private var pendingMetadataEdit: PendingMetadataEdit? = null + private var pendingBatchMetadataEdit: PendingBatchMetadataEdit? = null private var pendingLyricsSave: PendingLyricsSave? = null private var pendingDeleteSong: Song? = null private var pendingDeleteCallback: ((Boolean) -> Unit)? = null @@ -4746,6 +4764,200 @@ class PlayerViewModel @Inject constructor( lyricsStateHolder.loadLyricsForSong(currentSong, lyricsSourcePreference.value) } + fun saveBatchMetadata( + songs: List, + title: String?, + artist: String?, + album: String?, + albumArtist: String?, + composer: String?, + genre: String?, + lyrics: String?, + trackNumber: Int?, + discNumber: Int?, + replayGainTrackGainDb: String?, + replayGainAlbumGainDb: String?, + coverArtUpdate: CoverArtUpdate? + ) { + viewModelScope.launch { + // Check if we need MediaStore permission (Android 11+) + val localSongsNeedingPermission = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) { + songs.mapNotNull { song -> + song.id.toLongOrNull()?.takeIf { it > 0 }?.let { song to it } + } + } else { + emptyList() + } + + // If we have local songs on Android 11+, request permission for batch edit + if (localSongsNeedingPermission.isNotEmpty()) { + val uris = localSongsNeedingPermission.mapNotNull { (_, songId) -> + android.provider.MediaStore.Audio.Media.getContentUri( + android.provider.MediaStore.VOLUME_EXTERNAL_PRIMARY, + songId + ) + } + + if (uris.isNotEmpty()) { + val intentSender = com.theveloper.pixelplay.utils.MediaStorePermissionHelper + .createWriteRequestIntentSender(context, uris) + + if (intentSender != null) { + // Store pending batch edit + pendingBatchMetadataEdit = PendingBatchMetadataEdit( + songs = songs, + title = title, + artist = artist, + album = album, + albumArtist = albumArtist, + composer = composer, + genre = genre, + lyrics = lyrics, + trackNumber = trackNumber, + discNumber = discNumber, + replayGainTrackGainDb = replayGainTrackGainDb, + replayGainAlbumGainDb = replayGainAlbumGainDb, + coverArtUpdate = coverArtUpdate + ) + _writePermissionRequest.emit(intentSender) + return@launch + } + } + } + + performBatchMetadataEdit( + songs, title, artist, album, albumArtist, composer, genre, lyrics, + trackNumber, discNumber, replayGainTrackGainDb, replayGainAlbumGainDb, coverArtUpdate + ) + } + } + + private suspend fun performBatchMetadataEdit( + songs: List, + title: String?, + artist: String?, + album: String?, + albumArtist: String?, + composer: String?, + genre: String?, + lyrics: String?, + trackNumber: Int?, + discNumber: Int?, + replayGainTrackGainDb: String?, + replayGainAlbumGainDb: String?, + coverArtUpdate: CoverArtUpdate? + ) { + var successCount = 0 + var failureCount = 0 + val previousAlbumArts = mutableSetOf() + + songs.forEach { song -> + previousAlbumArts.add(song.albumArtUriString) + + val result = metadataEditStateHolder.saveMetadata( + song = song, + newTitle = title ?: song.title, + newArtist = artist ?: song.displayArtist, + newAlbum = album ?: song.album, + newAlbumArtist = albumArtist ?: (song.albumArtist ?: ""), + newComposer = composer ?: "", + newGenre = genre ?: (song.genre ?: ""), + newLyrics = lyrics ?: (song.lyrics ?: ""), + newTrackNumber = trackNumber ?: song.trackNumber, + newDiscNumber = discNumber ?: song.discNumber, + newReplayGainTrackGainDb = replayGainTrackGainDb, + newReplayGainAlbumGainDb = replayGainAlbumGainDb, + coverArtUpdate = coverArtUpdate + ) + + if (result.success && result.updatedSong != null) { + successCount++ + val updatedSong = result.updatedSong + val refreshedAlbumArtUri = result.updatedAlbumArtUri + + // Invalidate caches for this song + invalidateCoverArtCaches(song.albumArtUriString, refreshedAlbumArtUri) + + // Update queue if this song is in it + _playerUiState.update { state -> + val updatedQueue = state.currentPlaybackQueue.replaceSong(updatedSong) + if (updatedQueue === state.currentPlaybackQueue) { + state + } else { + state.copy(currentPlaybackQueue = updatedQueue) + } + } + + // Update library state + libraryStateHolder.updateSong(updatedSong) + + // If this is the current playing song, update it + if (playbackStateHolder.stablePlayerState.value.currentSong?.id == song.id) { + playbackStateHolder.updateStablePlayerState { + it.copy( + currentSong = updatedSong, + lyrics = result.parsedLyrics + ) + } + + // Update MediaItem for notification + val controller = playbackStateHolder.mediaController + if (controller != null) { + val currentIndex = controller.currentMediaItemIndex + if (currentIndex >= 0 && currentIndex < controller.mediaItemCount) { + val currentPosition = controller.currentPosition + val newMediaItem = MediaItemBuilder.build(updatedSong) + controller.replaceMediaItem(currentIndex, newMediaItem) + controller.seekTo(currentIndex, currentPosition) + } + } + } + + // Update selected song for info sheet if needed + if (_selectedSongForInfo.value?.id == song.id) { + _selectedSongForInfo.value = updatedSong + } + } else { + failureCount++ + } + } + + // Handle cover art theme updates if artwork was changed + if (coverArtUpdate != null) { + previousAlbumArts.forEach { previousArt -> + purgeAlbumArtThemes(previousArt, null) + } + + // Regenerate theme for current song if it was edited + val currentSongId = playbackStateHolder.stablePlayerState.value.currentSong?.id + if (currentSongId != null && songs.any { it.id == currentSongId }) { + val currentSong = playbackStateHolder.stablePlayerState.value.currentSong + val paletteTargetUri = currentSong?.albumArtUriString + if (paletteTargetUri != null) { + themeStateHolder.getAlbumColorSchemeFlow(paletteTargetUri) + themeStateHolder.extractAndGenerateColorScheme( + paletteTargetUri.toUri(), + paletteTargetUri, + isPreload = false + ) + } else { + themeStateHolder.extractAndGenerateColorScheme(null, null, isPreload = false) + } + } + } + + // Clear multi-selection + multiSelectionStateHolder.clearSelection() + + // Show result toast + val message = when { + failureCount == 0 -> context.getString(R.string.batch_edit_success, successCount) + successCount == 0 -> context.getString(R.string.batch_edit_failed) + else -> context.getString(R.string.batch_edit_partial_success, successCount, songs.size) + } + _toastEvents.emit(message) + } + fun editSongMetadata( song: Song, newTitle: String, @@ -4798,6 +5010,36 @@ class PlayerViewModel @Inject constructor( /** Called from the UI after the user approves or denies the MediaStore write permission. */ fun onWritePermissionResult(granted: Boolean) { + // Handle batch metadata edit + val batchMetadata = pendingBatchMetadataEdit + if (batchMetadata != null) { + pendingBatchMetadataEdit = null + if (!granted) { + viewModelScope.launch { + _toastEvents.emit(context.getString(R.string.player_permission_denied_edit_files)) + } + return + } + viewModelScope.launch { + performBatchMetadataEdit( + batchMetadata.songs, + batchMetadata.title, + batchMetadata.artist, + batchMetadata.album, + batchMetadata.albumArtist, + batchMetadata.composer, + batchMetadata.genre, + batchMetadata.lyrics, + batchMetadata.trackNumber, + batchMetadata.discNumber, + batchMetadata.replayGainTrackGainDb, + batchMetadata.replayGainAlbumGainDb, + batchMetadata.coverArtUpdate + ) + } + return + } + // Handle batch genre edit val batchGenre = pendingBatchGenreEdit if (batchGenre != null) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2b53febaf..05955fd16 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -228,4 +228,20 @@ %1$d%% v%1$d %1$s %2$s + + + Edit %d Songs + Only modified fields will be updated. Leave fields empty to keep existing values. + (Mixed values) + (Optional - leave empty to skip) + Successfully updated %d songs + Updated %d of %d songs. Some files could not be edited. + Failed to update songs + + + Batch Cover Art + This will replace the cover art for all %d selected songs + Set Cover Art for All + Remove All Cover Art + (Multiple different covers)