From aee35f562ad9703639e1897513ec3912a3c80e19 Mon Sep 17 00:00:00 2001 From: Mraphaelpy Date: Sun, 17 Aug 2025 22:37:28 -0300 Subject: [PATCH 01/12] Implemented a complete playlist system with user interface improvements and multilingual support --- .idea/.gitignore | 3 + .idea/.name | 1 + .idea/AndroidProjectSystem.xml | 6 + .idea/codeStyles/Project.xml | 117 +++ .idea/codeStyles/codeStyleConfig.xml | 5 + .idea/compiler.xml | 6 + .idea/deploymentTargetSelector.xml | 18 + .idea/gradle.xml | 20 + .idea/inspectionProfiles/Project_Default.xml | 50 ++ .idea/migrations.xml | 10 + .idea/misc.xml | 9 + .idea/runConfigurations.xml | 17 + .idea/vcs.xml | 6 + app/build.gradle.kts | 1 + app/src/main/AndroidManifest.xml | 7 + .../files/ui/dialog/FileOptionsMenuDialog.kt | 32 + .../explorer/screen/main/tab/home/HomeTab.kt | 20 + .../screen/playlist/PlaylistActivity.kt | 48 ++ .../screen/playlist/ui/PlaylistDetailSheet.kt | 715 ++++++++++++++++++ .../playlist/ui/PlaylistManagerScreen.kt | 556 ++++++++++++++ .../viewer/audio/AudioPlayerActivity.kt | 16 + .../viewer/audio/AudioPlayerInstance.kt | 304 +++++++- .../screen/viewer/audio/PlaylistManager.kt | 112 +++ .../screen/viewer/audio/model/Playlist.kt | 37 + .../viewer/audio/model/PlaylistState.kt | 26 + .../viewer/audio/ui/AudioPlayerScreen.kt | 122 +++ .../viewer/audio/ui/PlaylistBottomSheet.kt | 254 +++++++ .../audio/ui/PlaylistDetailBottomSheet.kt | 320 ++++++++ app/src/main/res/values-pt-rBR/strings.xml | 34 + app/src/main/res/values/strings.xml | 33 + gradle/libs.versions.toml | 2 + 31 files changed, 2898 insertions(+), 9 deletions(-) create mode 100644 .idea/.gitignore create mode 100644 .idea/.name create mode 100644 .idea/AndroidProjectSystem.xml create mode 100644 .idea/codeStyles/Project.xml create mode 100644 .idea/codeStyles/codeStyleConfig.xml create mode 100644 .idea/compiler.xml create mode 100644 .idea/deploymentTargetSelector.xml create mode 100644 .idea/gradle.xml create mode 100644 .idea/inspectionProfiles/Project_Default.xml create mode 100644 .idea/migrations.xml create mode 100644 .idea/misc.xml create mode 100644 .idea/runConfigurations.xml create mode 100644 .idea/vcs.xml create mode 100644 app/src/main/java/com/raival/compose/file/explorer/screen/playlist/PlaylistActivity.kt create mode 100644 app/src/main/java/com/raival/compose/file/explorer/screen/playlist/ui/PlaylistDetailSheet.kt create mode 100644 app/src/main/java/com/raival/compose/file/explorer/screen/playlist/ui/PlaylistManagerScreen.kt create mode 100644 app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/PlaylistManager.kt create mode 100644 app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/model/Playlist.kt create mode 100644 app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/model/PlaylistState.kt create mode 100644 app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/ui/PlaylistBottomSheet.kt create mode 100644 app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/ui/PlaylistDetailBottomSheet.kt diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 00000000..26d33521 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/.name b/.idea/.name new file mode 100644 index 00000000..c42e2011 --- /dev/null +++ b/.idea/.name @@ -0,0 +1 @@ +File Explorer \ No newline at end of file diff --git a/.idea/AndroidProjectSystem.xml b/.idea/AndroidProjectSystem.xml new file mode 100644 index 00000000..4a53bee8 --- /dev/null +++ b/.idea/AndroidProjectSystem.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml new file mode 100644 index 00000000..4bec4ea8 --- /dev/null +++ b/.idea/codeStyles/Project.xml @@ -0,0 +1,117 @@ + + + + + + \ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 00000000..a55e7a17 --- /dev/null +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 00000000..b86273d9 --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml new file mode 100644 index 00000000..a93e6966 --- /dev/null +++ b/.idea/deploymentTargetSelector.xml @@ -0,0 +1,18 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml new file mode 100644 index 00000000..cdf2fb4a --- /dev/null +++ b/.idea/gradle.xml @@ -0,0 +1,20 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 00000000..f0c6ad08 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,50 @@ + + + + \ No newline at end of file diff --git a/.idea/migrations.xml b/.idea/migrations.xml new file mode 100644 index 00000000..f8051a6f --- /dev/null +++ b/.idea/migrations.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 00000000..b2c751a3 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml new file mode 100644 index 00000000..16660f1d --- /dev/null +++ b/.idea/runConfigurations.xml @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 00000000..35eb1ddf --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e45ef389..b2f2feb1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -49,6 +49,7 @@ android { } dependencies { + implementation(libs.androidx.material3) "baselineProfile"(project(":baselineprofile")) implementation(libs.androidx.profileinstaller) coreLibraryDesugaring(libs.desugar.jdk.libs) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 4f2ecd3d..8832f634 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -150,6 +150,13 @@ android:theme="@style/Theme.FileExplorer" android:windowSoftInputMode="adjustResize" /> + + 1 val isSingleFile = !isMultipleSelection && targetContentHolder.isFile() val isSingleFolder = !isMultipleSelection && targetContentHolder.isFolder + val isAudioFile = isSingleFile && targetContentHolder is LocalFileHolder && + audioFileType.contains(targetContentHolder.file.extension) + + var showPlaylistDialog by remember { mutableStateOf(false) } var hasFolders = false tab.selectedFiles.forEach { @@ -258,6 +266,13 @@ fun FileOptionsMenuDialog( tab.unselectAllFiles() } + // Add to playlist option for audio files + if (isAudioFile) { + FileOption(Icons.AutoMirrored.Rounded.PlaylistAdd, stringResource(R.string.add_to_playlist)) { + showPlaylistDialog = true + } + } + if (apkBundleFileType.contains(targetContentHolder.file.extension)) { FileOption(Icons.Rounded.Merge, stringResource(R.string.convert_to_apk)) { onDismissRequest() @@ -297,6 +312,23 @@ fun FileOptionsMenuDialog( } } } + + // Playlist dialog for audio files + if (isAudioFile) { + PlaylistBottomSheet( + isVisible = showPlaylistDialog, + onDismiss = { + showPlaylistDialog = false + onDismissRequest() + }, + onPlaylistSelected = { playlist -> + showPlaylistDialog = false + onDismissRequest() + tab.unselectAllFiles() + }, + selectedSong = if (targetContentHolder is LocalFileHolder) targetContentHolder else null + ) + } } } diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/home/HomeTab.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/home/HomeTab.kt index e1a26ffb..da9ed151 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/home/HomeTab.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/home/HomeTab.kt @@ -1,6 +1,7 @@ package com.raival.compose.file.explorer.screen.main.tab.home import android.content.ContentResolver +import android.content.Intent import android.database.Cursor import android.net.Uri import android.provider.MediaStore @@ -10,6 +11,7 @@ import androidx.compose.material.icons.rounded.Android import androidx.compose.material.icons.rounded.Archive import androidx.compose.material.icons.rounded.AudioFile import androidx.compose.material.icons.rounded.Image +import androidx.compose.material.icons.rounded.QueueMusic import androidx.compose.material.icons.rounded.VideoFile import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf @@ -102,6 +104,24 @@ class HomeTab : Tab() { ) ) + add( + HomeCategory( + name = globalClass.getString(R.string.playlists), + icon = Icons.Rounded.QueueMusic, + onClick = { + try { + val context = globalClass.applicationContext + val intent = Intent(context, com.raival.compose.file.explorer.screen.playlist.PlaylistActivity::class.java) + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + context.startActivity(intent) + } catch (e: Exception) { + // Log the error or handle it gracefully + e.printStackTrace() + } + } + ) + ) + add( HomeCategory( name = globalClass.getString(R.string.documents), diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/playlist/PlaylistActivity.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/playlist/PlaylistActivity.kt new file mode 100644 index 00000000..5330f604 --- /dev/null +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/playlist/PlaylistActivity.kt @@ -0,0 +1,48 @@ +package com.raival.compose.file.explorer.screen.playlist + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.ui.Modifier +import com.raival.compose.file.explorer.screen.playlist.ui.PlaylistManagerScreen +import com.raival.compose.file.explorer.screen.viewer.audio.AudioPlayerActivity +import com.raival.compose.file.explorer.screen.viewer.audio.PlaylistManager +import com.raival.compose.file.explorer.theme.FileExplorerTheme + +class PlaylistActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + FileExplorerTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + PlaylistManagerScreen( + onBackPressed = { onBackPressedDispatcher.onBackPressed() }, + onPlayPlaylist = { playlist, startIndex -> + PlaylistManager.getInstance().loadPlaylist(playlist.id) + if (playlist.songs.isNotEmpty() && startIndex < playlist.songs.size) { + val firstSong = playlist.songs[startIndex] + val intent = Intent(this@PlaylistActivity, AudioPlayerActivity::class.java).apply { + data = Uri.fromFile(firstSong.file) + putExtra("startIndex", startIndex) + putExtra("fromPlaylist", true) + putExtra("uid", firstSong.uid) + } + startActivity(intent) + finish() + } + } + ) + } + } + } + } +} diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/playlist/ui/PlaylistDetailSheet.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/playlist/ui/PlaylistDetailSheet.kt new file mode 100644 index 00000000..fe12c752 --- /dev/null +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/playlist/ui/PlaylistDetailSheet.kt @@ -0,0 +1,715 @@ +package com.raival.compose.file.explorer.screen.playlist.ui + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +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.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.PlaylistPlay +import androidx.compose.material.icons.filled.Album +import androidx.compose.material.icons.filled.Audiotrack +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.MusicNote +import androidx.compose.material.icons.filled.MusicOff +import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.PlaylistRemove +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +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.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.min +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.raival.compose.file.explorer.R +import com.raival.compose.file.explorer.screen.viewer.audio.PlaylistManager +import com.raival.compose.file.explorer.screen.viewer.audio.model.Playlist +import com.raival.compose.file.explorer.screen.main.tab.files.holder.LocalFileHolder +import kotlinx.coroutines.delay + +@OptIn(ExperimentalFoundationApi::class, ExperimentalAnimationApi::class) +@Composable +fun PlaylistDetailSheet( + playlist: Playlist, + onDismiss: () -> Unit, + onPlayClick: (Int) -> Unit +) { + // Calculate optimal dialog size based on screen size + val configuration = LocalConfiguration.current + val density = LocalDensity.current + + // Use 80% of screen width and height, but limit to reasonable values + val screenWidth = with(density) { configuration.screenWidthDp.dp } + val screenHeight = with(density) { configuration.screenHeightDp.dp } + + val dialogWidth = min(screenWidth * 0.9f, 480.dp) + val dialogHeight = min(screenHeight * 0.8f, 650.dp) + + val lazyListState = rememberLazyListState() + + val isScrolled by remember { + derivedStateOf { lazyListState.firstVisibleItemIndex > 0 || lazyListState.firstVisibleItemScrollOffset > 0 } + } + + var currentlyPlayingIndex by remember { mutableIntStateOf(-1) } + + // Simulating current playing track for UI purposes + LaunchedEffect(playlist) { + if (playlist.songs.isNotEmpty()) { + currentlyPlayingIndex = -1 + } + } + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties( + dismissOnBackPress = true, + dismissOnClickOutside = true, + usePlatformDefaultWidth = false + ) + ) { + Surface( + modifier = Modifier + .width(dialogWidth) + .height(dialogHeight), + shape = RoundedCornerShape(28.dp), + color = MaterialTheme.colorScheme.surface, + shadowElevation = 6.dp + ) { + Box( + modifier = Modifier.fillMaxSize() + ) { + // Close button in the top right + IconButton( + onClick = onDismiss, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(8.dp) + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.close), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + // Header background that shows when scrolled + AnimatedVisibility( + visible = isScrolled, + enter = fadeIn(tween(200)), + exit = fadeOut(tween(200)) + ) { + Surface( + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.95f), + shadowElevation = 4.dp, + modifier = Modifier + .fillMaxWidth() + .height(56.dp) + ) {} + } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp) + .padding(top = 16.dp) + ) { + // Header with playlist info + PlaylistHeader( + playlist = playlist, + isScrolled = isScrolled, + onPlayAllClick = { onPlayClick(0) } + ) + + // Songs List + if (playlist.songs.isEmpty()) { + EmptyPlaylistContent() + } else { + LazyColumn( + state = lazyListState, + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(vertical = 16.dp) + ) { + itemsIndexed( + items = playlist.songs, + key = { _, song -> song.uid } + ) { index, song -> + val isPlaying = index == currentlyPlayingIndex + + SongItem( + song = song, + index = index, + isPlaying = isPlaying, + modifier = Modifier + .animateItem( + placementSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessLow + ) + ) + .fillMaxWidth(), + onPlayClick = { + onPlayClick(index) + currentlyPlayingIndex = if (isPlaying) -1 else index + }, + onRemoveClick = { + PlaylistManager.getInstance().removeSongFromPlaylistAt(playlist.id, index) + if (index == currentlyPlayingIndex) { + currentlyPlayingIndex = -1 + } else if (index < currentlyPlayingIndex) { + currentlyPlayingIndex-- + } + } + ) + } + } + } + + // Bottom spacing for better visual appearance + Spacer(modifier = Modifier.height(16.dp)) + } + } + } + } +} + +@OptIn(ExperimentalAnimationApi::class) +@Composable +private fun PlaylistHeader( + playlist: Playlist, + isScrolled: Boolean, + onPlayAllClick: () -> Unit +) { + val elevation by animateDpAsState( + targetValue = if (isScrolled) 8.dp else 0.dp, + animationSpec = tween(300), + label = "headerElevation" + ) + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = if (isScrolled) 0.dp else 16.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp, bottom = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + // Playlist info with icon + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.weight(1f) + ) { + // Playlist icon with animation + Box( + modifier = Modifier + .size(if (isScrolled) 40.dp else 56.dp) + .clip(RoundedCornerShape(12.dp)) + .background( + Brush.linearGradient( + colors = listOf( + MaterialTheme.colorScheme.primary, + MaterialTheme.colorScheme.tertiary + ) + ) + ), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.PlaylistPlay, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.size(if (isScrolled) 24.dp else 32.dp) + ) + } + + // Playlist name and count + AnimatedContent( + targetState = isScrolled, + label = "HeaderTextAnimation", + transitionSpec = { + fadeIn(tween(300)) togetherWith fadeOut(tween(150)) + } + ) { scrolled -> + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = playlist.name, + style = if (scrolled) { + MaterialTheme.typography.titleMedium + } else { + MaterialTheme.typography.headlineSmall + }, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + Text( + text = stringResource( + R.string.songs_count, + playlist.songs.size + ), + style = if (scrolled) { + MaterialTheme.typography.bodySmall + } else { + MaterialTheme.typography.bodyMedium + }, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + + // Play all button + if (playlist.songs.isNotEmpty()) { + FilledTonalButton( + onClick = onPlayAllClick, + shape = RoundedCornerShape(20.dp), + modifier = Modifier.height(40.dp) + ) { + Icon( + imageVector = Icons.Default.PlayArrow, + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = stringResource(R.string.play_all), + style = MaterialTheme.typography.labelLarge + ) + } + } + } + + // Visual divider that animates based on scroll + HorizontalDivider( + thickness = if (isScrolled) 1.dp else 0.5.dp, + color = if (isScrolled) { + MaterialTheme.colorScheme.outlineVariant + } else { + MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) + } + ) + } +} + +@Composable +private fun EmptyPlaylistContent() { + Box( + modifier = Modifier + .fillMaxWidth() + .height(300.dp), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.padding(24.dp) + ) { + // Empty state icon with animation + var showAnimation by remember { mutableStateOf(true) } + + LaunchedEffect(Unit) { + while (true) { + delay(3000) + showAnimation = !showAnimation + delay(200) + showAnimation = !showAnimation + } + } + + Box( + modifier = Modifier + .size(100.dp) + .clip(CircleShape) + .background( + Brush.radialGradient( + colors = listOf( + MaterialTheme.colorScheme.secondaryContainer, + MaterialTheme.colorScheme.secondary.copy(alpha = 0.3f) + ) + ) + ) + .border( + width = 2.dp, + color = MaterialTheme.colorScheme.secondary.copy(alpha = 0.5f), + shape = CircleShape + ), + contentAlignment = Alignment.Center + ) { + AnimatedContent( + targetState = showAnimation, + label = "EmptyAnimation", + transitionSpec = { + (fadeIn(tween(500)) + scaleIn(tween(500))) togetherWith + (fadeOut(tween(200)) + scaleOut(tween(200))) + } + ) { state -> + Icon( + imageVector = if (state) Icons.Default.MusicNote else Icons.Default.MusicOff, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.onSecondaryContainer + ) + } + } + + Text( + text = stringResource(R.string.empty_playlist), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + + Text( + text = stringResource(R.string.empty_playlist_description), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth(0.8f) + .alpha(0.8f) + ) + } + } +} + +@OptIn(ExperimentalAnimationApi::class) +@Composable +private fun SongItem( + song: LocalFileHolder, + index: Int, + isPlaying: Boolean, + modifier: Modifier = Modifier, + onPlayClick: () -> Unit, + onRemoveClick: () -> Unit +) { + var showRemoveConfirmation by remember { mutableStateOf(false) } + var showDropdownMenu by remember { mutableStateOf(false) } + + val elevation by animateDpAsState( + targetValue = if (isPlaying) 6.dp else 1.dp, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessLow + ), + label = "cardElevation" + ) + + ElevatedCard( + modifier = modifier, + shape = RoundedCornerShape(16.dp), + elevation = CardDefaults.elevatedCardElevation(defaultElevation = elevation), + colors = CardDefaults.elevatedCardColors( + containerColor = if (isPlaying) { + MaterialTheme.colorScheme.secondaryContainer + } else { + MaterialTheme.colorScheme.surface + } + ) + ) { + Box(modifier = Modifier.fillMaxWidth()) { + // Song progress indicator for currently playing song + if (isPlaying) { + LinearProgressIndicator( + progress = { 0.7f }, // Simulate progress for UI demonstration + modifier = Modifier + .fillMaxWidth() + .height(2.dp) + .align(Alignment.TopCenter), + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f) + ) + } + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onPlayClick() } + .padding(vertical = 12.dp, horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Track number or play icon + AnimatedContent( + targetState = isPlaying, + label = "PlayingState", + transitionSpec = { + (fadeIn(tween(300)) + scaleIn(tween(300))) togetherWith + (fadeOut(tween(150)) + scaleOut(tween(150))) + } + ) { playing -> + Box( + modifier = Modifier + .size(40.dp) + .clip(RoundedCornerShape(12.dp)) + .background( + if (playing) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.surfaceVariant + } + ), + contentAlignment = Alignment.Center + ) { + if (playing) { + Icon( + imageVector = Icons.Default.Audiotrack, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimary + ) + } else { + Text( + text = (index + 1).toString(), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = FontWeight.Bold + ) + } + } + } + + Spacer(modifier = Modifier.width(16.dp)) + + // Song info + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = song.displayName, + style = MaterialTheme.typography.titleMedium, + fontWeight = if (isPlaying) FontWeight.Bold else FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = if (isPlaying) { + MaterialTheme.colorScheme.onSecondaryContainer + } else { + MaterialTheme.colorScheme.onSurface + } + ) + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + Icon( + imageVector = Icons.Default.Album, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + ) + + Text( + text = song.basePath, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + fontStyle = FontStyle.Italic + ) + } + } + + // Action buttons + Box { + Row { + // Play/Pause button + AnimatedContent( + targetState = isPlaying, + label = "PlayButtonState", + transitionSpec = { + fadeIn(tween(300)) togetherWith fadeOut(tween(150)) + } + ) { playing -> + IconButton( + onClick = onPlayClick, + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .background( + if (playing) { + MaterialTheme.colorScheme.primary + } else { + Color.Transparent + } + ) + ) { + Icon( + imageVector = if (playing) { + Icons.Default.Pause + } else { + Icons.Default.PlayArrow + }, + contentDescription = stringResource( + if (playing) R.string.pause else R.string.play + ), + tint = if (playing) { + MaterialTheme.colorScheme.onPrimary + } else { + MaterialTheme.colorScheme.primary + } + ) + } + } + + // More options button + IconButton( + onClick = { showDropdownMenu = true } + ) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = stringResource(R.string.more), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + // Dropdown menu + DropdownMenu( + expanded = showDropdownMenu, + onDismissRequest = { showDropdownMenu = false } + ) { + DropdownMenuItem( + text = { + Text(stringResource(R.string.remove_from_playlist)) + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.PlaylistRemove, + contentDescription = null, + tint = MaterialTheme.colorScheme.error + ) + }, + onClick = { + showDropdownMenu = false + showRemoveConfirmation = true + } + ) + } + } + } + } + } + + // Remove confirmation dialog with fixed size and center positioning + if (showRemoveConfirmation) { + AlertDialog( + onDismissRequest = { showRemoveConfirmation = false }, + title = { + Text( + text = stringResource(R.string.remove_song), + fontWeight = FontWeight.Bold + ) + }, + text = { + Text( + text = stringResource(R.string.remove_song_confirmation, song.displayName), + style = MaterialTheme.typography.bodyLarge + ) + }, + confirmButton = { + TextButton( + onClick = { + onRemoveClick() + showRemoveConfirmation = false + } + ) { + Text( + text = stringResource(R.string.remove).uppercase(), + color = MaterialTheme.colorScheme.error, + fontWeight = FontWeight.Bold + ) + } + }, + dismissButton = { + TextButton(onClick = { showRemoveConfirmation = false }) { + Text( + text = stringResource(R.string.cancel).uppercase(), + fontWeight = FontWeight.Medium + ) + } + }, + shape = RoundedCornerShape(28.dp), + properties = DialogProperties( + dismissOnBackPress = true, + dismissOnClickOutside = true, + usePlatformDefaultWidth = false + ), + modifier = Modifier.fillMaxWidth(0.9f) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/playlist/ui/PlaylistManagerScreen.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/playlist/ui/PlaylistManagerScreen.kt new file mode 100644 index 00000000..382edfab --- /dev/null +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/playlist/ui/PlaylistManagerScreen.kt @@ -0,0 +1,556 @@ +package com.raival.compose.file.explorer.screen.playlist.ui + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.QueueMusic +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.GraphicEq +import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.raival.compose.file.explorer.R +import com.raival.compose.file.explorer.screen.viewer.audio.PlaylistManager +import com.raival.compose.file.explorer.screen.viewer.audio.model.Playlist + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class, ExperimentalAnimationApi::class) +@Composable +fun PlaylistManagerScreen( + onBackPressed: () -> Unit, + onPlayPlaylist: (Playlist, Int) -> Unit +) { + val playlistManager = remember { PlaylistManager.getInstance() } + val playlistState by playlistManager.playlists.collectAsStateWithLifecycle(initialValue = emptyList()) + val currentPlaylist by playlistManager.currentPlaylist.collectAsStateWithLifecycle(initialValue = null) + + var showCreateDialog by remember { mutableStateOf(false) } + var showPlaylistDetail by remember { mutableStateOf(null) } + + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() + val lazyListState = rememberLazyListState() + + val isScrolled = remember { + derivedStateOf { lazyListState.firstVisibleItemIndex > 0 || lazyListState.firstVisibleItemScrollOffset > 0 } + } + + val fabExpanded by animateFloatAsState( + targetValue = if (isScrolled.value) 0f else 1f, + animationSpec = tween(300), + label = "fabExpanded" + ) + + Scaffold( + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + containerColor = MaterialTheme.colorScheme.background, + topBar = { + TopAppBar( + title = { + Text( + text = stringResource(R.string.playlists), + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + navigationIcon = { + IconButton(onClick = onBackPressed) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.back) + ) + } + }, + scrollBehavior = scrollBehavior, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.background, + scrolledContainerColor = MaterialTheme.colorScheme.background.copy(alpha = 0.95f) + ) + ) + }, + floatingActionButton = { + ExtendedFloatingActionButton( + onClick = { showCreateDialog = true }, + icon = { + Icon( + imageVector = Icons.Default.Add, + contentDescription = null + ) + }, + text = { + AnimatedVisibility( + visible = fabExpanded > 0.5f, + enter = fadeIn() + slideInVertically(), + exit = fadeOut() + slideOutVertically() + ) { + Text(stringResource(R.string.create_playlist)) + } + }, + expanded = fabExpanded > 0.5f, + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + ) { paddingValues -> + AnimatedContent( + targetState = playlistState.isEmpty(), + label = "ContentAnimation", + transitionSpec = { + fadeIn(tween(400)) togetherWith fadeOut(tween(200)) + } + ) { isEmpty -> + if (isEmpty) { + EmptyPlaylistsState( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + onCreatePlaylist = { showCreateDialog = true } + ) + } else { + LazyColumn( + state = lazyListState, + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(vertical = 16.dp) + ) { + items( + items = playlistState, + key = { it.id } + ) { playlist -> + PlaylistCard( + playlist = playlist, + isCurrentlyPlaying = currentPlaylist?.id == playlist.id, + modifier = Modifier + .animateItem(tween(400)) + .fillMaxWidth(), + onClick = { showPlaylistDetail = playlist }, + onPlayClick = { onPlayPlaylist(playlist, 0) }, + onDeleteClick = { playlistManager.deletePlaylist(playlist.id) } + ) + } + } + } + } + } + + // Create Playlist Dialog + if (showCreateDialog) { + CreatePlaylistDialog( + onDismiss = { showCreateDialog = false }, + onConfirm = { name -> + playlistManager.createPlaylist(name) + showCreateDialog = false + } + ) + } + + // Playlist Detail Sheet + showPlaylistDetail?.let { playlist -> + PlaylistDetailSheet( + playlist = playlist, + onDismiss = { showPlaylistDetail = null }, + onPlayClick = { index -> onPlayPlaylist(playlist, index) } + ) + } +} + +@Composable +private fun EmptyPlaylistsState( + modifier: Modifier = Modifier, + onCreatePlaylist: () -> Unit +) { + Box( + modifier = modifier, + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier.padding(24.dp) + ) { + Surface( + modifier = Modifier + .size(120.dp), + shape = CircleShape, + color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.7f), + contentColor = MaterialTheme.colorScheme.onPrimaryContainer + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = Icons.AutoMirrored.Filled.QueueMusic, + contentDescription = null, + modifier = Modifier.size(64.dp) + ) + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + Text( + text = stringResource(R.string.no_playlists), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onBackground + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = stringResource(R.string.no_playlists_description), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.alpha(0.8f) + ) + + Spacer(modifier = Modifier.height(24.dp)) + + FilledTonalButton( + onClick = onCreatePlaylist, + modifier = Modifier.height(48.dp), + shape = RoundedCornerShape(24.dp) + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = null + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.create_playlist), + style = MaterialTheme.typography.labelLarge + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalAnimationApi::class) +@Composable +private fun PlaylistCard( + playlist: Playlist, + isCurrentlyPlaying: Boolean, + modifier: Modifier = Modifier, + onClick: () -> Unit, + onPlayClick: () -> Unit, + onDeleteClick: () -> Unit +) { + var showDeleteConfirmation by remember { mutableStateOf(false) } + + val cardColor = if (isCurrentlyPlaying) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surface + } + + Card( + onClick = onClick, + modifier = modifier, + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = cardColor + ), + elevation = CardDefaults.cardElevation( + defaultElevation = 0.dp, + pressedElevation = 0.dp, + focusedElevation = 0.dp, + hoveredElevation = 0.dp, + draggedElevation = 0.dp, + disabledElevation = 0.dp + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Surface( + modifier = Modifier.size(48.dp), + shape = RoundedCornerShape(12.dp), + color = if (isCurrentlyPlaying) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.secondaryContainer + }, + contentColor = if (isCurrentlyPlaying) { + MaterialTheme.colorScheme.onPrimary + } else { + MaterialTheme.colorScheme.onSecondaryContainer + } + ) { + Box(contentAlignment = Alignment.Center) { + AnimatedContent( + targetState = isCurrentlyPlaying, + label = "PlayingIconAnimation", + transitionSpec = { + fadeIn(tween(400)) + scaleIn(tween(400)) togetherWith + fadeOut(tween(200)) + scaleOut(tween(200)) + } + ) { playing -> + Icon( + imageVector = if (playing) { + Icons.Default.GraphicEq + } else { + Icons.AutoMirrored.Filled.QueueMusic + }, + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + } + } + } + + Spacer(modifier = Modifier.width(16.dp)) + + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = playlist.name, + style = MaterialTheme.typography.titleMedium, + fontWeight = if (isCurrentlyPlaying) FontWeight.Bold else FontWeight.SemiBold, + color = if (isCurrentlyPlaying) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurface + }, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + Text( + text = stringResource(R.string.songs_count, playlist.songs.size), + style = MaterialTheme.typography.bodyMedium, + color = if (isCurrentlyPlaying) { + MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f) + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + ) + } + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + // Play button + Surface( + onClick = onPlayClick, + enabled = playlist.songs.isNotEmpty(), + shape = CircleShape, + color = if (isCurrentlyPlaying) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.secondaryContainer + }, + contentColor = if (isCurrentlyPlaying) { + MaterialTheme.colorScheme.onPrimary + } else { + MaterialTheme.colorScheme.onSecondaryContainer + }, + modifier = Modifier.size(40.dp) + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = if (isCurrentlyPlaying) { + Icons.Default.Pause + } else { + Icons.Default.PlayArrow + }, + contentDescription = if (isCurrentlyPlaying) { + stringResource(R.string.pause) + } else { + stringResource(R.string.play) + }, + modifier = Modifier.size(24.dp) + ) + } + } + + // Delete button + Surface( + onClick = { showDeleteConfirmation = true }, + shape = CircleShape, + color = Color.Transparent, + contentColor = MaterialTheme.colorScheme.error, + modifier = Modifier.size(40.dp) + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = stringResource(R.string.delete), + modifier = Modifier.size(24.dp) + ) + } + } + } + } + } + + if (showDeleteConfirmation) { + AlertDialog( + onDismissRequest = { showDeleteConfirmation = false }, + title = { + Text( + text = stringResource(R.string.delete_playlist), + fontWeight = FontWeight.Bold + ) + }, + text = { + Text( + text = stringResource(R.string.delete_playlist_confirmation, playlist.name), + style = MaterialTheme.typography.bodyLarge + ) + }, + confirmButton = { + TextButton( + onClick = { + onDeleteClick() + showDeleteConfirmation = false + } + ) { + Text( + text = stringResource(R.string.delete).uppercase(), + color = MaterialTheme.colorScheme.error, + fontWeight = FontWeight.Bold + ) + } + }, + dismissButton = { + TextButton(onClick = { showDeleteConfirmation = false }) { + Text( + text = stringResource(R.string.cancel).uppercase(), + fontWeight = FontWeight.Medium + ) + } + }, + containerColor = MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape(28.dp) + ) + } +} + +@Composable +private fun CreatePlaylistDialog( + onDismiss: () -> Unit, + onConfirm: (String) -> Unit +) { + var playlistName by remember { mutableStateOf("") } + val density = LocalDensity.current + + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + text = stringResource(R.string.create_playlist), + fontWeight = FontWeight.Bold + ) + }, + text = { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = stringResource(R.string.enter_playlist_name), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + OutlinedTextField( + value = playlistName, + onValueChange = { playlistName = it }, + label = { Text(stringResource(R.string.playlist_name)) }, + singleLine = true, + shape = RoundedCornerShape(12.dp), + modifier = Modifier.fillMaxWidth() + ) + } + }, + confirmButton = { + TextButton( + onClick = { onConfirm(playlistName.trim()) }, + enabled = playlistName.trim().isNotEmpty() + ) { + Text( + text = stringResource(R.string.create).uppercase(), + fontWeight = FontWeight.Bold + ) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text( + text = stringResource(R.string.cancel).uppercase(), + fontWeight = FontWeight.Medium + ) + } + }, + containerColor = MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape(28.dp) + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/AudioPlayerActivity.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/AudioPlayerActivity.kt index 9e1224b2..7205a7dc 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/AudioPlayerActivity.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/AudioPlayerActivity.kt @@ -16,6 +16,22 @@ class AudioPlayerActivity : ViewerActivity() { } override fun onReady(instance: ViewerInstance) { + // Verifica se veio de uma playlist + val fromPlaylist = intent.getBooleanExtra("fromPlaylist", false) + val startIndex = intent.getIntExtra("startIndex", 0) + + if (fromPlaylist) { + // Inicializa com playlist + val playlistManager = PlaylistManager.getInstance() + playlistManager.currentPlaylist.value?.let { playlist -> + if (playlist.songs.isNotEmpty() && startIndex < playlist.songs.size) { + val audioInstance = instance as AudioPlayerInstance + // Usar loadPlaylist em vez de initializePlaylistMode + audioInstance.loadPlaylist(playlist, startIndex) + } + } + } + setContent { FileExplorerTheme { MusicPlayerScreen( diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/AudioPlayerInstance.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/AudioPlayerInstance.kt index a6264f2c..9915e5b6 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/AudioPlayerInstance.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/AudioPlayerInstance.kt @@ -14,10 +14,13 @@ import com.raival.compose.file.explorer.App.Companion.globalClass import com.raival.compose.file.explorer.App.Companion.logger import com.raival.compose.file.explorer.R import com.raival.compose.file.explorer.common.isNot +import com.raival.compose.file.explorer.screen.main.tab.files.holder.LocalFileHolder import com.raival.compose.file.explorer.screen.viewer.ViewerInstance import com.raival.compose.file.explorer.screen.viewer.audio.model.AudioMetadata import com.raival.compose.file.explorer.screen.viewer.audio.model.AudioPlayerColorScheme import com.raival.compose.file.explorer.screen.viewer.audio.model.PlayerState +import com.raival.compose.file.explorer.screen.viewer.audio.model.Playlist +import com.raival.compose.file.explorer.screen.viewer.audio.model.PlaylistState import com.raival.compose.file.explorer.screen.viewer.audio.ui.extractColorsFromBitmap import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -49,12 +52,32 @@ class AudioPlayerInstance( private val _colorScheme = MutableStateFlow(AudioPlayerColorScheme()) val audioPlayerColorScheme: StateFlow = _colorScheme.asStateFlow() + private val _playlistState = MutableStateFlow(PlaylistState()) + val playlistState: StateFlow = _playlistState.asStateFlow() + + private val playlistManager = PlaylistManager.getInstance() + private var exoPlayer: ExoPlayer? = null private var positionTrackingJob: Job? = null @OptIn(UnstableApi::class) suspend fun initializePlayer(context: Context, uri: Uri) { withContext(Dispatchers.Main) { + // Release previous player if it exists + exoPlayer?.let { player -> + player.stop() + player.release() + } + + // Reset player state when changing songs + _playerState.update { + it.copy( + currentPosition = 0L, + duration = 0L, + isLoading = true + ) + } + exoPlayer = ExoPlayer.Builder(context).build().apply { val mediaItem = MediaItem.Builder() .setUri(uri) @@ -84,6 +107,11 @@ class AudioPlayerInstance( ) } } + + // Handle automatic progression to next song + if (playbackState == Player.STATE_ENDED) { + handleSongEnded() + } } }) } @@ -93,24 +121,95 @@ class AudioPlayerInstance( startPositionTracking() } + // Overloaded method for better metadata extraction from LocalFileHolder + suspend fun initializePlayer(context: Context, uri: Uri, fileHolder: LocalFileHolder? = null) { + withContext(Dispatchers.Main) { + // Release previous player if it exists + exoPlayer?.let { player -> + player.stop() + player.release() + } + + // Reset player state when changing songs + _playerState.update { + it.copy( + currentPosition = 0L, + duration = 0L, + isLoading = true + ) + } + + exoPlayer = ExoPlayer.Builder(context).build().apply { + val mediaItem = MediaItem.Builder() + .setUri(uri) + .build() + + setMediaItem(mediaItem) + prepare() + + addListener(object : Player.Listener { + override fun onIsPlayingChanged(isPlaying: Boolean) { + _playerState.update { + it.copy(isPlaying = isPlaying) + } + } + + override fun onPlaybackStateChanged(playbackState: Int) { + _playerState.update { + it.copy( + isLoading = playbackState == Player.STATE_BUFFERING + ) + } + + if (playbackState == Player.STATE_READY) { + _playerState.update { + it.copy( + duration = duration + ) + } + } + + // Handle automatic progression to next song + if (playbackState == Player.STATE_ENDED) { + handleSongEnded() + } + } + }) + } + } + + extractMetadata(context, uri, fileHolder) + startPositionTracking() + } + fun setDefaultColorScheme(colorScheme: AudioPlayerColorScheme) { _colorScheme.value = colorScheme } - private suspend fun extractMetadata(context: Context, uri: Uri) { + private suspend fun extractMetadata(context: Context, uri: Uri, fileHolder: LocalFileHolder? = null) { withContext(Dispatchers.IO) { try { val retriever = MediaMetadataRetriever() - retriever.setDataSource(context, uri) + + // Try to use file path first if available, then URI + if (fileHolder != null) { + retriever.setDataSource(fileHolder.file.absolutePath) + } else { + retriever.setDataSource(context, uri) + } val title = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_TITLE) - ?: globalClass.getString(R.string.unknown_title) + ?: (fileHolder?.displayName?.substringBeforeLast('.') + ?: uri.lastPathSegment?.substringBeforeLast('.') + ?: globalClass.getString(R.string.unknown_title)) + val artist = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_ARTIST) ?: globalClass.getString(R.string.unknown_artist) + val album = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_ALBUM) ?: globalClass.getString(R.string.unknown_album) - val durationStr = - retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION) + + val durationStr = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION) val duration = durationStr?.toLongOrNull() ?: 0L // Extract album art @@ -138,14 +237,52 @@ class AudioPlayerInstance( retriever.release() } catch (e: Exception) { logger.logError(e) - // Fallback metadata + // Fallback metadata using file name from LocalFileHolder or URI + val fileName = fileHolder?.displayName ?: uri.lastPathSegment ?: "Unknown" //TODO: change to string resource + val title = fileName.substringBeforeLast('.').ifEmpty { fileName } + _metadata.value = AudioMetadata( - title = uri.lastPathSegment ?: globalClass.getString(R.string.unknown_title) + title = title, + artist = globalClass.getString(R.string.unknown_artist), + album = globalClass.getString(R.string.unknown_album) ) } } } + private fun handleSongEnded() { + val currentState = _playlistState.value + when (_playerState.value.repeatMode) { + Player.REPEAT_MODE_ONE -> { + // Repeat current song + exoPlayer?.seekTo(0) + exoPlayer?.play() + } + Player.REPEAT_MODE_ALL -> { + if (currentState.hasNextSong()) { + skipToNext() + } else { + // Go back to first song if we've reached the end + stopCurrentPlayer() + _playlistState.update { it.copy(currentSongIndex = 0) } + val firstSong = _playlistState.value.getCurrentSong() + firstSong?.let { song -> + CoroutineScope(Dispatchers.Main).launch { + val songUri = Uri.fromFile(song.file) + initializePlayer(globalClass, songUri, song) + } + } + } + } + else -> { + // Play next song if available + if (currentState.hasNextSong()) { + skipToNext() + } + } + } + } + private fun startPositionTracking() { positionTrackingJob?.cancel() positionTrackingJob = CoroutineScope(Dispatchers.Main).launch { @@ -178,11 +315,23 @@ class AudioPlayerInstance( } fun skipNext() { - exoPlayer?.seekToNext() + // If we have a playlist loaded, use playlist navigation + if (_playlistState.value.currentPlaylist != null) { + skipToNext() + } else { + // Fallback to original ExoPlayer navigation + exoPlayer?.seekToNext() + } } fun skipPrevious() { - exoPlayer?.seekToPrevious() + // If we have a playlist loaded, use playlist navigation + if (_playlistState.value.currentPlaylist != null) { + skipToPrevious() + } else { + // Fallback to original ExoPlayer navigation + exoPlayer?.seekToPrevious() + } } fun setPlaybackSpeed(speed: Float) { @@ -212,9 +361,146 @@ class AudioPlayerInstance( _isVolumeVisible.value = !_isVolumeVisible.value } + // Playlist management methods + fun loadPlaylist(playlist: Playlist, startIndex: Int = 0) { + if (playlist.isEmpty()) return + + // Stop current playback first + stopCurrentPlayer() + + val shuffledIndices = if (_playlistState.value.isShuffled) { + playlist.songs.indices.shuffled() + } else { + emptyList() + } + + _playlistState.update { + it.copy( + currentPlaylist = playlist, + currentSongIndex = startIndex, + shuffledIndices = shuffledIndices + ) + } + + playlistManager.setCurrentPlaylist(playlist) + + // Load the first song + val songToPlay = _playlistState.value.getCurrentSong() + songToPlay?.let { song -> + CoroutineScope(Dispatchers.Main).launch { + val songUri = Uri.fromFile(song.file) + initializePlayer(globalClass, songUri, song) + } + } + } + + fun skipToNext() { + val currentState = _playlistState.value + currentState.currentPlaylist?.let { playlist -> + if (currentState.hasNextSong()) { + // Stop current playback first + stopCurrentPlayer() + + val nextIndex = currentState.currentSongIndex + 1 + _playlistState.update { it.copy(currentSongIndex = nextIndex) } + + val nextSong = _playlistState.value.getCurrentSong() + nextSong?.let { song -> + CoroutineScope(Dispatchers.Main).launch { + val songUri = Uri.fromFile(song.file) + initializePlayer(globalClass, songUri, song) + } + } + } + } + } + + fun skipToPrevious() { + val currentState = _playlistState.value + currentState.currentPlaylist?.let { playlist -> + if (currentState.hasPreviousSong()) { + // Stop current playback first + stopCurrentPlayer() + + val previousIndex = currentState.currentSongIndex - 1 + _playlistState.update { it.copy(currentSongIndex = previousIndex) } + + val previousSong = _playlistState.value.getCurrentSong() + previousSong?.let { song -> + CoroutineScope(Dispatchers.Main).launch { + val songUri = Uri.fromFile(song.file) + initializePlayer(globalClass, songUri, song) + } + } + } + } + } + + fun skipToSong(index: Int) { + val currentState = _playlistState.value + currentState.currentPlaylist?.let { playlist -> + val actualIndex = if (currentState.isShuffled && currentState.shuffledIndices.isNotEmpty()) { + currentState.shuffledIndices.getOrNull(index) ?: index + } else { + index + } + + if (actualIndex in 0 until playlist.songs.size) { + // Stop current playback first + stopCurrentPlayer() + + _playlistState.update { it.copy(currentSongIndex = index) } + + val song = playlist.songs[actualIndex] + CoroutineScope(Dispatchers.Main).launch { + val songUri = Uri.fromFile(song.file) + initializePlayer(globalClass, songUri, song) + } + } + } + } + + fun toggleShuffle() { + val currentState = _playlistState.value + currentState.currentPlaylist?.let { playlist -> + val newShuffledState = !currentState.isShuffled + val shuffledIndices = if (newShuffledState) { + playlist.songs.indices.shuffled() + } else { + emptyList() + } + + _playlistState.update { + it.copy( + isShuffled = newShuffledState, + shuffledIndices = shuffledIndices, + currentSongIndex = 0 // Reset to first song when toggling shuffle + ) + } + } + } + + fun clearPlaylist() { + _playlistState.update { PlaylistState() } + playlistManager.clearCurrentPlaylist() + } + + private fun stopCurrentPlayer() { + exoPlayer?.let { player -> + if (player.isPlaying) { + player.stop() + } + } + } + override fun onClose() { positionTrackingJob?.cancel() exoPlayer?.release() exoPlayer = null + clearPlaylist() + } + + fun initializePlaylistMode(playlist: Playlist, startIndex: Int = 0) { + loadPlaylist(playlist, startIndex) } } \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/PlaylistManager.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/PlaylistManager.kt new file mode 100644 index 00000000..f5d0cb7b --- /dev/null +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/PlaylistManager.kt @@ -0,0 +1,112 @@ +package com.raival.compose.file.explorer.screen.viewer.audio + +import com.raival.compose.file.explorer.screen.main.tab.files.holder.LocalFileHolder +import com.raival.compose.file.explorer.screen.viewer.audio.model.Playlist +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +class PlaylistManager { + private val _playlists = MutableStateFlow>(emptyList()) + val playlists: StateFlow> = _playlists.asStateFlow() + + private val _currentPlaylist = MutableStateFlow(null) + val currentPlaylist: StateFlow = _currentPlaylist.asStateFlow() + + fun createPlaylist(name: String): Playlist { + val playlist = Playlist(name = name) + _playlists.value = _playlists.value + playlist + return playlist + } + + fun createPlaylistWithSong(name: String, song: LocalFileHolder): Playlist { + val playlist = Playlist(name = name) + playlist.addSong(song) + _playlists.value = _playlists.value + playlist + return playlist + } + + fun addSongToPlaylist(playlistId: String, song: LocalFileHolder) { + _playlists.value = _playlists.value.map { playlist -> + if (playlist.id == playlistId) { + playlist.copy().apply { addSong(song) } + } else { + playlist + } + } + + // Update current playlist if it's the one being modified + if (_currentPlaylist.value?.id == playlistId) { + _currentPlaylist.value = _playlists.value.find { it.id == playlistId } + } + } + + fun removeSongFromPlaylistAt(playlistId: String, index: Int) { + _playlists.value = _playlists.value.map { playlist -> + if (playlist.id == playlistId) { + playlist.copy().apply { removeSongAt(index) } + } else { + playlist + } + } + + // Update current playlist if it's the one being modified + if (_currentPlaylist.value?.id == playlistId) { + _currentPlaylist.value = _playlists.value.find { it.id == playlistId } + } + } + + fun deletePlaylist(playlistId: String) { + _playlists.value = _playlists.value.filter { it.id != playlistId } + + // Clear current playlist if it was deleted + if (_currentPlaylist.value?.id == playlistId) { + _currentPlaylist.value = null + } + } + + fun setCurrentPlaylist(playlist: Playlist) { + _currentPlaylist.value = playlist + } + + fun clearCurrentPlaylist() { + _currentPlaylist.value = null + } + + fun loadPlaylist(playlistId: String) { + val playlist = _playlists.value.find { it.id == playlistId } + _currentPlaylist.value = playlist + } + + fun getPlaylistById(id: String): Playlist? { + return _playlists.value.find { it.id == id } + } + + fun updatePlaylistName(playlistId: String, newName: String) { + _playlists.value = _playlists.value.map { playlist -> + if (playlist.id == playlistId) { + playlist.copy(name = newName) + } else { + playlist + } + } + + // Update current playlist if it's the one being modified + if (_currentPlaylist.value?.id == playlistId) { + _currentPlaylist.value = _playlists.value.find { it.id == playlistId } + } + } + + companion object { + @Volatile + private var INSTANCE: PlaylistManager? = null + + fun getInstance(): PlaylistManager { + return INSTANCE ?: synchronized(this) { + val instance = INSTANCE ?: PlaylistManager() + INSTANCE = instance + instance + } + } + } +} diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/model/Playlist.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/model/Playlist.kt new file mode 100644 index 00000000..0fac39a6 --- /dev/null +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/model/Playlist.kt @@ -0,0 +1,37 @@ +package com.raival.compose.file.explorer.screen.viewer.audio.model + +import android.net.Uri +import com.raival.compose.file.explorer.screen.main.tab.files.holder.LocalFileHolder +import java.util.UUID + +data class Playlist( + val id: String = UUID.randomUUID().toString(), + val name: String, + val songs: MutableList = mutableListOf(), + val createdAt: Long = System.currentTimeMillis(), + val currentSongIndex: Int = 0 +) { + fun addSong(song: LocalFileHolder) { + if (!songs.contains(song)) { + songs.add(song) + } + } + + fun removeSong(song: LocalFileHolder) { + songs.remove(song) + } + + fun removeSongAt(index: Int) { + if (index in 0 until songs.size) { + songs.removeAt(index) + } + } + + fun getSongUris(): List { + return songs.map { Uri.fromFile(it.file) } + } + + fun isEmpty() = songs.isEmpty() + + fun size() = songs.size +} diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/model/PlaylistState.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/model/PlaylistState.kt new file mode 100644 index 00000000..0f30690a --- /dev/null +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/model/PlaylistState.kt @@ -0,0 +1,26 @@ +package com.raival.compose.file.explorer.screen.viewer.audio.model + +data class PlaylistState( + val currentPlaylist: Playlist? = null, + val currentSongIndex: Int = 0, + val isShuffled: Boolean = false, + val shuffledIndices: List = emptyList() +) { + fun getCurrentSong() = currentPlaylist?.songs?.getOrNull( + if (isShuffled && shuffledIndices.isNotEmpty()) { + shuffledIndices.getOrNull(currentSongIndex) ?: currentSongIndex + } else { + currentSongIndex + } + ) + + fun hasNextSong() = if (isShuffled && shuffledIndices.isNotEmpty()) { + currentSongIndex < shuffledIndices.size - 1 + } else { + currentPlaylist?.let { currentSongIndex < it.songs.size - 1 } ?: false + } + + fun hasPreviousSong() = currentSongIndex > 0 + + fun getTotalSongs() = currentPlaylist?.songs?.size ?: 0 +} diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/ui/AudioPlayerScreen.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/ui/AudioPlayerScreen.kt index cdbbb37b..aa717a45 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/ui/AudioPlayerScreen.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/ui/AudioPlayerScreen.kt @@ -28,8 +28,10 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.VolumeDown import androidx.compose.material.icons.automirrored.filled.VolumeUp @@ -42,6 +44,8 @@ import androidx.compose.material.icons.filled.Repeat import androidx.compose.material.icons.filled.SkipNext import androidx.compose.material.icons.filled.SkipPrevious import androidx.compose.material.icons.filled.Speed +import androidx.compose.material.icons.filled.Shuffle +import androidx.compose.material.icons.filled.ShuffleOn import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card @@ -90,6 +94,7 @@ import com.raival.compose.file.explorer.common.ui.Space import com.raival.compose.file.explorer.screen.viewer.audio.AudioPlayerInstance import com.raival.compose.file.explorer.screen.viewer.audio.model.AudioMetadata import com.raival.compose.file.explorer.screen.viewer.audio.model.AudioPlayerColorScheme +import com.raival.compose.file.explorer.screen.viewer.audio.model.Playlist import kotlin.math.abs @Composable @@ -103,6 +108,12 @@ fun MusicPlayerScreen( val isEqualizerVisible by audioPlayerInstance.isEqualizerVisible.collectAsState() val isVolumeVisible by audioPlayerInstance.isVolumeVisible.collectAsState() val customColorScheme by audioPlayerInstance.audioPlayerColorScheme.collectAsState() + val playlistState by audioPlayerInstance.playlistState.collectAsState() + + var showPlaylistDialog by remember { mutableStateOf(false) } + var showPlaylistDetailDialog by remember { mutableStateOf(false) } + var selectedPlaylist by remember { mutableStateOf(null) } + val defaultScheme = AudioPlayerColorScheme( primary = MaterialTheme.colorScheme.primary, secondary = MaterialTheme.colorScheme.secondary, @@ -151,6 +162,7 @@ fun MusicPlayerScreen( TopControls( onEqualizerClick = { audioPlayerInstance.toggleEqualizer() }, onVolumeClick = { audioPlayerInstance.toggleVolume() }, + onPlaylistClick = { showPlaylistDialog = true }, onCloseClick = onClosed, audioPlayerColorScheme = customColorScheme ) @@ -201,6 +213,22 @@ fun MusicPlayerScreen( onRepeatToggle = { audioPlayerInstance.toggleRepeatMode() }, colorScheme = customColorScheme ) + + // Current playlist info + playlistState.currentPlaylist?.let { playlist -> + Spacer(modifier = Modifier.height(16.dp)) + CurrentPlaylistInfo( + playlist = playlist, + currentSongIndex = playlistState.currentSongIndex, + isShuffled = playlistState.isShuffled, + onShuffleToggle = { audioPlayerInstance.toggleShuffle() }, + onPlaylistClick = { + selectedPlaylist = playlist + showPlaylistDetailDialog = true + }, + colorScheme = customColorScheme + ) + } } // Volume overlay @@ -229,6 +257,27 @@ fun MusicPlayerScreen( ) } } + + // Playlist dialogs + PlaylistBottomSheet( + isVisible = showPlaylistDialog, + onDismiss = { showPlaylistDialog = false }, + onPlaylistSelected = { playlist -> + selectedPlaylist = playlist + showPlaylistDialog = false + showPlaylistDetailDialog = true + } + ) + + selectedPlaylist?.let { playlist -> + PlaylistDetailBottomSheet( + isVisible = showPlaylistDetailDialog, + playlist = playlist, + onDismiss = { showPlaylistDetailDialog = false }, + onPlaySong = { index -> }, + audioPlayerInstance = audioPlayerInstance + ) + } } } @@ -236,6 +285,7 @@ fun MusicPlayerScreen( fun TopControls( onEqualizerClick: () -> Unit, onVolumeClick: () -> Unit, + onPlaylistClick: () -> Unit, onCloseClick: () -> Unit, audioPlayerColorScheme: AudioPlayerColorScheme, ) { @@ -255,6 +305,14 @@ fun TopControls( Spacer(Modifier.weight(1f)) Row { + IconButton(onClick = onPlaylistClick) { + Icon( + Icons.Default.MusicNote, + contentDescription = "Playlists", + tint = audioPlayerColorScheme.tintColor + ) + } + IconButton(onClick = onVolumeClick) { Icon( Icons.AutoMirrored.Filled.VolumeUp, @@ -827,4 +885,68 @@ fun extractColorsFromBitmap( } catch (_: Exception) { defaultScheme // Fallback to default colors } +} + +@Composable +fun CurrentPlaylistInfo( + playlist: Playlist, + currentSongIndex: Int, + isShuffled: Boolean, + onShuffleToggle: () -> Unit, + onPlaylistClick: () -> Unit, + colorScheme: AudioPlayerColorScheme +) { + Card( + modifier = Modifier + .fillMaxWidth() + .clickable { onPlaylistClick() }, + colors = CardDefaults.cardColors( + containerColor = colorScheme.surface.copy(alpha = 0.7f) + ), + shape = RoundedCornerShape(12.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.MusicNote, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = colorScheme.primary + ) + + Spacer(modifier = Modifier.width(12.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = playlist.name, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = colorScheme.tintColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = "${currentSongIndex + 1} de ${playlist.size()}${if (isShuffled) " • Aleatório" else ""}", + style = MaterialTheme.typography.bodySmall, + color = colorScheme.tintColor.copy(alpha = 0.7f) + ) + } + + IconButton( + onClick = onShuffleToggle, + modifier = Modifier.size(32.dp) + ) { + Icon( + if (isShuffled) Icons.Default.ShuffleOn else Icons.Default.Shuffle, + contentDescription = "Toggle Shuffle", + tint = if (isShuffled) colorScheme.primary else colorScheme.tintColor.copy(alpha = 0.7f), + modifier = Modifier.size(20.dp) + ) + } + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/ui/PlaylistBottomSheet.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/ui/PlaylistBottomSheet.kt new file mode 100644 index 00000000..b3f6da4f --- /dev/null +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/ui/PlaylistBottomSheet.kt @@ -0,0 +1,254 @@ +package com.raival.compose.file.explorer.screen.viewer.audio.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.MusicNote +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.raival.compose.file.explorer.R +import com.raival.compose.file.explorer.common.ui.BottomSheetDialog +import com.raival.compose.file.explorer.screen.main.tab.files.holder.LocalFileHolder +import com.raival.compose.file.explorer.screen.viewer.audio.PlaylistManager +import com.raival.compose.file.explorer.screen.viewer.audio.model.Playlist + +@Composable +fun PlaylistBottomSheet( + isVisible: Boolean, + onDismiss: () -> Unit, + onPlaylistSelected: (Playlist) -> Unit, + selectedSong: LocalFileHolder? = null +) { + if (isVisible) { + val playlistManager = remember { PlaylistManager.getInstance() } + val playlists by playlistManager.playlists.collectAsState() + var showCreateDialog by remember { mutableStateOf(false) } + + BottomSheetDialog(onDismissRequest = onDismiss) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.playlists), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold + ) + + IconButton(onClick = { showCreateDialog = true }) { + Icon(Icons.Default.Add, contentDescription = stringResource(R.string.create_playlist)) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + if (playlists.isEmpty()) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + Icons.Default.MusicNote, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.no_playlists_created), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text =stringResource(R.string.tap_to_create_first_playlist), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } else { + LazyColumn { + items(playlists) { playlist -> + PlaylistItem( + playlist = playlist, + onPlaylistClick = { + selectedSong?.let { song -> + playlistManager.addSongToPlaylist(playlist.id, song) + } + onPlaylistSelected(playlist) + }, + onDeleteClick = { + playlistManager.deletePlaylist(playlist.id) + } + ) + } + } + } + } + } + + // Create playlist dialog + if (showCreateDialog) { + CreatePlaylistDialog( + onDismiss = { showCreateDialog = false }, + onPlaylistCreated = { name -> + val newPlaylist = if (selectedSong != null) { + playlistManager.createPlaylistWithSong(name, selectedSong) + } else { + playlistManager.createPlaylist(name) + } + onPlaylistSelected(newPlaylist) + showCreateDialog = false + } + ) + } + } +} + +@Composable +fun PlaylistItem( + playlist: Playlist, + onPlaylistClick: () -> Unit, + onDeleteClick: () -> Unit +) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .clickable { onPlaylistClick() }, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.MusicNote, + contentDescription = null, + modifier = Modifier.size(40.dp), + tint = MaterialTheme.colorScheme.primary + ) + + Spacer(modifier = Modifier.width(16.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = playlist.name, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = "${playlist.size()} ${stringResource(R.string.song)}${if (playlist.size() != 1) "s" else ""}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + IconButton(onClick = onDeleteClick) { + Icon( + Icons.Default.Delete, + contentDescription = stringResource(R.string.delete_playlist), + tint = MaterialTheme.colorScheme.error + ) + } + } + } +} + +@Composable +fun CreatePlaylistDialog( + onDismiss: () -> Unit, + onPlaylistCreated: (String) -> Unit +) { + var playlistName by remember { mutableStateOf("") } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.create_playlist)) }, + text = { + Column { + Text(stringResource(R.string.playlist_name)) + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + value = playlistName, + onValueChange = { playlistName = it }, + label = { Text(stringResource(R.string.enter_playlist_name)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + } + }, + confirmButton = { + Button( + onClick = { + if (playlistName.isNotBlank()) { + onPlaylistCreated(playlistName.trim()) + } + }, + enabled = playlistName.isNotBlank() + ) { + Text(stringResource(R.string.create)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.cancel)) + } + } + ) +} diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/ui/PlaylistDetailBottomSheet.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/ui/PlaylistDetailBottomSheet.kt new file mode 100644 index 00000000..c6902eab --- /dev/null +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/ui/PlaylistDetailBottomSheet.kt @@ -0,0 +1,320 @@ +package com.raival.compose.file.explorer.screen.viewer.audio.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.MusicNote +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Shuffle +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +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.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.raival.compose.file.explorer.R +import com.raival.compose.file.explorer.common.ui.BottomSheetDialog +import com.raival.compose.file.explorer.screen.main.tab.files.holder.LocalFileHolder +import com.raival.compose.file.explorer.screen.viewer.audio.AudioPlayerInstance +import com.raival.compose.file.explorer.screen.viewer.audio.PlaylistManager +import com.raival.compose.file.explorer.screen.viewer.audio.model.Playlist + +@Composable +fun PlaylistDetailBottomSheet( + isVisible: Boolean, + playlist: Playlist, + onDismiss: () -> Unit, + onPlaySong: (Int) -> Unit, + audioPlayerInstance: AudioPlayerInstance +) { + if (isVisible) { + val playlistManager = remember { PlaylistManager.getInstance() } + val playlistState by audioPlayerInstance.playlistState.collectAsState() + val currentPlaylist = playlistState.currentPlaylist + val isCurrentPlaylist = currentPlaylist?.id == playlist.id + + BottomSheetDialog(onDismissRequest = onDismiss) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + // Header + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = playlist.name, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = "${playlist.size()} música${if (playlist.size() != 1) "s" else ""}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Row { + if (playlist.size() > 1) { + IconButton( + onClick = { + audioPlayerInstance.loadPlaylist(playlist, 0) + audioPlayerInstance.toggleShuffle() + audioPlayerInstance.playPause() + } + ) { + Icon( + Icons.Default.Shuffle, + contentDescription = stringResource(R.string.shuffle_mode), + tint = if (isCurrentPlaylist && playlistState.isShuffled) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + ) + } + } + + IconButton(onClick = onDismiss) { + Icon(Icons.Default.Close, contentDescription = "Close") + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Play all button + if (playlist.songs.isNotEmpty()) { + Card( + modifier = Modifier + .fillMaxWidth() + .clickable { + audioPlayerInstance.loadPlaylist(playlist, 0) + audioPlayerInstance.playPause() + onDismiss() + }, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary), + contentAlignment = Alignment.Center + ) { + Icon( + Icons.Default.PlayArrow, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimary + ) + } + + Spacer(modifier = Modifier.width(16.dp)) + + Text( + text = stringResource(R.string.play_all), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + } + + // Songs list + if (playlist.songs.isEmpty()) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + Icons.Default.MusicNote, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.empty_playlist), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + } + } + } else { + LazyColumn { + itemsIndexed(playlist.songs) { index, song -> + PlaylistSongItem( + song = song, + index = index, + isCurrentSong = isCurrentPlaylist && playlistState.currentSongIndex == index, + onSongClick = { + audioPlayerInstance.loadPlaylist(playlist, index) + audioPlayerInstance.playPause() + onDismiss() + }, + onRemoveClick = { + playlistManager.removeSongFromPlaylistAt(playlist.id, index) + } + ) + } + } + } + } + } + } +} + +@Composable +fun PlaylistSongItem( + song: LocalFileHolder, + index: Int, + isCurrentSong: Boolean, + onSongClick: () -> Unit, + onRemoveClick: () -> Unit +) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 2.dp) + .clickable { onSongClick() }, + colors = CardDefaults.cardColors( + containerColor = if (isCurrentSong) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surface + } + ), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Song number/play indicator + Box( + modifier = Modifier + .size(32.dp) + .clip(CircleShape) + .background( + if (isCurrentSong) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.surfaceVariant + } + ), + contentAlignment = Alignment.Center + ) { + if (isCurrentSong) { + Icon( + Icons.Default.PlayArrow, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.size(16.dp) + ) + } else { + Text( + text = "${index + 1}", + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + Spacer(modifier = Modifier.width(12.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = song.displayName, + style = MaterialTheme.typography.bodyMedium, + fontWeight = if (isCurrentSong) FontWeight.Medium else FontWeight.Normal, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = if (isCurrentSong) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurface + } + ) + + Text( + text = song.file.name ?: stringResource(R.string.unknown), + style = MaterialTheme.typography.bodySmall, + color = if (isCurrentSong) { + MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f) + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + IconButton(onClick = onRemoveClick) { + Icon( + Icons.Default.Delete, + contentDescription = stringResource(R.string.remove_from_playlist), + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(20.dp) + ) + } + } + } +} diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index d22ac76c..9d373d2a 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -402,4 +402,38 @@ Baixar nova atualização Usar visualizadores integrados Abrir automaticamente arquivos suportados com visualizadores integrados + + + Playlists + Criar playlist + Adicionar à playlist + Nenhuma playlist criada + Toque em + para criar sua primeira playlist + Nome da playlist + Digite o nome da playlist + Playlist criada com sucesso + Música adicionada à playlist + Música removida da playlist + Playlist atual + Modo aleatório + Tocando agora + %d músicas + Reproduzir tudo + Identificador + Playlist vazia + Esta playlist está vazia + Reproduzir + Remover da playlist + Remover música + Deseja remover esta música? + Remover + Nenhuma playlist disponível + Pausar + Excluir playlist + Deseja excluir esta playlist? + Voltar + Gerenciar playlists + Nenhuma playlist + Música + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1f8f3dfd..fc8c2108 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -402,4 +402,37 @@ Download new update Use built-in viewers Automatically open supported files with built-in viewers + + Playlists + Create playlist + Add to playlist + No playlists created + Tap + to create your first playlist + Playlist name + Enter playlist name + Playlist created successfully + Song added to playlist + Song removed from playlist + Current playlist + Shuffle mode + Now playing + %d songs + Play all + ID + Empty playlist + This playlist is empty + Play + Remove from playlist + Remove song + Do you want to remove this song? + Remove + No playlists available + Pause + Delete playlist + Do you want to delete this playlist? + Back + Manage playlists + No playlists + Song + \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f52a76b5..5e44d361 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -34,6 +34,7 @@ baselineprofile = "1.3.4" profileinstaller = "1.4.1" reorderable = "2.5.1" uiToolingPreviewAndroid = "1.8.3" +material3 = "1.3.2" [libraries] accompanist-systemuicontroller = { module = "com.google.accompanist:accompanist-systemuicontroller", version.ref = "accompanistSystemuicontroller" } @@ -78,6 +79,7 @@ androidx-benchmark-macro-junit4 = { group = "androidx.benchmark", name = "benchm androidx-profileinstaller = { group = "androidx.profileinstaller", name = "profileinstaller", version.ref = "profileinstaller" } reorderable = { module = "sh.calvin.reorderable:reorderable", version.ref = "reorderable" } androidx-ui-tooling-preview-android = { group = "androidx.compose.ui", name = "ui-tooling-preview-android", version.ref = "uiToolingPreviewAndroid" } +androidx-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } From 7b0ca6a3cf6dae18b88631e7baab474e51a9da93 Mon Sep 17 00:00:00 2001 From: Mraphaelpy Date: Sun, 17 Aug 2025 22:39:50 -0300 Subject: [PATCH 02/12] remove .idea --- .idea/.gitignore | 3 - .idea/.name | 1 - .idea/AndroidProjectSystem.xml | 6 - .idea/codeStyles/Project.xml | 117 ------------------- .idea/codeStyles/codeStyleConfig.xml | 5 - .idea/compiler.xml | 6 - .idea/deploymentTargetSelector.xml | 18 --- .idea/gradle.xml | 20 ---- .idea/inspectionProfiles/Project_Default.xml | 50 -------- .idea/migrations.xml | 10 -- .idea/misc.xml | 9 -- .idea/runConfigurations.xml | 17 --- .idea/vcs.xml | 6 - 13 files changed, 268 deletions(-) delete mode 100644 .idea/.gitignore delete mode 100644 .idea/.name delete mode 100644 .idea/AndroidProjectSystem.xml delete mode 100644 .idea/codeStyles/Project.xml delete mode 100644 .idea/codeStyles/codeStyleConfig.xml delete mode 100644 .idea/compiler.xml delete mode 100644 .idea/deploymentTargetSelector.xml delete mode 100644 .idea/gradle.xml delete mode 100644 .idea/inspectionProfiles/Project_Default.xml delete mode 100644 .idea/migrations.xml delete mode 100644 .idea/misc.xml delete mode 100644 .idea/runConfigurations.xml delete mode 100644 .idea/vcs.xml diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index 26d33521..00000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml diff --git a/.idea/.name b/.idea/.name deleted file mode 100644 index c42e2011..00000000 --- a/.idea/.name +++ /dev/null @@ -1 +0,0 @@ -File Explorer \ No newline at end of file diff --git a/.idea/AndroidProjectSystem.xml b/.idea/AndroidProjectSystem.xml deleted file mode 100644 index 4a53bee8..00000000 --- a/.idea/AndroidProjectSystem.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - \ No newline at end of file diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml deleted file mode 100644 index 4bec4ea8..00000000 --- a/.idea/codeStyles/Project.xml +++ /dev/null @@ -1,117 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml deleted file mode 100644 index a55e7a17..00000000 --- a/.idea/codeStyles/codeStyleConfig.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml deleted file mode 100644 index b86273d9..00000000 --- a/.idea/compiler.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml deleted file mode 100644 index a93e6966..00000000 --- a/.idea/deploymentTargetSelector.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml deleted file mode 100644 index cdf2fb4a..00000000 --- a/.idea/gradle.xml +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml deleted file mode 100644 index f0c6ad08..00000000 --- a/.idea/inspectionProfiles/Project_Default.xml +++ /dev/null @@ -1,50 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/migrations.xml b/.idea/migrations.xml deleted file mode 100644 index f8051a6f..00000000 --- a/.idea/migrations.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index b2c751a3..00000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml deleted file mode 100644 index 16660f1d..00000000 --- a/.idea/runConfigurations.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 35eb1ddf..00000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file From c763aacac3b1a15932b3a9139da08553fcc09e96 Mon Sep 17 00:00:00 2001 From: Mraphaelpy Date: Mon, 18 Aug 2025 20:26:52 -0300 Subject: [PATCH 03/12] feat: add persistence support - playlists can now be saved and loaded --- app/build.gradle.kts | 2 +- .../screen/viewer/audio/PlaylistManager.kt | 107 ++++++++++++++---- .../screen/viewer/audio/model/PlaylistData.kt | 8 ++ 3 files changed, 93 insertions(+), 24 deletions(-) create mode 100644 app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/model/PlaylistData.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b2c3fccd..59f242c5 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -49,7 +49,7 @@ android { } dependencies { - implementation(libs.androidx.material3) + implementation(libs.androidx.compose.material3) "baselineProfile"(project(":baselineprofile")) implementation(libs.androidx.profileinstaller) coreLibraryDesugaring(libs.desugar.jdk.libs) diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/PlaylistManager.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/PlaylistManager.kt index f5d0cb7b..b08a1090 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/PlaylistManager.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/PlaylistManager.kt @@ -1,21 +1,98 @@ package com.raival.compose.file.explorer.screen.viewer.audio +import android.content.SharedPreferences +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import com.raival.compose.file.explorer.App.Companion.globalClass import com.raival.compose.file.explorer.screen.main.tab.files.holder.LocalFileHolder import com.raival.compose.file.explorer.screen.viewer.audio.model.Playlist +import com.raival.compose.file.explorer.screen.viewer.audio.model.PlaylistData import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import java.io.File +import androidx.core.content.edit -class PlaylistManager { +class PlaylistManager private constructor() { private val _playlists = MutableStateFlow>(emptyList()) val playlists: StateFlow> = _playlists.asStateFlow() private val _currentPlaylist = MutableStateFlow(null) val currentPlaylist: StateFlow = _currentPlaylist.asStateFlow() + private val gson = Gson() + private val preferences: SharedPreferences + get() = globalClass.getSharedPreferences("app_preferences", 0) + + companion object { + @Volatile + private var INSTANCE: PlaylistManager? = null + private const val PREFS_KEY = "saved_playlists" + + fun getInstance(): PlaylistManager { + return INSTANCE ?: synchronized(this) { + val instance = PlaylistManager() + instance.loadPlaylists() + INSTANCE = instance + instance + } + } + } + + private fun savePlaylists() { + try { + val playlistsData = _playlists.value.map { playlist -> + PlaylistData( + id = playlist.id, + name = playlist.name, + songPaths = playlist.songs.map { it.file.absolutePath }, + createdAt = playlist.createdAt + ) + } + val json = gson.toJson(playlistsData) + + preferences.edit { putString(PREFS_KEY, json) } + } catch (e: Exception) { + e.printStackTrace() + } + } + + private fun loadPlaylists() { + try { + val json = preferences.getString(PREFS_KEY, "[]") ?: "[]" + + val type = object : TypeToken>() {}.type + val playlistsData: List = gson.fromJson(json, type) + + val loadedPlaylists = playlistsData.mapNotNull { data -> + try { + val validSongs = data.songPaths.mapNotNull { path -> + val file = File(path) + if (file.exists() && file.isFile) { + LocalFileHolder(file) + } else null + }.toMutableList() + + Playlist( + id = data.id, + name = data.name, + songs = validSongs, + createdAt = data.createdAt + ) + } catch (e: Exception) { + null + } + } + _playlists.value = loadedPlaylists + } catch (e: Exception) { + e.printStackTrace() + _playlists.value = emptyList() + } + } fun createPlaylist(name: String): Playlist { val playlist = Playlist(name = name) _playlists.value = _playlists.value + playlist + savePlaylists() return playlist } @@ -23,6 +100,7 @@ class PlaylistManager { val playlist = Playlist(name = name) playlist.addSong(song) _playlists.value = _playlists.value + playlist + savePlaylists() return playlist } @@ -34,11 +112,10 @@ class PlaylistManager { playlist } } - - // Update current playlist if it's the one being modified if (_currentPlaylist.value?.id == playlistId) { _currentPlaylist.value = _playlists.value.find { it.id == playlistId } } + savePlaylists() } fun removeSongFromPlaylistAt(playlistId: String, index: Int) { @@ -49,20 +126,18 @@ class PlaylistManager { playlist } } - - // Update current playlist if it's the one being modified if (_currentPlaylist.value?.id == playlistId) { _currentPlaylist.value = _playlists.value.find { it.id == playlistId } } + savePlaylists() } fun deletePlaylist(playlistId: String) { _playlists.value = _playlists.value.filter { it.id != playlistId } - - // Clear current playlist if it was deleted if (_currentPlaylist.value?.id == playlistId) { _currentPlaylist.value = null } + savePlaylists() } fun setCurrentPlaylist(playlist: Playlist) { @@ -90,23 +165,9 @@ class PlaylistManager { playlist } } - - // Update current playlist if it's the one being modified if (_currentPlaylist.value?.id == playlistId) { _currentPlaylist.value = _playlists.value.find { it.id == playlistId } } + savePlaylists() } - - companion object { - @Volatile - private var INSTANCE: PlaylistManager? = null - - fun getInstance(): PlaylistManager { - return INSTANCE ?: synchronized(this) { - val instance = INSTANCE ?: PlaylistManager() - INSTANCE = instance - instance - } - } - } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/model/PlaylistData.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/model/PlaylistData.kt new file mode 100644 index 00000000..8c66d516 --- /dev/null +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/model/PlaylistData.kt @@ -0,0 +1,8 @@ +package com.raival.compose.file.explorer.screen.viewer.audio.model + +data class PlaylistData( + val id: String, + val name: String, + val songPaths: List, + val createdAt: Long +) \ No newline at end of file From c84d2432b8064d703e24a1e44f45e4d9b61b5abc Mon Sep 17 00:00:00 2001 From: Mraphaelpy Date: Mon, 18 Aug 2025 23:54:53 -0300 Subject: [PATCH 04/12] fixed some strings, separated playlist screen into files, and removed comments --- .../playlist/ui/EmptyPlaylistContent.kt | 124 ++++ .../screen/playlist/ui/PlaylistDetailSheet.kt | 675 +++--------------- .../playlist/ui/PlaylistHeaderComponents.kt | 173 +++++ .../playlist/ui/PlaylistManagerScreen.kt | 26 +- .../explorer/screen/playlist/ui/SongItem.kt | 363 ++++++++++ .../viewer/audio/ui/AudioPlayerScreen.kt | 1 - .../audio/ui/PlaylistDetailBottomSheet.kt | 4 +- app/src/main/res/values/strings.xml | 2 +- 8 files changed, 759 insertions(+), 609 deletions(-) create mode 100644 app/src/main/java/com/raival/compose/file/explorer/screen/playlist/ui/EmptyPlaylistContent.kt create mode 100644 app/src/main/java/com/raival/compose/file/explorer/screen/playlist/ui/PlaylistHeaderComponents.kt create mode 100644 app/src/main/java/com/raival/compose/file/explorer/screen/playlist/ui/SongItem.kt diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/playlist/ui/EmptyPlaylistContent.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/playlist/ui/EmptyPlaylistContent.kt new file mode 100644 index 00000000..e09e9f04 --- /dev/null +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/playlist/ui/EmptyPlaylistContent.kt @@ -0,0 +1,124 @@ +package com.raival.compose.file.explorer.screen.playlist.ui + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.MusicNote +import androidx.compose.material.icons.filled.MusicOff +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +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.graphics.Brush +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.raival.compose.file.explorer.R +import kotlinx.coroutines.delay + +@OptIn(ExperimentalAnimationApi::class) +@Composable +fun EmptyPlaylistContent() { + Box( + modifier = Modifier + .fillMaxWidth() + .height(300.dp), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.padding(24.dp) + ) { + var showAnimation by remember { mutableStateOf(true) } + + LaunchedEffect(Unit) { + while (true) { + delay(3000) + showAnimation = !showAnimation + delay(200) + showAnimation = !showAnimation + } + } + + Box( + modifier = Modifier + .size(100.dp) + .clip(CircleShape) + .background( + Brush.radialGradient( + colors = listOf( + MaterialTheme.colorScheme.secondaryContainer, + MaterialTheme.colorScheme.secondary.copy(alpha = 0.3f) + ) + ) + ) + .border( + width = 2.dp, + color = MaterialTheme.colorScheme.secondary.copy(alpha = 0.5f), + shape = CircleShape + ), + contentAlignment = Alignment.Center + ) { + AnimatedContent( + targetState = showAnimation, + label = "EmptyAnimation", + transitionSpec = { + (fadeIn(tween(500)) + scaleIn(tween(500))) togetherWith + (fadeOut(tween(200)) + scaleOut(tween(200))) + } + ) { state -> + Icon( + imageVector = if (state) Icons.Default.MusicNote else Icons.Default.MusicOff, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.onSecondaryContainer + ) + } + } + + Text( + text = stringResource(R.string.empty_playlist), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + + Text( + text = stringResource(R.string.empty_playlist_description), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth(0.8f) + .alpha(0.8f) + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/playlist/ui/PlaylistDetailSheet.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/playlist/ui/PlaylistDetailSheet.kt index fe12c752..79a50af6 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/playlist/ui/PlaylistDetailSheet.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/playlist/ui/PlaylistDetailSheet.kt @@ -1,89 +1,43 @@ package com.raival.compose.file.explorer.screen.playlist.ui -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.AnimatedVisibility +import android.annotation.SuppressLint import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.animateDpAsState -import androidx.compose.animation.core.spring -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.scaleIn -import androidx.compose.animation.scaleOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically -import androidx.compose.animation.togetherWith import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box 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.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.PlaylistPlay -import androidx.compose.material.icons.filled.Album -import androidx.compose.material.icons.filled.Audiotrack import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material.icons.filled.MusicNote -import androidx.compose.material.icons.filled.MusicOff -import androidx.compose.material.icons.filled.Pause -import androidx.compose.material.icons.filled.PlayArrow -import androidx.compose.material.icons.filled.PlaylistRemove -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.ElevatedCard -import androidx.compose.material3.FilledTonalButton -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue 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.graphics.Brush -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontStyle -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.min import androidx.compose.ui.window.Dialog @@ -91,9 +45,8 @@ import androidx.compose.ui.window.DialogProperties import com.raival.compose.file.explorer.R import com.raival.compose.file.explorer.screen.viewer.audio.PlaylistManager import com.raival.compose.file.explorer.screen.viewer.audio.model.Playlist -import com.raival.compose.file.explorer.screen.main.tab.files.holder.LocalFileHolder -import kotlinx.coroutines.delay +@SuppressLint("ConfigurationScreenWidthHeight") @OptIn(ExperimentalFoundationApi::class, ExperimentalAnimationApi::class) @Composable fun PlaylistDetailSheet( @@ -101,26 +54,19 @@ fun PlaylistDetailSheet( onDismiss: () -> Unit, onPlayClick: (Int) -> Unit ) { - // Calculate optimal dialog size based on screen size val configuration = LocalConfiguration.current val density = LocalDensity.current - - // Use 80% of screen width and height, but limit to reasonable values val screenWidth = with(density) { configuration.screenWidthDp.dp } val screenHeight = with(density) { configuration.screenHeightDp.dp } - - val dialogWidth = min(screenWidth * 0.9f, 480.dp) - val dialogHeight = min(screenHeight * 0.8f, 650.dp) - + val dialogWidth = min(screenWidth * 0.92f, 480.dp) + val dialogHeight = min(screenHeight * 0.85f, 680.dp) val lazyListState = rememberLazyListState() - val isScrolled by remember { derivedStateOf { lazyListState.firstVisibleItemIndex > 0 || lazyListState.firstVisibleItemScrollOffset > 0 } } var currentlyPlayingIndex by remember { mutableIntStateOf(-1) } - // Simulating current playing track for UI purposes LaunchedEffect(playlist) { if (playlist.songs.isNotEmpty()) { currentlyPlayingIndex = -1 @@ -138,578 +84,119 @@ fun PlaylistDetailSheet( Surface( modifier = Modifier .width(dialogWidth) - .height(dialogHeight), + .height(dialogHeight) + .clip(RoundedCornerShape(28.dp)), shape = RoundedCornerShape(28.dp), color = MaterialTheme.colorScheme.surface, - shadowElevation = 6.dp + shadowElevation = 8.dp ) { - Box( - modifier = Modifier.fillMaxSize() - ) { - // Close button in the top right - IconButton( - onClick = onDismiss, - modifier = Modifier - .align(Alignment.TopEnd) - .padding(8.dp) - ) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = stringResource(R.string.close), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - - // Header background that shows when scrolled - AnimatedVisibility( - visible = isScrolled, - enter = fadeIn(tween(200)), - exit = fadeOut(tween(200)) - ) { - Surface( - color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.95f), - shadowElevation = 4.dp, - modifier = Modifier - .fillMaxWidth() - .height(56.dp) - ) {} - } - - Column( - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 16.dp) - .padding(top = 16.dp) - ) { - // Header with playlist info - PlaylistHeader( - playlist = playlist, - isScrolled = isScrolled, - onPlayAllClick = { onPlayClick(0) } - ) - - // Songs List - if (playlist.songs.isEmpty()) { - EmptyPlaylistContent() - } else { - LazyColumn( - state = lazyListState, - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(12.dp), - contentPadding = PaddingValues(vertical = 16.dp) - ) { - itemsIndexed( - items = playlist.songs, - key = { _, song -> song.uid } - ) { index, song -> - val isPlaying = index == currentlyPlayingIndex - - SongItem( - song = song, - index = index, - isPlaying = isPlaying, - modifier = Modifier - .animateItem( - placementSpec = spring( - dampingRatio = Spring.DampingRatioMediumBouncy, - stiffness = Spring.StiffnessLow - ) - ) - .fillMaxWidth(), - onPlayClick = { - onPlayClick(index) - currentlyPlayingIndex = if (isPlaying) -1 else index - }, - onRemoveClick = { - PlaylistManager.getInstance().removeSongFromPlaylistAt(playlist.id, index) - if (index == currentlyPlayingIndex) { - currentlyPlayingIndex = -1 - } else if (index < currentlyPlayingIndex) { - currentlyPlayingIndex-- - } - } - ) - } - } - } - - // Bottom spacing for better visual appearance - Spacer(modifier = Modifier.height(16.dp)) - } - } + PlaylistDetailContent( + playlist = playlist, + isScrolled = isScrolled, + currentlyPlayingIndex = currentlyPlayingIndex, + onPlayClick = { index -> + onPlayClick(index) + currentlyPlayingIndex = if (index == currentlyPlayingIndex) -1 else index + }, + onDismiss = onDismiss, + lazyListState = lazyListState + ) } } } -@OptIn(ExperimentalAnimationApi::class) +@OptIn(ExperimentalFoundationApi::class) @Composable -private fun PlaylistHeader( +private fun PlaylistDetailContent( playlist: Playlist, isScrolled: Boolean, - onPlayAllClick: () -> Unit + currentlyPlayingIndex: Int, + onPlayClick: (Int) -> Unit, + onDismiss: () -> Unit, + lazyListState: LazyListState ) { - val elevation by animateDpAsState( - targetValue = if (isScrolled) 8.dp else 0.dp, - animationSpec = tween(300), - label = "headerElevation" - ) + Box(modifier = Modifier.fillMaxSize()) { + PlaylistHeaderBackground(isScrolled = isScrolled) - Column( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = if (isScrolled) 0.dp else 16.dp) - ) { - Row( + Column( modifier = Modifier - .fillMaxWidth() - .padding(top = 8.dp, bottom = 16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + .fillMaxSize() + .padding(top = 8.dp) ) { - // Playlist info with icon - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier.weight(1f) + Box( + modifier = Modifier + .fillMaxWidth() + .padding(start = 20.dp, end = 12.dp) ) { - // Playlist icon with animation - Box( + IconButton( + onClick = onDismiss, modifier = Modifier - .size(if (isScrolled) 40.dp else 56.dp) - .clip(RoundedCornerShape(12.dp)) - .background( - Brush.linearGradient( - colors = listOf( - MaterialTheme.colorScheme.primary, - MaterialTheme.colorScheme.tertiary - ) - ) - ), - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.PlaylistPlay, - contentDescription = null, - tint = MaterialTheme.colorScheme.onPrimary, - modifier = Modifier.size(if (isScrolled) 24.dp else 32.dp) - ) - } - - // Playlist name and count - AnimatedContent( - targetState = isScrolled, - label = "HeaderTextAnimation", - transitionSpec = { - fadeIn(tween(300)) togetherWith fadeOut(tween(150)) - } - ) { scrolled -> - Column( - modifier = Modifier.weight(1f) - ) { - Text( - text = playlist.name, - style = if (scrolled) { - MaterialTheme.typography.titleMedium - } else { - MaterialTheme.typography.headlineSmall - }, - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - - Text( - text = stringResource( - R.string.songs_count, - playlist.songs.size - ), - style = if (scrolled) { - MaterialTheme.typography.bodySmall - } else { - MaterialTheme.typography.bodyMedium - }, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } - - // Play all button - if (playlist.songs.isNotEmpty()) { - FilledTonalButton( - onClick = onPlayAllClick, - shape = RoundedCornerShape(20.dp), - modifier = Modifier.height(40.dp) + .align(Alignment.CenterEnd) + .size(40.dp) ) { Icon( - imageVector = Icons.Default.PlayArrow, - contentDescription = null, - modifier = Modifier.size(20.dp) - ) - Spacer(modifier = Modifier.width(6.dp)) - Text( - text = stringResource(R.string.play_all), - style = MaterialTheme.typography.labelLarge + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.close), + tint = MaterialTheme.colorScheme.onSurfaceVariant ) } } - } - - // Visual divider that animates based on scroll - HorizontalDivider( - thickness = if (isScrolled) 1.dp else 0.5.dp, - color = if (isScrolled) { - MaterialTheme.colorScheme.outlineVariant - } else { - MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) - } - ) - } -} -@Composable -private fun EmptyPlaylistContent() { - Box( - modifier = Modifier - .fillMaxWidth() - .height(300.dp), - contentAlignment = Alignment.Center - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier.padding(24.dp) - ) { - // Empty state icon with animation - var showAnimation by remember { mutableStateOf(true) } - - LaunchedEffect(Unit) { - while (true) { - delay(3000) - showAnimation = !showAnimation - delay(200) - showAnimation = !showAnimation - } - } - - Box( + Column( modifier = Modifier - .size(100.dp) - .clip(CircleShape) - .background( - Brush.radialGradient( - colors = listOf( - MaterialTheme.colorScheme.secondaryContainer, - MaterialTheme.colorScheme.secondary.copy(alpha = 0.3f) - ) - ) - ) - .border( - width = 2.dp, - color = MaterialTheme.colorScheme.secondary.copy(alpha = 0.5f), - shape = CircleShape - ), - contentAlignment = Alignment.Center + .fillMaxSize() + .padding(horizontal = 20.dp) ) { - AnimatedContent( - targetState = showAnimation, - label = "EmptyAnimation", - transitionSpec = { - (fadeIn(tween(500)) + scaleIn(tween(500))) togetherWith - (fadeOut(tween(200)) + scaleOut(tween(200))) - } - ) { state -> - Icon( - imageVector = if (state) Icons.Default.MusicNote else Icons.Default.MusicOff, - contentDescription = null, - modifier = Modifier.size(48.dp), - tint = MaterialTheme.colorScheme.onSecondaryContainer - ) - } - } - - Text( - text = stringResource(R.string.empty_playlist), - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface - ) - - Text( - text = stringResource(R.string.empty_playlist_description), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - modifier = Modifier - .fillMaxWidth(0.8f) - .alpha(0.8f) - ) - } - } -} - -@OptIn(ExperimentalAnimationApi::class) -@Composable -private fun SongItem( - song: LocalFileHolder, - index: Int, - isPlaying: Boolean, - modifier: Modifier = Modifier, - onPlayClick: () -> Unit, - onRemoveClick: () -> Unit -) { - var showRemoveConfirmation by remember { mutableStateOf(false) } - var showDropdownMenu by remember { mutableStateOf(false) } - - val elevation by animateDpAsState( - targetValue = if (isPlaying) 6.dp else 1.dp, - animationSpec = spring( - dampingRatio = Spring.DampingRatioMediumBouncy, - stiffness = Spring.StiffnessLow - ), - label = "cardElevation" - ) - - ElevatedCard( - modifier = modifier, - shape = RoundedCornerShape(16.dp), - elevation = CardDefaults.elevatedCardElevation(defaultElevation = elevation), - colors = CardDefaults.elevatedCardColors( - containerColor = if (isPlaying) { - MaterialTheme.colorScheme.secondaryContainer - } else { - MaterialTheme.colorScheme.surface - } - ) - ) { - Box(modifier = Modifier.fillMaxWidth()) { - // Song progress indicator for currently playing song - if (isPlaying) { - LinearProgressIndicator( - progress = { 0.7f }, // Simulate progress for UI demonstration - modifier = Modifier - .fillMaxWidth() - .height(2.dp) - .align(Alignment.TopCenter), - color = MaterialTheme.colorScheme.primary, - trackColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f) + PlaylistHeader( + playlist = playlist, + isScrolled = isScrolled, + onPlayAllClick = { onPlayClick(0) } ) - } - - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { onPlayClick() } - .padding(vertical = 12.dp, horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - // Track number or play icon - AnimatedContent( - targetState = isPlaying, - label = "PlayingState", - transitionSpec = { - (fadeIn(tween(300)) + scaleIn(tween(300))) togetherWith - (fadeOut(tween(150)) + scaleOut(tween(150))) - } - ) { playing -> + if (playlist.songs.isEmpty()) { Box( modifier = Modifier - .size(40.dp) - .clip(RoundedCornerShape(12.dp)) - .background( - if (playing) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.surfaceVariant - } - ), + .weight(1f) + .fillMaxWidth(), contentAlignment = Alignment.Center ) { - if (playing) { - Icon( - imageVector = Icons.Default.Audiotrack, - contentDescription = null, - tint = MaterialTheme.colorScheme.onPrimary - ) - } else { - Text( - text = (index + 1).toString(), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - fontWeight = FontWeight.Bold - ) - } + EmptyPlaylistContent() } - } - - Spacer(modifier = Modifier.width(16.dp)) - - // Song info - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - Text( - text = song.displayName, - style = MaterialTheme.typography.titleMedium, - fontWeight = if (isPlaying) FontWeight.Bold else FontWeight.Medium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - color = if (isPlaying) { - MaterialTheme.colorScheme.onSecondaryContainer - } else { - MaterialTheme.colorScheme.onSurface - } - ) - - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp) - ) { - Icon( - imageVector = Icons.Default.Album, - contentDescription = null, - modifier = Modifier.size(14.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) - ) - - Text( - text = song.basePath, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - fontStyle = FontStyle.Italic + } else { + LazyColumn( + state = lazyListState, + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues( + top = 16.dp, + bottom = 24.dp ) - } - } - - // Action buttons - Box { - Row { - // Play/Pause button - AnimatedContent( - targetState = isPlaying, - label = "PlayButtonState", - transitionSpec = { - fadeIn(tween(300)) togetherWith fadeOut(tween(150)) - } - ) { playing -> - IconButton( - onClick = onPlayClick, + ) { + itemsIndexed( + items = playlist.songs, + key = { _, song -> song.uid } + ) { index, song -> + val isPlaying = index == currentlyPlayingIndex + + SongItem( + song = song, + index = index, + isPlaying = isPlaying, modifier = Modifier - .size(40.dp) - .clip(CircleShape) - .background( - if (playing) { - MaterialTheme.colorScheme.primary - } else { - Color.Transparent - } - ) - ) { - Icon( - imageVector = if (playing) { - Icons.Default.Pause - } else { - Icons.Default.PlayArrow - }, - contentDescription = stringResource( - if (playing) R.string.pause else R.string.play - ), - tint = if (playing) { - MaterialTheme.colorScheme.onPrimary - } else { - MaterialTheme.colorScheme.primary - } - ) - } - } - - // More options button - IconButton( - onClick = { showDropdownMenu = true } - ) { - Icon( - imageVector = Icons.Default.MoreVert, - contentDescription = stringResource(R.string.more), - tint = MaterialTheme.colorScheme.onSurfaceVariant + .animateItem() + .fillMaxWidth() + .padding(vertical = 1.dp), + onPlayClick = { onPlayClick(index) }, + onRemoveClick = { + PlaylistManager.getInstance().removeSongFromPlaylistAt(playlist.id, index) + } ) } } - - // Dropdown menu - DropdownMenu( - expanded = showDropdownMenu, - onDismissRequest = { showDropdownMenu = false } - ) { - DropdownMenuItem( - text = { - Text(stringResource(R.string.remove_from_playlist)) - }, - leadingIcon = { - Icon( - imageVector = Icons.Default.PlaylistRemove, - contentDescription = null, - tint = MaterialTheme.colorScheme.error - ) - }, - onClick = { - showDropdownMenu = false - showRemoveConfirmation = true - } - ) - } } + Spacer(modifier = Modifier.height(16.dp)) } } } - - // Remove confirmation dialog with fixed size and center positioning - if (showRemoveConfirmation) { - AlertDialog( - onDismissRequest = { showRemoveConfirmation = false }, - title = { - Text( - text = stringResource(R.string.remove_song), - fontWeight = FontWeight.Bold - ) - }, - text = { - Text( - text = stringResource(R.string.remove_song_confirmation, song.displayName), - style = MaterialTheme.typography.bodyLarge - ) - }, - confirmButton = { - TextButton( - onClick = { - onRemoveClick() - showRemoveConfirmation = false - } - ) { - Text( - text = stringResource(R.string.remove).uppercase(), - color = MaterialTheme.colorScheme.error, - fontWeight = FontWeight.Bold - ) - } - }, - dismissButton = { - TextButton(onClick = { showRemoveConfirmation = false }) { - Text( - text = stringResource(R.string.cancel).uppercase(), - fontWeight = FontWeight.Medium - ) - } - }, - shape = RoundedCornerShape(28.dp), - properties = DialogProperties( - dismissOnBackPress = true, - dismissOnClickOutside = true, - usePlatformDefaultWidth = false - ), - modifier = Modifier.fillMaxWidth(0.9f) - ) - } } \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/playlist/ui/PlaylistHeaderComponents.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/playlist/ui/PlaylistHeaderComponents.kt new file mode 100644 index 00000000..fdfbd463 --- /dev/null +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/playlist/ui/PlaylistHeaderComponents.kt @@ -0,0 +1,173 @@ +package com.raival.compose.file.explorer.screen.playlist.ui + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.PlaylistPlay +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.raival.compose.file.explorer.R +import com.raival.compose.file.explorer.screen.viewer.audio.model.Playlist + +@Composable +fun PlaylistHeaderBackground(isScrolled: Boolean) { + AnimatedVisibility( + visible = isScrolled, + enter = fadeIn(tween(200)), + exit = fadeOut(tween(200)) + ) { + Surface( + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.95f), + shadowElevation = 4.dp, + modifier = Modifier + .fillMaxWidth() + .height(56.dp) + ) {} + } +} + +@OptIn(ExperimentalAnimationApi::class) +@Composable +fun PlaylistHeader( + playlist: Playlist, + isScrolled: Boolean, + onPlayAllClick: () -> Unit +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = if (isScrolled) 0.dp else 16.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp, bottom = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.weight(1f) + ) { + Box( + modifier = Modifier + .size(if (isScrolled) 40.dp else 56.dp) + .clip(RoundedCornerShape(12.dp)) + .background( + Brush.linearGradient( + colors = listOf( + MaterialTheme.colorScheme.primary, + MaterialTheme.colorScheme.tertiary + ) + ) + ), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.PlaylistPlay, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.size(if (isScrolled) 24.dp else 32.dp) + ) + } + + AnimatedContent( + targetState = isScrolled, + label = "HeaderTextAnimation", + transitionSpec = { + fadeIn(tween(300)) togetherWith fadeOut(tween(150)) + } + ) { scrolled -> + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = playlist.name, + style = if (scrolled) { + MaterialTheme.typography.titleMedium + } else { + MaterialTheme.typography.headlineSmall + }, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + Text( + text = stringResource( + R.string.songs_count, + playlist.songs.size + ), + style = if (scrolled) { + MaterialTheme.typography.bodySmall + } else { + MaterialTheme.typography.bodyMedium + }, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + + if (playlist.songs.isNotEmpty()) { + FilledTonalButton( + onClick = onPlayAllClick, + shape = RoundedCornerShape(20.dp), + modifier = Modifier.height(40.dp) + ) { + Icon( + imageVector = Icons.Default.PlayArrow, + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = stringResource(R.string.play_all), + style = MaterialTheme.typography.labelLarge + ) + } + } + } + + HorizontalDivider( + thickness = if (isScrolled) 1.dp else 0.5.dp, + color = if (isScrolled) { + MaterialTheme.colorScheme.outlineVariant + } else { + MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) + } + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/playlist/ui/PlaylistManagerScreen.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/playlist/ui/PlaylistManagerScreen.kt index 382edfab..8938a712 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/playlist/ui/PlaylistManagerScreen.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/playlist/ui/PlaylistManagerScreen.kt @@ -13,6 +13,7 @@ import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.animation.togetherWith import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -198,7 +199,6 @@ fun PlaylistManagerScreen( } } - // Create Playlist Dialog if (showCreateDialog) { CreatePlaylistDialog( onDismiss = { showCreateDialog = false }, @@ -209,7 +209,6 @@ fun PlaylistManagerScreen( ) } - // Playlist Detail Sheet showPlaylistDetail?.let { playlist -> PlaylistDetailSheet( playlist = playlist, @@ -309,17 +308,24 @@ private fun PlaylistCard( Card( onClick = onClick, - modifier = modifier, + modifier = modifier + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.6f), + shape = RoundedCornerShape(16.dp) + ) + .padding(1.dp), shape = RoundedCornerShape(16.dp), colors = CardDefaults.cardColors( - containerColor = cardColor + containerColor = cardColor, + contentColor = MaterialTheme.colorScheme.onSurface ), elevation = CardDefaults.cardElevation( - defaultElevation = 0.dp, - pressedElevation = 0.dp, - focusedElevation = 0.dp, - hoveredElevation = 0.dp, - draggedElevation = 0.dp, + defaultElevation = 4.dp, + pressedElevation = 2.dp, + focusedElevation = 6.dp, + hoveredElevation = 8.dp, + draggedElevation = 12.dp, disabledElevation = 0.dp ) ) { @@ -397,7 +403,6 @@ private fun PlaylistCard( Row( horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - // Play button Surface( onClick = onPlayClick, enabled = playlist.songs.isNotEmpty(), @@ -431,7 +436,6 @@ private fun PlaylistCard( } } - // Delete button Surface( onClick = { showDeleteConfirmation = true }, shape = CircleShape, diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/playlist/ui/SongItem.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/playlist/ui/SongItem.kt new file mode 100644 index 00000000..93c82059 --- /dev/null +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/playlist/ui/SongItem.kt @@ -0,0 +1,363 @@ +package com.raival.compose.file.explorer.screen.playlist.ui + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Album +import androidx.compose.material.icons.filled.Audiotrack +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.PlaylistRemove +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.DialogProperties +import com.raival.compose.file.explorer.R +import com.raival.compose.file.explorer.screen.main.tab.files.holder.LocalFileHolder +@OptIn(ExperimentalAnimationApi::class) +@Composable +fun SongItem( + song: LocalFileHolder, + index: Int, + isPlaying: Boolean, + modifier: Modifier = Modifier, + onPlayClick: () -> Unit, + onRemoveClick: () -> Unit +) { + var showRemoveConfirmation by remember { mutableStateOf(false) } + var showDropdownMenu by remember { mutableStateOf(false) } + + val elevation by animateDpAsState( + targetValue = if (isPlaying) 6.dp else 1.dp, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessLow + ), + label = "cardElevation" + ) + + ElevatedCard( + modifier = modifier, + shape = RoundedCornerShape(16.dp), + elevation = CardDefaults.elevatedCardElevation(defaultElevation = elevation), + colors = CardDefaults.elevatedCardColors( + containerColor = if (isPlaying) { + MaterialTheme.colorScheme.secondaryContainer + } else { + MaterialTheme.colorScheme.surface + } + ) + ) { + Box(modifier = Modifier.fillMaxWidth()) { + // Song progress indicator for currently playing song + if (isPlaying) { + LinearProgressIndicator( + progress = { 0.7f }, // Simulate progress for UI demonstration + modifier = Modifier + .fillMaxWidth() + .height(2.dp) + .align(Alignment.TopCenter), + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f) + ) + } + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onPlayClick() } + .padding(vertical = 12.dp, horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Track number or play icon + SongNumberIndicator(index = index, isPlaying = isPlaying) + + Spacer(modifier = Modifier.width(16.dp)) + + // Song info + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = song.displayName, + style = MaterialTheme.typography.titleMedium, + fontWeight = if (isPlaying) FontWeight.Bold else FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = if (isPlaying) { + MaterialTheme.colorScheme.onSecondaryContainer + } else { + MaterialTheme.colorScheme.onSurface + } + ) + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + Icon( + imageVector = Icons.Default.Album, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + ) + + Text( + text = song.basePath, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + fontStyle = FontStyle.Italic + ) + } + } + + // Action buttons + SongItemActions( + isPlaying = isPlaying, + onPlayClick = onPlayClick, + onMenuClick = { showDropdownMenu = true }, + showMenu = showDropdownMenu, + onDismissMenu = { showDropdownMenu = false }, + onRemoveClick = { showRemoveConfirmation = true } + ) + } + } + } + + // Remove confirmation dialog + if (showRemoveConfirmation) { + RemoveConfirmationDialog( + songName = song.displayName, + onConfirm = { + onRemoveClick() + showRemoveConfirmation = false + }, + onDismiss = { showRemoveConfirmation = false } + ) + } +} + +@OptIn(ExperimentalAnimationApi::class) +@Composable +private fun SongNumberIndicator(index: Int, isPlaying: Boolean) { + AnimatedContent( + targetState = isPlaying, + label = "PlayingState", + transitionSpec = { + (fadeIn(tween(300)) + scaleIn(tween(300))) togetherWith + (fadeOut(tween(150)) + scaleOut(tween(150))) + } + ) { playing -> + Box( + modifier = Modifier + .size(40.dp) + .clip(RoundedCornerShape(12.dp)) + .background( + if (playing) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.surfaceVariant + } + ), + contentAlignment = Alignment.Center + ) { + if (playing) { + Icon( + imageVector = Icons.Default.Audiotrack, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimary + ) + } else { + Text( + text = (index + 1).toString(), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = FontWeight.Bold + ) + } + } + } +} + +@OptIn(ExperimentalAnimationApi::class) +@Composable +private fun SongItemActions( + isPlaying: Boolean, + onPlayClick: () -> Unit, + onMenuClick: () -> Unit, + showMenu: Boolean, + onDismissMenu: () -> Unit, + onRemoveClick: () -> Unit +) { + Box { + Row { + // Play/Pause button + AnimatedContent( + targetState = isPlaying, + label = "PlayButtonState", + transitionSpec = { + fadeIn(tween(300)) togetherWith fadeOut(tween(150)) + } + ) { playing -> + IconButton( + onClick = onPlayClick, + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .background( + if (playing) { + MaterialTheme.colorScheme.primary + } else { + Color.Transparent + } + ) + ) { + Icon( + imageVector = if (playing) { + Icons.Default.Pause + } else { + Icons.Default.PlayArrow + }, + contentDescription = stringResource( + if (playing) R.string.pause else R.string.play + ), + tint = if (playing) { + MaterialTheme.colorScheme.onPrimary + } else { + MaterialTheme.colorScheme.primary + } + ) + } + } + + // More options button + IconButton(onClick = onMenuClick) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = stringResource(R.string.more), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + // Dropdown menu + DropdownMenu( + expanded = showMenu, + onDismissRequest = onDismissMenu + ) { + DropdownMenuItem( + text = { + Text(stringResource(R.string.remove_from_playlist)) + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.PlaylistRemove, + contentDescription = null, + tint = MaterialTheme.colorScheme.error + ) + }, + onClick = { + onDismissMenu() + onRemoveClick() + } + ) + } + } +} + +@Composable +fun RemoveConfirmationDialog( + songName: String, + onConfirm: () -> Unit, + onDismiss: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + text = stringResource(R.string.remove_song), + fontWeight = FontWeight.Bold + ) + }, + text = { + Text( + text = stringResource(R.string.remove_song_confirmation, songName), + style = MaterialTheme.typography.bodyLarge + ) + }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text( + text = stringResource(R.string.remove).uppercase(), + color = MaterialTheme.colorScheme.error, + fontWeight = FontWeight.Bold + ) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text( + text = stringResource(R.string.cancel).uppercase(), + fontWeight = FontWeight.Medium + ) + } + }, + shape = RoundedCornerShape(28.dp), + properties = DialogProperties( + dismissOnBackPress = true, + dismissOnClickOutside = true, + usePlatformDefaultWidth = false + ), + modifier = Modifier.fillMaxWidth(0.9f) + ) +} + diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/ui/AudioPlayerScreen.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/ui/AudioPlayerScreen.kt index aa717a45..c60aa81c 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/ui/AudioPlayerScreen.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/ui/AudioPlayerScreen.kt @@ -109,7 +109,6 @@ fun MusicPlayerScreen( val isVolumeVisible by audioPlayerInstance.isVolumeVisible.collectAsState() val customColorScheme by audioPlayerInstance.audioPlayerColorScheme.collectAsState() val playlistState by audioPlayerInstance.playlistState.collectAsState() - var showPlaylistDialog by remember { mutableStateOf(false) } var showPlaylistDetailDialog by remember { mutableStateOf(false) } var selectedPlaylist by remember { mutableStateOf(null) } diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/ui/PlaylistDetailBottomSheet.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/ui/PlaylistDetailBottomSheet.kt index c6902eab..f1ebed49 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/ui/PlaylistDetailBottomSheet.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/ui/PlaylistDetailBottomSheet.kt @@ -81,7 +81,7 @@ fun PlaylistDetailBottomSheet( overflow = TextOverflow.Ellipsis ) Text( - text = "${playlist.size()} música${if (playlist.size() != 1) "s" else ""}", + text = "${playlist.size()} ${stringResource(R.string.song)}${if (playlist.size() != 1) "s" else ""}", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -109,7 +109,7 @@ fun PlaylistDetailBottomSheet( } IconButton(onClick = onDismiss) { - Icon(Icons.Default.Close, contentDescription = "Close") + Icon(Icons.Default.Close, contentDescription = stringResource(R.string.close)) } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fa5a51d3..fd145359 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -416,7 +416,7 @@ Current playlist Shuffle mode Now playing - %d songs + %d Songs Play all ID Empty playlist From 4e4b22bdd7f52a56cd0eed94c14ed6fe09ccb43f Mon Sep 17 00:00:00 2001 From: Mraphaelpy Date: Mon, 18 Aug 2025 23:58:27 -0300 Subject: [PATCH 05/12] removed comments --- .../file/explorer/screen/playlist/ui/SongItem.kt | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/playlist/ui/SongItem.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/playlist/ui/SongItem.kt index 93c82059..91494d47 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/playlist/ui/SongItem.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/playlist/ui/SongItem.kt @@ -95,10 +95,9 @@ fun SongItem( ) ) { Box(modifier = Modifier.fillMaxWidth()) { - // Song progress indicator for currently playing song if (isPlaying) { LinearProgressIndicator( - progress = { 0.7f }, // Simulate progress for UI demonstration + progress = { 0.7f }, modifier = Modifier .fillMaxWidth() .height(2.dp) @@ -115,12 +114,9 @@ fun SongItem( .padding(vertical = 12.dp, horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically ) { - // Track number or play icon SongNumberIndicator(index = index, isPlaying = isPlaying) Spacer(modifier = Modifier.width(16.dp)) - - // Song info Column( modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp) @@ -159,8 +155,6 @@ fun SongItem( ) } } - - // Action buttons SongItemActions( isPlaying = isPlaying, onPlayClick = onPlayClick, @@ -173,7 +167,6 @@ fun SongItem( } } - // Remove confirmation dialog if (showRemoveConfirmation) { RemoveConfirmationDialog( songName = song.displayName, @@ -240,7 +233,6 @@ private fun SongItemActions( ) { Box { Row { - // Play/Pause button AnimatedContent( targetState = isPlaying, label = "PlayButtonState", @@ -279,7 +271,6 @@ private fun SongItemActions( } } - // More options button IconButton(onClick = onMenuClick) { Icon( imageVector = Icons.Default.MoreVert, @@ -289,7 +280,6 @@ private fun SongItemActions( } } - // Dropdown menu DropdownMenu( expanded = showMenu, onDismissRequest = onDismissMenu From 874832e8588e922edf1b93254c0f5d70c9bf5129 Mon Sep 17 00:00:00 2001 From: Mraphaelpy Date: Wed, 20 Aug 2025 00:29:10 -0300 Subject: [PATCH 06/12] Refactor: split into smaller composables for clarity and reduced complexity. --- .../playlist/ui/PlaylistHeaderComponents.kt | 204 ++++---- .../explorer/screen/playlist/ui/SongItem.kt | 316 ++++++------ .../viewer/audio/AudioPlayerActivity.kt | 3 - .../viewer/audio/AudioPlayerInstance.kt | 20 +- .../viewer/audio/model/PlaylistState.kt | 1 + .../viewer/audio/ui/AudioPlayerScreen.kt | 2 +- .../viewer/audio/ui/PlaylistBottomSheet.kt | 1 - .../audio/ui/PlaylistDetailBottomSheet.kt | 448 ++++++++++-------- 8 files changed, 552 insertions(+), 443 deletions(-) diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/playlist/ui/PlaylistHeaderComponents.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/playlist/ui/PlaylistHeaderComponents.kt index fdfbd463..e50c650c 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/playlist/ui/PlaylistHeaderComponents.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/playlist/ui/PlaylistHeaderComponents.kt @@ -69,98 +69,7 @@ fun PlaylistHeader( .fillMaxWidth() .padding(bottom = if (isScrolled) 0.dp else 16.dp) ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(top = 8.dp, bottom = 16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier.weight(1f) - ) { - Box( - modifier = Modifier - .size(if (isScrolled) 40.dp else 56.dp) - .clip(RoundedCornerShape(12.dp)) - .background( - Brush.linearGradient( - colors = listOf( - MaterialTheme.colorScheme.primary, - MaterialTheme.colorScheme.tertiary - ) - ) - ), - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.PlaylistPlay, - contentDescription = null, - tint = MaterialTheme.colorScheme.onPrimary, - modifier = Modifier.size(if (isScrolled) 24.dp else 32.dp) - ) - } - - AnimatedContent( - targetState = isScrolled, - label = "HeaderTextAnimation", - transitionSpec = { - fadeIn(tween(300)) togetherWith fadeOut(tween(150)) - } - ) { scrolled -> - Column( - modifier = Modifier.weight(1f) - ) { - Text( - text = playlist.name, - style = if (scrolled) { - MaterialTheme.typography.titleMedium - } else { - MaterialTheme.typography.headlineSmall - }, - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - - Text( - text = stringResource( - R.string.songs_count, - playlist.songs.size - ), - style = if (scrolled) { - MaterialTheme.typography.bodySmall - } else { - MaterialTheme.typography.bodyMedium - }, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } - - if (playlist.songs.isNotEmpty()) { - FilledTonalButton( - onClick = onPlayAllClick, - shape = RoundedCornerShape(20.dp), - modifier = Modifier.height(40.dp) - ) { - Icon( - imageVector = Icons.Default.PlayArrow, - contentDescription = null, - modifier = Modifier.size(20.dp) - ) - Spacer(modifier = Modifier.width(6.dp)) - Text( - text = stringResource(R.string.play_all), - style = MaterialTheme.typography.labelLarge - ) - } - } - } - + HeaderRow(playlist, isScrolled, onPlayAllClick) HorizontalDivider( thickness = if (isScrolled) 1.dp else 0.5.dp, color = if (isScrolled) { @@ -170,4 +79,115 @@ fun PlaylistHeader( } ) } +} + +@Composable +fun HeaderRow( + playlist: Playlist, + isScrolled: Boolean, + onPlayAllClick: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp, bottom = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + PlaylistInfo(playlist, isScrolled) + + if (playlist.songs.isNotEmpty()) { + PlayAllButton(onPlayAllClick) + } + } +} + +@Composable +fun PlaylistInfo(playlist: Playlist, isScrolled: Boolean, modifier: Modifier = Modifier) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = modifier + ) { + PlaylistIcon(isScrolled) + + AnimatedContent( + targetState = isScrolled, + label = "HeaderTextAnimation", + transitionSpec = { + fadeIn(tween(300)) togetherWith fadeOut(tween(150)) + } + ) { scrolled -> + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = playlist.name, + style = if (scrolled) { + MaterialTheme.typography.titleMedium + } else { + MaterialTheme.typography.headlineSmall + }, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + Text( + text = stringResource(R.string.songs_count, playlist.songs.size), + style = if (scrolled) { + MaterialTheme.typography.bodySmall + } else { + MaterialTheme.typography.bodyMedium + }, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } +} + +@Composable +fun PlaylistIcon(isScrolled: Boolean) { + Box( + modifier = Modifier + .size(if (isScrolled) 40.dp else 56.dp) + .clip(RoundedCornerShape(12.dp)) + .background( + Brush.linearGradient( + colors = listOf( + MaterialTheme.colorScheme.primary, + MaterialTheme.colorScheme.tertiary + ) + ) + ), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.PlaylistPlay, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.size(if (isScrolled) 24.dp else 32.dp) + ) + } +} + +@Composable +fun PlayAllButton(onPlayAllClick: () -> Unit) { + FilledTonalButton( + onClick = onPlayAllClick, + shape = RoundedCornerShape(20.dp), + modifier = Modifier.height(40.dp) + ) { + Icon( + imageVector = Icons.Default.PlayArrow, + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = stringResource(R.string.play_all), + style = MaterialTheme.typography.labelLarge + ) + } } \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/playlist/ui/SongItem.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/playlist/ui/SongItem.kt index 91494d47..dc523a89 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/playlist/ui/SongItem.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/playlist/ui/SongItem.kt @@ -41,6 +41,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ProgressIndicatorDefaults import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -73,6 +74,7 @@ fun SongItem( var showRemoveConfirmation by remember { mutableStateOf(false) } var showDropdownMenu by remember { mutableStateOf(false) } + // A animação da elevação do card val elevation by animateDpAsState( targetValue = if (isPlaying) 6.dp else 1.dp, animationSpec = spring( @@ -94,79 +96,19 @@ fun SongItem( } ) ) { - Box(modifier = Modifier.fillMaxWidth()) { - if (isPlaying) { - LinearProgressIndicator( - progress = { 0.7f }, - modifier = Modifier - .fillMaxWidth() - .height(2.dp) - .align(Alignment.TopCenter), - color = MaterialTheme.colorScheme.primary, - trackColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f) - ) - } - - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { onPlayClick() } - .padding(vertical = 12.dp, horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - SongNumberIndicator(index = index, isPlaying = isPlaying) - - Spacer(modifier = Modifier.width(16.dp)) - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - Text( - text = song.displayName, - style = MaterialTheme.typography.titleMedium, - fontWeight = if (isPlaying) FontWeight.Bold else FontWeight.Medium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - color = if (isPlaying) { - MaterialTheme.colorScheme.onSecondaryContainer - } else { - MaterialTheme.colorScheme.onSurface - } - ) - - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp) - ) { - Icon( - imageVector = Icons.Default.Album, - contentDescription = null, - modifier = Modifier.size(14.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) - ) - - Text( - text = song.basePath, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - fontStyle = FontStyle.Italic - ) - } - } - SongItemActions( - isPlaying = isPlaying, - onPlayClick = onPlayClick, - onMenuClick = { showDropdownMenu = true }, - showMenu = showDropdownMenu, - onDismissMenu = { showDropdownMenu = false }, - onRemoveClick = { showRemoveConfirmation = true } - ) - } - } + SongContent( + song = song, + index = index, + isPlaying = isPlaying, + onPlayClick = onPlayClick, + onShowDropdownMenu = { showDropdownMenu = true }, + showDropdownMenu = showDropdownMenu, + onDismissMenu = { showDropdownMenu = false }, + onRemoveClick = { showRemoveConfirmation = true } + ) } + // Diálogo de confirmação para remoção if (showRemoveConfirmation) { RemoveConfirmationDialog( songName = song.displayName, @@ -179,6 +121,94 @@ fun SongItem( } } +@Composable +private fun SongContent( + song: LocalFileHolder, + index: Int, + isPlaying: Boolean, + onPlayClick: () -> Unit, + onShowDropdownMenu: () -> Unit, + showDropdownMenu: Boolean, + onDismissMenu: () -> Unit, + onRemoveClick: () -> Unit +) { + Box(modifier = Modifier.fillMaxWidth()) { + if (isPlaying) { + LinearProgressIndicator( + progress = { 0.7f }, + modifier = Modifier + .fillMaxWidth() + .height(2.dp) + .align(Alignment.TopCenter), + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f), + strokeCap = ProgressIndicatorDefaults.LinearStrokeCap, + ) + } + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onPlayClick() } + .padding(vertical = 12.dp, horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + SongNumberIndicator(index = index, isPlaying = isPlaying) + + Spacer(modifier = Modifier.width(16.dp)) + + SongDetails(song) + + SongItemActions( + isPlaying = isPlaying, + onPlayClick = onPlayClick, + onMenuClick = onShowDropdownMenu, + showMenu = showDropdownMenu, + onDismissMenu = onDismissMenu, + onRemoveClick = onRemoveClick + ) + } + } +} + +@Composable +private fun SongDetails(song: LocalFileHolder, modifier: Modifier = Modifier) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = song.displayName, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colorScheme.onSurface + ) + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + Icon( + imageVector = Icons.Default.Album, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + ) + + Text( + text = song.basePath, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + fontStyle = FontStyle.Italic + ) + } + } +} + @OptIn(ExperimentalAnimationApi::class) @Composable private fun SongNumberIndicator(index: Int, isPlaying: Boolean) { @@ -232,78 +262,98 @@ private fun SongItemActions( onRemoveClick: () -> Unit ) { Box { - Row { - AnimatedContent( - targetState = isPlaying, - label = "PlayButtonState", - transitionSpec = { - fadeIn(tween(300)) togetherWith fadeOut(tween(150)) - } - ) { playing -> - IconButton( - onClick = onPlayClick, - modifier = Modifier - .size(40.dp) - .clip(CircleShape) - .background( - if (playing) { - MaterialTheme.colorScheme.primary - } else { - Color.Transparent - } - ) - ) { - Icon( - imageVector = if (playing) { - Icons.Default.Pause - } else { - Icons.Default.PlayArrow - }, - contentDescription = stringResource( - if (playing) R.string.pause else R.string.play - ), - tint = if (playing) { - MaterialTheme.colorScheme.onPrimary - } else { - MaterialTheme.colorScheme.primary - } - ) - } - } - - IconButton(onClick = onMenuClick) { - Icon( - imageVector = Icons.Default.MoreVert, - contentDescription = stringResource(R.string.more), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } + ActionButtons(isPlaying, onPlayClick, onMenuClick) + // Dropdown Menu DropdownMenu( expanded = showMenu, onDismissRequest = onDismissMenu ) { - DropdownMenuItem( - text = { - Text(stringResource(R.string.remove_from_playlist)) - }, - leadingIcon = { - Icon( - imageVector = Icons.Default.PlaylistRemove, - contentDescription = null, - tint = MaterialTheme.colorScheme.error - ) - }, - onClick = { - onDismissMenu() - onRemoveClick() - } + RemoveFromPlaylistMenuItem(onDismissMenu, onRemoveClick) + } + } +} + +@Composable +private fun ActionButtons( + isPlaying: Boolean, + onPlayClick: () -> Unit, + onMenuClick: () -> Unit +) { + Row { + AnimatedContent( + targetState = isPlaying, + label = "PlayButtonState", + transitionSpec = { + fadeIn(tween(300)) togetherWith fadeOut(tween(150)) + } + ) { playing -> + PlayPauseButton(playing, onPlayClick) + } + + IconButton(onClick = onMenuClick) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = stringResource(R.string.more), + tint = MaterialTheme.colorScheme.onSurfaceVariant ) } } } +@Composable +private fun PlayPauseButton(isPlaying: Boolean, onPlayClick: () -> Unit) { + IconButton( + onClick = onPlayClick, + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .background( + if (isPlaying) { + MaterialTheme.colorScheme.primary + } else { + Color.Transparent + } + ) + ) { + Icon( + imageVector = if (isPlaying) { + Icons.Default.Pause + } else { + Icons.Default.PlayArrow + }, + contentDescription = stringResource( + if (isPlaying) R.string.pause else R.string.play + ), + tint = if (isPlaying) { + MaterialTheme.colorScheme.onPrimary + } else { + MaterialTheme.colorScheme.primary + } + ) + } +} + +@Composable +private fun RemoveFromPlaylistMenuItem(onDismissMenu: () -> Unit, onRemoveClick: () -> Unit) { + DropdownMenuItem( + text = { + Text(stringResource(R.string.remove_from_playlist)) + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.PlaylistRemove, + contentDescription = null, + tint = MaterialTheme.colorScheme.error + ) + }, + onClick = { + onDismissMenu() + onRemoveClick() + } + ) +} + @Composable fun RemoveConfirmationDialog( songName: String, diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/AudioPlayerActivity.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/AudioPlayerActivity.kt index 7205a7dc..3e277cf0 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/AudioPlayerActivity.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/AudioPlayerActivity.kt @@ -16,17 +16,14 @@ class AudioPlayerActivity : ViewerActivity() { } override fun onReady(instance: ViewerInstance) { - // Verifica se veio de uma playlist val fromPlaylist = intent.getBooleanExtra("fromPlaylist", false) val startIndex = intent.getIntExtra("startIndex", 0) if (fromPlaylist) { - // Inicializa com playlist val playlistManager = PlaylistManager.getInstance() playlistManager.currentPlaylist.value?.let { playlist -> if (playlist.songs.isNotEmpty() && startIndex < playlist.songs.size) { val audioInstance = instance as AudioPlayerInstance - // Usar loadPlaylist em vez de initializePlaylistMode audioInstance.loadPlaylist(playlist, startIndex) } } diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/AudioPlayerInstance.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/AudioPlayerInstance.kt index 9915e5b6..336fc51c 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/AudioPlayerInstance.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/AudioPlayerInstance.kt @@ -5,6 +5,7 @@ import android.graphics.BitmapFactory import android.media.MediaMetadataRetriever import android.net.Uri import androidx.annotation.OptIn +import androidx.compose.ui.res.stringResource import androidx.media3.common.C.TIME_UNSET import androidx.media3.common.MediaItem import androidx.media3.common.Player @@ -55,6 +56,7 @@ class AudioPlayerInstance( private val _playlistState = MutableStateFlow(PlaylistState()) val playlistState: StateFlow = _playlistState.asStateFlow() + private val playlistManager = PlaylistManager.getInstance() private var exoPlayer: ExoPlayer? = null @@ -190,7 +192,7 @@ class AudioPlayerInstance( withContext(Dispatchers.IO) { try { val retriever = MediaMetadataRetriever() - + // Try to use file path first if available, then URI if (fileHolder != null) { retriever.setDataSource(fileHolder.file.absolutePath) @@ -199,16 +201,16 @@ class AudioPlayerInstance( } val title = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_TITLE) - ?: (fileHolder?.displayName?.substringBeforeLast('.') + ?: (fileHolder?.displayName?.substringBeforeLast('.') ?: uri.lastPathSegment?.substringBeforeLast('.') ?: globalClass.getString(R.string.unknown_title)) - + val artist = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_ARTIST) ?: globalClass.getString(R.string.unknown_artist) - + val album = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_ALBUM) ?: globalClass.getString(R.string.unknown_album) - + val durationStr = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION) val duration = durationStr?.toLongOrNull() ?: 0L @@ -238,9 +240,9 @@ class AudioPlayerInstance( } catch (e: Exception) { logger.logError(e) // Fallback metadata using file name from LocalFileHolder or URI - val fileName = fileHolder?.displayName ?: uri.lastPathSegment ?: "Unknown" //TODO: change to string resource + val fileName = fileHolder?.displayName ?: uri.lastPathSegment ?: "Unknown" val title = fileName.substringBeforeLast('.').ifEmpty { fileName } - + _metadata.value = AudioMetadata( title = title, artist = globalClass.getString(R.string.unknown_artist), @@ -396,7 +398,7 @@ class AudioPlayerInstance( fun skipToNext() { val currentState = _playlistState.value - currentState.currentPlaylist?.let { playlist -> + currentState.currentPlaylist?.let { _ -> if (currentState.hasNextSong()) { // Stop current playback first stopCurrentPlayer() @@ -417,7 +419,7 @@ class AudioPlayerInstance( fun skipToPrevious() { val currentState = _playlistState.value - currentState.currentPlaylist?.let { playlist -> + currentState.currentPlaylist?.let { _ -> if (currentState.hasPreviousSong()) { // Stop current playback first stopCurrentPlayer() diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/model/PlaylistState.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/model/PlaylistState.kt index 0f30690a..3a89e936 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/model/PlaylistState.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/model/PlaylistState.kt @@ -4,6 +4,7 @@ data class PlaylistState( val currentPlaylist: Playlist? = null, val currentSongIndex: Int = 0, val isShuffled: Boolean = false, + val teste: Boolean = true, val shuffledIndices: List = emptyList() ) { fun getCurrentSong() = currentPlaylist?.songs?.getOrNull( diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/ui/AudioPlayerScreen.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/ui/AudioPlayerScreen.kt index c60aa81c..c4ab9e4b 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/ui/AudioPlayerScreen.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/ui/AudioPlayerScreen.kt @@ -273,7 +273,7 @@ fun MusicPlayerScreen( isVisible = showPlaylistDetailDialog, playlist = playlist, onDismiss = { showPlaylistDetailDialog = false }, - onPlaySong = { index -> }, + onPlaySong = { _ -> }, audioPlayerInstance = audioPlayerInstance ) } diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/ui/PlaylistBottomSheet.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/ui/PlaylistBottomSheet.kt index b3f6da4f..f2ca8f3c 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/ui/PlaylistBottomSheet.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/ui/PlaylistBottomSheet.kt @@ -135,7 +135,6 @@ fun PlaylistBottomSheet( } } - // Create playlist dialog if (showCreateDialog) { CreatePlaylistDialog( onDismiss = { showCreateDialog = false }, diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/ui/PlaylistDetailBottomSheet.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/ui/PlaylistDetailBottomSheet.kt index f1ebed49..681e83e0 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/ui/PlaylistDetailBottomSheet.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/ui/PlaylistDetailBottomSheet.kt @@ -45,6 +45,7 @@ import com.raival.compose.file.explorer.screen.main.tab.files.holder.LocalFileHo import com.raival.compose.file.explorer.screen.viewer.audio.AudioPlayerInstance import com.raival.compose.file.explorer.screen.viewer.audio.PlaylistManager import com.raival.compose.file.explorer.screen.viewer.audio.model.Playlist +import com.raival.compose.file.explorer.screen.viewer.audio.model.PlaylistState @Composable fun PlaylistDetailBottomSheet( @@ -59,166 +60,194 @@ fun PlaylistDetailBottomSheet( val playlistState by audioPlayerInstance.playlistState.collectAsState() val currentPlaylist = playlistState.currentPlaylist val isCurrentPlaylist = currentPlaylist?.id == playlist.id - BottomSheetDialog(onDismissRequest = onDismiss) { Column( modifier = Modifier .fillMaxWidth() .padding(16.dp) ) { - // Header - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = playlist.name, - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Text( - text = "${playlist.size()} ${stringResource(R.string.song)}${if (playlist.size() != 1) "s" else ""}", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - - Row { - if (playlist.size() > 1) { - IconButton( - onClick = { - audioPlayerInstance.loadPlaylist(playlist, 0) - audioPlayerInstance.toggleShuffle() - audioPlayerInstance.playPause() - } - ) { - Icon( - Icons.Default.Shuffle, - contentDescription = stringResource(R.string.shuffle_mode), - tint = if (isCurrentPlaylist && playlistState.isShuffled) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - } - ) - } - } - - IconButton(onClick = onDismiss) { - Icon(Icons.Default.Close, contentDescription = stringResource(R.string.close)) - } - } - } - + PlaylistHeader(playlist, isCurrentPlaylist, audioPlayerInstance, playlistState, onDismiss) Spacer(modifier = Modifier.height(16.dp)) - // Play all button if (playlist.songs.isNotEmpty()) { - Card( - modifier = Modifier - .fillMaxWidth() - .clickable { - audioPlayerInstance.loadPlaylist(playlist, 0) - audioPlayerInstance.playPause() - onDismiss() - }, - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.primaryContainer - ) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Box( - modifier = Modifier - .size(40.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.primary), - contentAlignment = Alignment.Center - ) { - Icon( - Icons.Default.PlayArrow, - contentDescription = null, - tint = MaterialTheme.colorScheme.onPrimary - ) - } - - Spacer(modifier = Modifier.width(16.dp)) - - Text( - text = stringResource(R.string.play_all), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onPrimaryContainer - ) - } - } - + PlayAllButton(playlist, audioPlayerInstance, onDismiss) Spacer(modifier = Modifier.height(16.dp)) } - // Songs list if (playlist.songs.isEmpty()) { - Card( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 8.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant - ) - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Icon( - Icons.Default.MusicNote, - contentDescription = null, - modifier = Modifier.size(48.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = stringResource(R.string.empty_playlist), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center - ) - } - } + EmptyPlaylistCard() } else { - LazyColumn { - itemsIndexed(playlist.songs) { index, song -> - PlaylistSongItem( - song = song, - index = index, - isCurrentSong = isCurrentPlaylist && playlistState.currentSongIndex == index, - onSongClick = { - audioPlayerInstance.loadPlaylist(playlist, index) - audioPlayerInstance.playPause() - onDismiss() - }, - onRemoveClick = { - playlistManager.removeSongFromPlaylistAt(playlist.id, index) - } - ) - } - } + PlaylistSongsList(playlist, playlistManager, onPlaySong, isCurrentPlaylist, playlistState) } } } } } +@Composable +fun PlaylistHeader( + playlist: Playlist, + isCurrentPlaylist: Boolean, + audioPlayerInstance: AudioPlayerInstance, + playlistState: PlaylistState, + onDismiss: () -> Unit +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = playlist.name, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = "${playlist.size()} ${stringResource(R.string.song)}${if (playlist.size() != 1) "s" else ""}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Row { + if (playlist.size() > 1) { + ShuffleButton(playlist, audioPlayerInstance, isCurrentPlaylist, playlistState) + } + IconButton(onClick = onDismiss) { + Icon(Icons.Default.Close, contentDescription = stringResource(R.string.close)) + } + } + } +} + +@Composable +fun ShuffleButton(playlist: Playlist, audioPlayerInstance: AudioPlayerInstance, isCurrentPlaylist: Boolean, playlistState: PlaylistState) { + IconButton( + onClick = { + audioPlayerInstance.loadPlaylist(playlist, 0) + audioPlayerInstance.toggleShuffle() + audioPlayerInstance.playPause() + } + ) { + Icon( + Icons.Default.Shuffle, + contentDescription = stringResource(R.string.shuffle_mode), + tint = if (isCurrentPlaylist && playlistState.isShuffled) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + ) + } +} + +@Composable +fun PlayAllButton(playlist: Playlist, audioPlayerInstance: AudioPlayerInstance, onDismiss: () -> Unit) { + Card( + modifier = Modifier + .fillMaxWidth() + .clickable { + audioPlayerInstance.loadPlaylist(playlist, 0) + audioPlayerInstance.playPause() + onDismiss() + }, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary), + contentAlignment = Alignment.Center + ) { + Icon( + Icons.Default.PlayArrow, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimary + ) + } + + Spacer(modifier = Modifier.width(16.dp)) + + Text( + text = stringResource(R.string.play_all), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + } +} + +@Composable +fun EmptyPlaylistCard() { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + Icons.Default.MusicNote, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.empty_playlist), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + } + } +} + +@Composable +fun PlaylistSongsList( + playlist: Playlist, + playlistManager: PlaylistManager, + onPlaySong: (Int) -> Unit, + isCurrentPlaylist: Boolean, + playlistState: PlaylistState +) { + LazyColumn { + itemsIndexed(playlist.songs) { index, song -> + PlaylistSongItem( + song = song, + index = index, + isCurrentSong = isCurrentPlaylist && playlistState.currentSongIndex == index, + onSongClick = { + onPlaySong(index) + }, + onRemoveClick = { + playlistManager.removeSongFromPlaylistAt(playlist.id, index) + } + ) + } + } +} + @Composable fun PlaylistSongItem( song: LocalFileHolder, @@ -247,74 +276,85 @@ fun PlaylistSongItem( .padding(12.dp), verticalAlignment = Alignment.CenterVertically ) { - // Song number/play indicator - Box( - modifier = Modifier - .size(32.dp) - .clip(CircleShape) - .background( - if (isCurrentSong) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.surfaceVariant - } - ), - contentAlignment = Alignment.Center - ) { - if (isCurrentSong) { - Icon( - Icons.Default.PlayArrow, - contentDescription = null, - tint = MaterialTheme.colorScheme.onPrimary, - modifier = Modifier.size(16.dp) - ) - } else { - Text( - text = "${index + 1}", - style = MaterialTheme.typography.bodySmall, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } + SongIndicator(isCurrentSong = isCurrentSong, index = index) Spacer(modifier = Modifier.width(12.dp)) - Column(modifier = Modifier.weight(1f)) { - Text( - text = song.displayName, - style = MaterialTheme.typography.bodyMedium, - fontWeight = if (isCurrentSong) FontWeight.Medium else FontWeight.Normal, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - color = if (isCurrentSong) { - MaterialTheme.colorScheme.onPrimaryContainer - } else { - MaterialTheme.colorScheme.onSurface - } - ) - - Text( - text = song.file.name ?: stringResource(R.string.unknown), - style = MaterialTheme.typography.bodySmall, - color = if (isCurrentSong) { - MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f) - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } + SongDetails(song = song, isCurrentSong = isCurrentSong) - IconButton(onClick = onRemoveClick) { - Icon( - Icons.Default.Delete, - contentDescription = stringResource(R.string.remove_from_playlist), - tint = MaterialTheme.colorScheme.error, - modifier = Modifier.size(20.dp) - ) - } + RemoveButton(onRemoveClick) } } } + +@Composable +fun SongIndicator(isCurrentSong: Boolean, index: Int) { + Box( + modifier = Modifier + .size(32.dp) + .clip(CircleShape) + .background( + if (isCurrentSong) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.surfaceVariant + ), + contentAlignment = Alignment.Center + ) { + if (isCurrentSong) { + Icon( + Icons.Default.PlayArrow, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.size(16.dp) + ) + } else { + Text( + text = "${index + 1}", + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +@Composable +fun SongDetails(song: LocalFileHolder, isCurrentSong: Boolean, modifier: Modifier = Modifier ) { + Column(modifier = modifier) { + Text( + text = song.displayName, + style = MaterialTheme.typography.bodyMedium, + fontWeight = if (isCurrentSong) FontWeight.Medium else FontWeight.Normal, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = if (isCurrentSong) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurface + } + ) + + Text( + text = song.file.name ?: stringResource(R.string.unknown), + style = MaterialTheme.typography.bodySmall, + color = if (isCurrentSong) { + MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f) + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } +} + +@Composable +fun RemoveButton(onRemoveClick: () -> Unit) { + IconButton(onClick = onRemoveClick) { + Icon( + Icons.Default.Delete, + contentDescription = stringResource(R.string.remove_from_playlist), + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(20.dp) + ) + } +} From 309c9018f9e25ce6a3029cb72f38c11b03392d5a Mon Sep 17 00:00:00 2001 From: Mraphaelpy Date: Wed, 20 Aug 2025 00:58:22 -0300 Subject: [PATCH 07/12] feat(playlist): add rename and improve play all button --- .../playlist/ui/PlaylistHeaderComponents.kt | 10 +- .../playlist/ui/PlaylistManagerScreen.kt | 193 +++++++++++++----- 2 files changed, 153 insertions(+), 50 deletions(-) diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/playlist/ui/PlaylistHeaderComponents.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/playlist/ui/PlaylistHeaderComponents.kt index e50c650c..71739a70 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/playlist/ui/PlaylistHeaderComponents.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/playlist/ui/PlaylistHeaderComponents.kt @@ -94,7 +94,11 @@ fun HeaderRow( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - PlaylistInfo(playlist, isScrolled) + PlaylistInfo( + playlist = playlist, + isScrolled = isScrolled, + modifier = Modifier.weight(1f) + ) if (playlist.songs.isNotEmpty()) { PlayAllButton(onPlayAllClick) @@ -118,9 +122,7 @@ fun PlaylistInfo(playlist: Playlist, isScrolled: Boolean, modifier: Modifier = M fadeIn(tween(300)) togetherWith fadeOut(tween(150)) } ) { scrolled -> - Column( - modifier = Modifier.weight(1f) - ) { + Column { Text( text = playlist.name, style = if (scrolled) { diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/playlist/ui/PlaylistManagerScreen.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/playlist/ui/PlaylistManagerScreen.kt index 8938a712..f5ea9301 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/playlist/ui/PlaylistManagerScreen.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/playlist/ui/PlaylistManagerScreen.kt @@ -26,6 +26,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState @@ -36,12 +37,16 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.QueueMusic import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.GraphicEq +import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Pause import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material3.AlertDialog import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.FilledTonalButton @@ -89,6 +94,7 @@ fun PlaylistManagerScreen( var showCreateDialog by remember { mutableStateOf(false) } var showPlaylistDetail by remember { mutableStateOf(null) } + var showRenameDialog by remember { mutableStateOf(null) } val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() val lazyListState = rememberLazyListState() @@ -191,6 +197,9 @@ fun PlaylistManagerScreen( .fillMaxWidth(), onClick = { showPlaylistDetail = playlist }, onPlayClick = { onPlayPlaylist(playlist, 0) }, + onRenameClick = { + showRenameDialog = playlist + }, onDeleteClick = { playlistManager.deletePlaylist(playlist.id) } ) } @@ -209,6 +218,19 @@ fun PlaylistManagerScreen( ) } + showRenameDialog?.let { playlist -> + PlaylistRenameDialog( + currentName = playlist.name, + onRename = { newName -> + playlistManager.updatePlaylistName(playlist.id, newName) + showRenameDialog = null + }, + onDismiss = { + showRenameDialog = null + } + ) + } + showPlaylistDetail?.let { playlist -> PlaylistDetailSheet( playlist = playlist, @@ -296,6 +318,7 @@ private fun PlaylistCard( modifier: Modifier = Modifier, onClick: () -> Unit, onPlayClick: () -> Unit, + onRenameClick: () -> Unit, onDeleteClick: () -> Unit ) { var showDeleteConfirmation by remember { mutableStateOf(false) } @@ -400,56 +423,73 @@ private fun PlaylistCard( ) } - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Surface( - onClick = onPlayClick, - enabled = playlist.songs.isNotEmpty(), - shape = CircleShape, - color = if (isCurrentlyPlaying) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.secondaryContainer - }, - contentColor = if (isCurrentlyPlaying) { - MaterialTheme.colorScheme.onPrimary - } else { - MaterialTheme.colorScheme.onSecondaryContainer - }, - modifier = Modifier.size(40.dp) + var showMenu by remember { mutableStateOf(false) } + + Box(modifier = Modifier.wrapContentSize(Alignment.TopStart)) { + IconButton( + onClick = { showMenu = true } ) { - Box(contentAlignment = Alignment.Center) { - Icon( - imageVector = if (isCurrentlyPlaying) { - Icons.Default.Pause - } else { - Icons.Default.PlayArrow - }, - contentDescription = if (isCurrentlyPlaying) { - stringResource(R.string.pause) - } else { - stringResource(R.string.play) - }, - modifier = Modifier.size(24.dp) - ) - } + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = stringResource(R.string.more), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) } - Surface( - onClick = { showDeleteConfirmation = true }, - shape = CircleShape, - color = Color.Transparent, - contentColor = MaterialTheme.colorScheme.error, - modifier = Modifier.size(40.dp) + DropdownMenu( + expanded = showMenu, + onDismissRequest = { showMenu = false } ) { - Box(contentAlignment = Alignment.Center) { - Icon( - imageVector = Icons.Default.Delete, - contentDescription = stringResource(R.string.delete), - modifier = Modifier.size(24.dp) - ) - } + DropdownMenuItem( + onClick = { + onPlayClick() + showMenu = false + }, + text = { Text(stringResource(R.string.play_all)) }, + leadingIcon = { + Icon( + if (isCurrentlyPlaying) { + Icons.Default.Pause + } else { + Icons.Default.PlayArrow + }, + contentDescription = stringResource(R.string.play_all) + ) + } + ) + DropdownMenuItem( + onClick = { + onRenameClick() + showMenu = false + }, + text = { Text(stringResource(R.string.rename)) }, + leadingIcon = { + Icon( + Icons.Default.Edit, + contentDescription = stringResource(R.string.rename) + ) + } + ) + + DropdownMenuItem( + onClick = { + showDeleteConfirmation = true + showMenu = false + }, + text = { + Text( + "Excluir", + color = MaterialTheme.colorScheme.error + ) + }, + leadingIcon = { + Icon( + Icons.Default.Delete, + contentDescription = "Excluir", + tint = MaterialTheme.colorScheme.error + ) + } + ) } } } @@ -557,4 +597,65 @@ private fun CreatePlaylistDialog( containerColor = MaterialTheme.colorScheme.surface, shape = RoundedCornerShape(28.dp) ) +} + +@Composable +private fun PlaylistRenameDialog( + currentName: String, + onRename: (String) -> Unit, + onDismiss: () -> Unit +) { + var playlistName by remember { mutableStateOf(currentName) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + text = "Renomear Playlist", + fontWeight = FontWeight.Bold + ) + }, + text = { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = "Digite o novo nome da playlist", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + OutlinedTextField( + value = playlistName, + onValueChange = { playlistName = it }, + label = { Text("Nome da playlist") }, + singleLine = true, + shape = RoundedCornerShape(12.dp), + modifier = Modifier.fillMaxWidth() + ) + } + }, + confirmButton = { + TextButton( + onClick = { onRename(playlistName.trim()) }, + enabled = playlistName.trim().isNotEmpty() && playlistName.trim() != currentName + ) { + Text( + text = "RENOMEAR", + fontWeight = FontWeight.Bold + ) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text( + text = "CANCELAR", + fontWeight = FontWeight.Medium + ) + } + }, + containerColor = MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape(28.dp) + ) } \ No newline at end of file From 52def0e401e56f1c949915cab21c5da867ac42e5 Mon Sep 17 00:00:00 2001 From: Mraphaelpy Date: Wed, 20 Aug 2025 08:48:11 -0300 Subject: [PATCH 08/12] feat(prefs): add auto-play option --- .../playlist/ui/PlaylistHeaderComponents.kt | 10 +- .../playlist/ui/PlaylistManagerScreen.kt | 218 +++++------------- .../screen/preferences/PreferencesActivity.kt | 2 + .../screen/preferences/PreferencesManager.kt | 7 + .../viewer/audio/AudioPlayerActivity.kt | 11 +- .../viewer/audio/AudioPlayerInstance.kt | 30 ++- app/src/main/res/values-pt-rBR/strings.xml | 2 + app/src/main/res/values/strings.xml | 2 + 8 files changed, 110 insertions(+), 172 deletions(-) diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/playlist/ui/PlaylistHeaderComponents.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/playlist/ui/PlaylistHeaderComponents.kt index 71739a70..e50c650c 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/playlist/ui/PlaylistHeaderComponents.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/playlist/ui/PlaylistHeaderComponents.kt @@ -94,11 +94,7 @@ fun HeaderRow( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - PlaylistInfo( - playlist = playlist, - isScrolled = isScrolled, - modifier = Modifier.weight(1f) - ) + PlaylistInfo(playlist, isScrolled) if (playlist.songs.isNotEmpty()) { PlayAllButton(onPlayAllClick) @@ -122,7 +118,9 @@ fun PlaylistInfo(playlist: Playlist, isScrolled: Boolean, modifier: Modifier = M fadeIn(tween(300)) togetherWith fadeOut(tween(150)) } ) { scrolled -> - Column { + Column( + modifier = Modifier.weight(1f) + ) { Text( text = playlist.name, style = if (scrolled) { diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/playlist/ui/PlaylistManagerScreen.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/playlist/ui/PlaylistManagerScreen.kt index f5ea9301..e7e284f9 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/playlist/ui/PlaylistManagerScreen.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/playlist/ui/PlaylistManagerScreen.kt @@ -13,7 +13,6 @@ import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.animation.togetherWith import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -26,7 +25,6 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState @@ -39,14 +37,11 @@ import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.GraphicEq -import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Pause import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material3.AlertDialog import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.FilledTonalButton @@ -94,7 +89,6 @@ fun PlaylistManagerScreen( var showCreateDialog by remember { mutableStateOf(false) } var showPlaylistDetail by remember { mutableStateOf(null) } - var showRenameDialog by remember { mutableStateOf(null) } val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() val lazyListState = rememberLazyListState() @@ -197,9 +191,6 @@ fun PlaylistManagerScreen( .fillMaxWidth(), onClick = { showPlaylistDetail = playlist }, onPlayClick = { onPlayPlaylist(playlist, 0) }, - onRenameClick = { - showRenameDialog = playlist - }, onDeleteClick = { playlistManager.deletePlaylist(playlist.id) } ) } @@ -208,6 +199,7 @@ fun PlaylistManagerScreen( } } + // Create Playlist Dialog if (showCreateDialog) { CreatePlaylistDialog( onDismiss = { showCreateDialog = false }, @@ -218,19 +210,7 @@ fun PlaylistManagerScreen( ) } - showRenameDialog?.let { playlist -> - PlaylistRenameDialog( - currentName = playlist.name, - onRename = { newName -> - playlistManager.updatePlaylistName(playlist.id, newName) - showRenameDialog = null - }, - onDismiss = { - showRenameDialog = null - } - ) - } - + // Playlist Detail Sheet showPlaylistDetail?.let { playlist -> PlaylistDetailSheet( playlist = playlist, @@ -318,7 +298,6 @@ private fun PlaylistCard( modifier: Modifier = Modifier, onClick: () -> Unit, onPlayClick: () -> Unit, - onRenameClick: () -> Unit, onDeleteClick: () -> Unit ) { var showDeleteConfirmation by remember { mutableStateOf(false) } @@ -331,24 +310,17 @@ private fun PlaylistCard( Card( onClick = onClick, - modifier = modifier - .border( - width = 1.dp, - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.6f), - shape = RoundedCornerShape(16.dp) - ) - .padding(1.dp), + modifier = modifier, shape = RoundedCornerShape(16.dp), colors = CardDefaults.cardColors( - containerColor = cardColor, - contentColor = MaterialTheme.colorScheme.onSurface + containerColor = cardColor ), elevation = CardDefaults.cardElevation( - defaultElevation = 4.dp, - pressedElevation = 2.dp, - focusedElevation = 6.dp, - hoveredElevation = 8.dp, - draggedElevation = 12.dp, + defaultElevation = 0.dp, + pressedElevation = 0.dp, + focusedElevation = 0.dp, + hoveredElevation = 0.dp, + draggedElevation = 0.dp, disabledElevation = 0.dp ) ) { @@ -423,73 +395,58 @@ private fun PlaylistCard( ) } - var showMenu by remember { mutableStateOf(false) } - - Box(modifier = Modifier.wrapContentSize(Alignment.TopStart)) { - IconButton( - onClick = { showMenu = true } + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + // Play button + Surface( + onClick = onPlayClick, + enabled = playlist.songs.isNotEmpty(), + shape = CircleShape, + color = if (isCurrentlyPlaying) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.secondaryContainer + }, + contentColor = if (isCurrentlyPlaying) { + MaterialTheme.colorScheme.onPrimary + } else { + MaterialTheme.colorScheme.onSecondaryContainer + }, + modifier = Modifier.size(40.dp) ) { - Icon( - imageVector = Icons.Default.MoreVert, - contentDescription = stringResource(R.string.more), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = if (isCurrentlyPlaying) { + Icons.Default.Pause + } else { + Icons.Default.PlayArrow + }, + contentDescription = if (isCurrentlyPlaying) { + stringResource(R.string.pause) + } else { + stringResource(R.string.play) + }, + modifier = Modifier.size(24.dp) + ) + } } - DropdownMenu( - expanded = showMenu, - onDismissRequest = { showMenu = false } + // Delete button + Surface( + onClick = { showDeleteConfirmation = true }, + shape = CircleShape, + color = Color.Transparent, + contentColor = MaterialTheme.colorScheme.error, + modifier = Modifier.size(40.dp) ) { - DropdownMenuItem( - onClick = { - onPlayClick() - showMenu = false - }, - text = { Text(stringResource(R.string.play_all)) }, - leadingIcon = { - Icon( - if (isCurrentlyPlaying) { - Icons.Default.Pause - } else { - Icons.Default.PlayArrow - }, - contentDescription = stringResource(R.string.play_all) - ) - } - ) - DropdownMenuItem( - onClick = { - onRenameClick() - showMenu = false - }, - text = { Text(stringResource(R.string.rename)) }, - leadingIcon = { - Icon( - Icons.Default.Edit, - contentDescription = stringResource(R.string.rename) - ) - } - ) - - DropdownMenuItem( - onClick = { - showDeleteConfirmation = true - showMenu = false - }, - text = { - Text( - "Excluir", - color = MaterialTheme.colorScheme.error - ) - }, - leadingIcon = { - Icon( - Icons.Default.Delete, - contentDescription = "Excluir", - tint = MaterialTheme.colorScheme.error - ) - } - ) + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = stringResource(R.string.delete), + modifier = Modifier.size(24.dp) + ) + } } } } @@ -597,65 +554,4 @@ private fun CreatePlaylistDialog( containerColor = MaterialTheme.colorScheme.surface, shape = RoundedCornerShape(28.dp) ) -} - -@Composable -private fun PlaylistRenameDialog( - currentName: String, - onRename: (String) -> Unit, - onDismiss: () -> Unit -) { - var playlistName by remember { mutableStateOf(currentName) } - - AlertDialog( - onDismissRequest = onDismiss, - title = { - Text( - text = "Renomear Playlist", - fontWeight = FontWeight.Bold - ) - }, - text = { - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - Text( - text = "Digite o novo nome da playlist", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - - OutlinedTextField( - value = playlistName, - onValueChange = { playlistName = it }, - label = { Text("Nome da playlist") }, - singleLine = true, - shape = RoundedCornerShape(12.dp), - modifier = Modifier.fillMaxWidth() - ) - } - }, - confirmButton = { - TextButton( - onClick = { onRename(playlistName.trim()) }, - enabled = playlistName.trim().isNotEmpty() && playlistName.trim() != currentName - ) { - Text( - text = "RENOMEAR", - fontWeight = FontWeight.Bold - ) - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text( - text = "CANCELAR", - fontWeight = FontWeight.Medium - ) - } - }, - containerColor = MaterialTheme.colorScheme.surface, - shape = RoundedCornerShape(28.dp) - ) } \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/PreferencesActivity.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/PreferencesActivity.kt index 9f06cce5..0daaac57 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/PreferencesActivity.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/PreferencesActivity.kt @@ -30,6 +30,7 @@ import com.raival.compose.file.explorer.R import com.raival.compose.file.explorer.base.BaseActivity import com.raival.compose.file.explorer.common.ui.SafeSurface import com.raival.compose.file.explorer.screen.preferences.ui.AppearanceContainer +import com.raival.compose.file.explorer.screen.preferences.ui.AudioPlayerContainer import com.raival.compose.file.explorer.screen.preferences.ui.BehaviorContainer import com.raival.compose.file.explorer.screen.preferences.ui.FileListContainer import com.raival.compose.file.explorer.screen.preferences.ui.FileOperationContainer @@ -89,6 +90,7 @@ class PreferencesActivity : BaseActivity() { FileListContainer() FileOperationContainer() BehaviorContainer() + AudioPlayerContainer() RecentFilesContainer() TextEditorContainer() } diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/PreferencesManager.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/PreferencesManager.kt index 90019bb3..e3402b13 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/PreferencesManager.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/PreferencesManager.kt @@ -215,6 +215,13 @@ class PreferencesManager { getPreferencesKey = { booleanPreferencesKey(it) } ) + //---------- Audio Player -------------// + var autoPlayMusic by prefMutableState( + keyName = "autoPlayMusic", + defaultValue = false, + getPreferencesKey = { booleanPreferencesKey(it) } + ) + // //---------- File Sorting -------------// var defaultSortMethod by prefMutableState( diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/AudioPlayerActivity.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/AudioPlayerActivity.kt index 3e277cf0..ba50be53 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/AudioPlayerActivity.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/AudioPlayerActivity.kt @@ -2,10 +2,12 @@ package com.raival.compose.file.explorer.screen.viewer.audio import android.net.Uri import androidx.activity.compose.setContent +import androidx.lifecycle.lifecycleScope import com.raival.compose.file.explorer.screen.viewer.ViewerActivity import com.raival.compose.file.explorer.screen.viewer.ViewerInstance import com.raival.compose.file.explorer.screen.viewer.audio.ui.MusicPlayerScreen import com.raival.compose.file.explorer.theme.FileExplorerTheme +import kotlinx.coroutines.launch class AudioPlayerActivity : ViewerActivity() { override fun onCreateNewInstance( @@ -18,21 +20,26 @@ class AudioPlayerActivity : ViewerActivity() { override fun onReady(instance: ViewerInstance) { val fromPlaylist = intent.getBooleanExtra("fromPlaylist", false) val startIndex = intent.getIntExtra("startIndex", 0) + val audioInstance = instance as AudioPlayerInstance if (fromPlaylist) { val playlistManager = PlaylistManager.getInstance() playlistManager.currentPlaylist.value?.let { playlist -> if (playlist.songs.isNotEmpty() && startIndex < playlist.songs.size) { - val audioInstance = instance as AudioPlayerInstance audioInstance.loadPlaylist(playlist, startIndex) } } + } else { + // Individual song - initialize with autoplay if enabled + lifecycleScope.launch { + audioInstance.initializePlayer(this@AudioPlayerActivity, audioInstance.uri) + } } setContent { FileExplorerTheme { MusicPlayerScreen( - audioPlayerInstance = instance as AudioPlayerInstance, + audioPlayerInstance = audioInstance, onClosed = { onBackPressedDispatcher.onBackPressed() } ) } diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/AudioPlayerInstance.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/AudioPlayerInstance.kt index 336fc51c..edc36612 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/AudioPlayerInstance.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/AudioPlayerInstance.kt @@ -5,7 +5,6 @@ import android.graphics.BitmapFactory import android.media.MediaMetadataRetriever import android.net.Uri import androidx.annotation.OptIn -import androidx.compose.ui.res.stringResource import androidx.media3.common.C.TIME_UNSET import androidx.media3.common.MediaItem import androidx.media3.common.Player @@ -63,7 +62,7 @@ class AudioPlayerInstance( private var positionTrackingJob: Job? = null @OptIn(UnstableApi::class) - suspend fun initializePlayer(context: Context, uri: Uri) { + suspend fun initializePlayer(context: Context, uri: Uri, autoPlay: Boolean = false) { withContext(Dispatchers.Main) { // Release previous player if it exists exoPlayer?.let { player -> @@ -121,10 +120,19 @@ class AudioPlayerInstance( extractMetadata(context, uri) startPositionTracking() + + // Auto-play if enabled in preferences or explicitly requested + if (autoPlay || globalClass.preferencesManager.autoPlayMusic) { + withContext(Dispatchers.Main) { + // Small delay to ensure player is ready + delay(100) + exoPlayer?.play() + } + } } // Overloaded method for better metadata extraction from LocalFileHolder - suspend fun initializePlayer(context: Context, uri: Uri, fileHolder: LocalFileHolder? = null) { + suspend fun initializePlayer(context: Context, uri: Uri, fileHolder: LocalFileHolder? = null, autoPlay: Boolean = false) { withContext(Dispatchers.Main) { // Release previous player if it exists exoPlayer?.let { player -> @@ -182,6 +190,15 @@ class AudioPlayerInstance( extractMetadata(context, uri, fileHolder) startPositionTracking() + + // Auto-play if enabled in preferences or explicitly requested + if (autoPlay || globalClass.preferencesManager.autoPlayMusic) { + withContext(Dispatchers.Main) { + // Small delay to ensure player is ready + delay(100) + exoPlayer?.play() + } + } } fun setDefaultColorScheme(colorScheme: AudioPlayerColorScheme) { @@ -392,6 +409,13 @@ class AudioPlayerInstance( CoroutineScope(Dispatchers.Main).launch { val songUri = Uri.fromFile(song.file) initializePlayer(globalClass, songUri, song) + + // Auto-play if enabled in preferences + if (globalClass.preferencesManager.autoPlayMusic) { + // Small delay to ensure player is ready + delay(100) + exoPlayer?.play() + } } } } diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 49fc2256..f4844c9a 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -276,6 +276,8 @@ Dimensões Nenhuma informação disponível Player de Áudio + Reproduzir automaticamente + Iniciar a reprodução automaticamente ao abrir Título Desconhecido Álbum Desconhecido Volume diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fd145359..a9c1959a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -276,6 +276,8 @@ Dimensions No information available Audio Player + Auto-play music + Automatically start playing music when opened Unknown Title Unknown Album Volume From a7cf0242e62c2647a0ab3e62c5e1b2874062521c Mon Sep 17 00:00:00 2001 From: Mraphaelpy Date: Wed, 20 Aug 2025 09:07:08 -0300 Subject: [PATCH 09/12] fix(player): fix progress bar reset and sync --- .../viewer/audio/AudioPlayerInstance.kt | 117 +++++++++++++----- .../viewer/audio/ui/AudioPlayerScreen.kt | 26 ++-- 2 files changed, 104 insertions(+), 39 deletions(-) diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/AudioPlayerInstance.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/AudioPlayerInstance.kt index edc36612..458575c3 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/AudioPlayerInstance.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/AudioPlayerInstance.kt @@ -64,21 +64,15 @@ class AudioPlayerInstance( @OptIn(UnstableApi::class) suspend fun initializePlayer(context: Context, uri: Uri, autoPlay: Boolean = false) { withContext(Dispatchers.Main) { + // Reset state before any operations + resetPlayerState() + // Release previous player if it exists exoPlayer?.let { player -> player.stop() player.release() } - // Reset player state when changing songs - _playerState.update { - it.copy( - currentPosition = 0L, - duration = 0L, - isLoading = true - ) - } - exoPlayer = ExoPlayer.Builder(context).build().apply { val mediaItem = MediaItem.Builder() .setUri(uri) @@ -104,7 +98,8 @@ class AudioPlayerInstance( if (playbackState == Player.STATE_READY) { _playerState.update { it.copy( - duration = duration + duration = if (duration != TIME_UNSET) duration else 0L, + currentPosition = 0L // Ensure position starts at 0 ) } } @@ -134,21 +129,15 @@ class AudioPlayerInstance( // Overloaded method for better metadata extraction from LocalFileHolder suspend fun initializePlayer(context: Context, uri: Uri, fileHolder: LocalFileHolder? = null, autoPlay: Boolean = false) { withContext(Dispatchers.Main) { + // Reset state before any operations + resetPlayerState() + // Release previous player if it exists exoPlayer?.let { player -> player.stop() player.release() } - // Reset player state when changing songs - _playerState.update { - it.copy( - currentPosition = 0L, - duration = 0L, - isLoading = true - ) - } - exoPlayer = ExoPlayer.Builder(context).build().apply { val mediaItem = MediaItem.Builder() .setUri(uri) @@ -174,7 +163,8 @@ class AudioPlayerInstance( if (playbackState == Player.STATE_READY) { _playerState.update { it.copy( - duration = duration + duration = if (duration != TIME_UNSET) duration else 0L, + currentPosition = 0L ) } } @@ -194,7 +184,6 @@ class AudioPlayerInstance( // Auto-play if enabled in preferences or explicitly requested if (autoPlay || globalClass.preferencesManager.autoPlayMusic) { withContext(Dispatchers.Main) { - // Small delay to ensure player is ready delay(100) exoPlayer?.play() } @@ -303,15 +292,39 @@ class AudioPlayerInstance( } private fun startPositionTracking() { + // Cancel any existing tracking job positionTrackingJob?.cancel() + + var lastPosition = 0L + positionTrackingJob = CoroutineScope(Dispatchers.Main).launch { while (true) { exoPlayer?.let { player -> - _playerState.update { - it.copy( - currentPosition = player.currentPosition, - duration = player.duration.takeIf { it isNot TIME_UNSET } ?: 0L - ) + if (player.playbackState != Player.STATE_IDLE && + player.playbackState != Player.STATE_ENDED) { + + val currentPos = player.currentPosition.coerceAtLeast(0L) + val currentDuration = if (player.duration != TIME_UNSET) player.duration else 0L + + // If position jumped backwards significantly, it's likely a new song + if (currentPos < lastPosition - 5000) { + // Reset position state for new song + _playerState.update { + it.copy( + currentPosition = 0L, + duration = currentDuration + ) + } + } else { + _playerState.update { + it.copy( + currentPosition = currentPos, + duration = currentDuration + ) + } + } + + lastPosition = currentPos } } delay(100) @@ -330,7 +343,13 @@ class AudioPlayerInstance( } fun seekTo(position: Long) { - exoPlayer?.seekTo(position) + exoPlayer?.let { player -> + player.seekTo(position) + // Update state immediately to provide better user feedback + _playerState.update { + it.copy(currentPosition = position) + } + } } fun skipNext() { @@ -434,7 +453,8 @@ class AudioPlayerInstance( nextSong?.let { song -> CoroutineScope(Dispatchers.Main).launch { val songUri = Uri.fromFile(song.file) - initializePlayer(globalClass, songUri, song) + // Auto-play when skipping to next song + initializePlayer(globalClass, songUri, song, autoPlay = true) } } } @@ -455,7 +475,8 @@ class AudioPlayerInstance( previousSong?.let { song -> CoroutineScope(Dispatchers.Main).launch { val songUri = Uri.fromFile(song.file) - initializePlayer(globalClass, songUri, song) + // Auto-play when skipping to previous song + initializePlayer(globalClass, songUri, song, autoPlay = true) } } } @@ -480,7 +501,8 @@ class AudioPlayerInstance( val song = playlist.songs[actualIndex] CoroutineScope(Dispatchers.Main).launch { val songUri = Uri.fromFile(song.file) - initializePlayer(globalClass, songUri, song) + // Auto-play when jumping to specific song + initializePlayer(globalClass, songUri, song, autoPlay = true) } } } @@ -511,18 +533,51 @@ class AudioPlayerInstance( playlistManager.clearCurrentPlaylist() } + private suspend fun resetPlayerState() { + // Cancel position tracking to prevent race conditions + positionTrackingJob?.cancel() + + // Reset player state immediately + _playerState.update { + it.copy( + currentPosition = 0L, + duration = 0L, + isLoading = true, + isPlaying = false + ) + } + + // Small delay to ensure UI processes the reset + delay(50) + } + private fun stopCurrentPlayer() { exoPlayer?.let { player -> if (player.isPlaying) { player.stop() } } + // Cancel position tracking to prevent updates from old player + positionTrackingJob?.cancel() } override fun onClose() { + // Cancel position tracking positionTrackingJob?.cancel() - exoPlayer?.release() + + // Release ExoPlayer resources + exoPlayer?.let { player -> + player.stop() + player.release() + } exoPlayer = null + + // Reset player state + _playerState.update { + PlayerState() + } + + // Clear playlist state clearPlaylist() } diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/ui/AudioPlayerScreen.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/ui/AudioPlayerScreen.kt index c4ab9e4b..6d34ee99 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/ui/AudioPlayerScreen.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/ui/AudioPlayerScreen.kt @@ -187,7 +187,8 @@ fun MusicPlayerScreen( currentPosition = playerState.currentPosition, duration = playerState.duration, onSeek = { audioPlayerInstance.seekTo(it) }, - colorScheme = customColorScheme + colorScheme = customColorScheme, + songId = "${metadata.title}-${metadata.artist}-${playerState.duration}" // Unique identifier per song ) Spacer(modifier = Modifier.height(32.dp)) @@ -461,17 +462,26 @@ fun ProgressBar( currentPosition: Long, duration: Long, onSeek: (Long) -> Unit, - colorScheme: AudioPlayerColorScheme + colorScheme: AudioPlayerColorScheme, + songId: String = "" ) { - var manualPosition by remember { mutableLongStateOf(0L) } - var manualSeek by remember { mutableFloatStateOf(0f) } - var isDragging by remember { mutableStateOf(false) } + // Reset state when song changes + var manualPosition by remember(songId) { mutableLongStateOf(0L) } + var manualSeek by remember(songId) { mutableFloatStateOf(0f) } + var isDragging by remember(songId) { mutableStateOf(false) } + val progress = if (duration > 0) { - if (abs(currentPosition - manualPosition) < 1000) { + if (isDragging) { + manualSeek + } else if (abs(currentPosition - manualPosition) > 2000) { + (currentPosition.toFloat() / duration.toFloat()).also { + manualPosition = currentPosition + } + } else { (currentPosition.toFloat() / duration.toFloat()).also { manualPosition = currentPosition } - } else manualSeek + } } else 0f Column { @@ -501,7 +511,7 @@ fun ProgressBar( horizontalArrangement = Arrangement.SpaceBetween ) { Text( - text = (if (isDragging) (manualSeek * duration).toLong() else manualPosition).toFormattedTime(), + text = (if (isDragging) (manualSeek * duration).toLong() else currentPosition).toFormattedTime(), color = colorScheme.tintColor.copy(alpha = 0.8f), fontSize = 12.sp, style = MaterialTheme.typography.bodySmall From f64a05882b156bae6bb544b0762f06cf3f67e1b3 Mon Sep 17 00:00:00 2001 From: Mraphaelpy Date: Wed, 20 Aug 2025 11:07:18 -0300 Subject: [PATCH 10/12] feat(playlist): add multiple song addition and fix duplicate songs bug --- .../main/tab/files/holder/LocalFileHolder.kt | 10 +++ .../files/ui/dialog/FileOptionsMenuDialog.kt | 19 +++++- .../screen/viewer/audio/PlaylistManager.kt | 30 ++++++++- .../screen/viewer/audio/model/Playlist.kt | 7 +- .../viewer/audio/ui/AudioPlayerScreen.kt | 2 +- .../viewer/audio/ui/PlaylistBottomSheet.kt | 64 +++++++++++++++++-- app/src/main/res/values-pt-rBR/strings.xml | 5 ++ app/src/main/res/values/strings.xml | 5 ++ 8 files changed, 128 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/holder/LocalFileHolder.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/holder/LocalFileHolder.kt index d3d519d5..0996337a 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/holder/LocalFileHolder.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/holder/LocalFileHolder.kt @@ -329,4 +329,14 @@ class LocalFileHolder(val file: File) : ContentHolder() { contentCount.folders ) } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is LocalFileHolder) return false + return file.absolutePath == other.file.absolutePath + } + + override fun hashCode(): Int { + return file.absolutePath.hashCode() + } } \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/dialog/FileOptionsMenuDialog.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/dialog/FileOptionsMenuDialog.kt index 8c68cc48..6342092c 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/dialog/FileOptionsMenuDialog.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/dialog/FileOptionsMenuDialog.kt @@ -85,6 +85,13 @@ fun FileOptionsMenuDialog( val isSingleFolder = !isMultipleSelection && targetContentHolder.isFolder val isAudioFile = isSingleFile && targetContentHolder is LocalFileHolder && audioFileType.contains(targetContentHolder.file.extension) + + // Check for multiple audio files + val audioFiles = targetFiles.filter { file -> + file is LocalFileHolder && file.isFile() && audioFileType.contains(file.file.extension) + }.map { it as LocalFileHolder } + val hasMultipleAudioFiles = audioFiles.size > 1 + val hasAnyAudioFiles = audioFiles.isNotEmpty() var showPlaylistDialog by remember { mutableStateOf(false) } @@ -275,7 +282,6 @@ fun FileOptionsMenuDialog( tab.unselectAllFiles() } - // Add to playlist option for audio files if (isAudioFile) { FileOption(Icons.AutoMirrored.Rounded.PlaylistAdd, stringResource(R.string.add_to_playlist)) { showPlaylistDialog = true @@ -296,6 +302,12 @@ fun FileOptionsMenuDialog( } } + if (hasMultipleAudioFiles) { + FileOption(Icons.AutoMirrored.Rounded.PlaylistAdd, stringResource(R.string.add_multiple_to_playlist)) { + showPlaylistDialog = true + } + } + if (tab.activeFolder !is ZipFileHolder) { FileOption(Icons.Rounded.Compress, stringResource(R.string.compress)) { globalClass.taskManager.addTask( @@ -323,7 +335,7 @@ fun FileOptionsMenuDialog( } // Playlist dialog for audio files - if (isAudioFile) { + if (isAudioFile || hasAnyAudioFiles) { PlaylistBottomSheet( isVisible = showPlaylistDialog, onDismiss = { @@ -335,7 +347,8 @@ fun FileOptionsMenuDialog( onDismissRequest() tab.unselectAllFiles() }, - selectedSong = if (targetContentHolder is LocalFileHolder) targetContentHolder else null + selectedSong = if (isAudioFile && targetContentHolder is LocalFileHolder) targetContentHolder else null, + selectedSongs = if (hasMultipleAudioFiles) audioFiles else emptyList() ) } } diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/PlaylistManager.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/PlaylistManager.kt index b08a1090..78c1d8a5 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/PlaylistManager.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/PlaylistManager.kt @@ -104,10 +104,11 @@ class PlaylistManager private constructor() { return playlist } - fun addSongToPlaylist(playlistId: String, song: LocalFileHolder) { + fun addSongToPlaylist(playlistId: String, song: LocalFileHolder): Boolean { + var wasAdded = false _playlists.value = _playlists.value.map { playlist -> if (playlist.id == playlistId) { - playlist.copy().apply { addSong(song) } + playlist.copy().apply { wasAdded = addSong(song) } } else { playlist } @@ -116,6 +117,31 @@ class PlaylistManager private constructor() { _currentPlaylist.value = _playlists.value.find { it.id == playlistId } } savePlaylists() + return wasAdded + } + + fun addMultipleSongsToPlaylist(playlistId: String, songs: List): Int { + if (songs.isEmpty()) return 0 + + var addedCount = 0 + _playlists.value = _playlists.value.map { playlist -> + if (playlist.id == playlistId) { + playlist.copy().apply { + songs.forEach { song -> + if (addSong(song)) { + addedCount++ + } + } + } + } else { + playlist + } + } + if (_currentPlaylist.value?.id == playlistId) { + _currentPlaylist.value = _playlists.value.find { it.id == playlistId } + } + savePlaylists() + return addedCount } fun removeSongFromPlaylistAt(playlistId: String, index: Int) { diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/model/Playlist.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/model/Playlist.kt index 0fac39a6..037e53b4 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/model/Playlist.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/model/Playlist.kt @@ -11,9 +11,12 @@ data class Playlist( val createdAt: Long = System.currentTimeMillis(), val currentSongIndex: Int = 0 ) { - fun addSong(song: LocalFileHolder) { - if (!songs.contains(song)) { + fun addSong(song: LocalFileHolder): Boolean { + return if (!songs.contains(song)) { songs.add(song) + true + } else { + false } } diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/ui/AudioPlayerScreen.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/ui/AudioPlayerScreen.kt index 6d34ee99..113dd743 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/ui/AudioPlayerScreen.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/ui/AudioPlayerScreen.kt @@ -939,7 +939,7 @@ fun CurrentPlaylistInfo( overflow = TextOverflow.Ellipsis ) Text( - text = "${currentSongIndex + 1} de ${playlist.size()}${if (isShuffled) " • Aleatório" else ""}", + text = "${currentSongIndex + 1} ${playlist.size()}${if (isShuffled) " • ${stringResource(R.string.random)}" else ""}", style = MaterialTheme.typography.bodySmall, color = colorScheme.tintColor.copy(alpha = 0.7f) ) diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/ui/PlaylistBottomSheet.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/ui/PlaylistBottomSheet.kt index f2ca8f3c..0d3fd213 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/ui/PlaylistBottomSheet.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/ui/PlaylistBottomSheet.kt @@ -38,6 +38,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import com.raival.compose.file.explorer.App.Companion.globalClass import com.raival.compose.file.explorer.R import com.raival.compose.file.explorer.common.ui.BottomSheetDialog import com.raival.compose.file.explorer.screen.main.tab.files.holder.LocalFileHolder @@ -49,12 +50,20 @@ fun PlaylistBottomSheet( isVisible: Boolean, onDismiss: () -> Unit, onPlaylistSelected: (Playlist) -> Unit, - selectedSong: LocalFileHolder? = null + selectedSong: LocalFileHolder? = null, + selectedSongs: List = emptyList() ) { if (isVisible) { val playlistManager = remember { PlaylistManager.getInstance() } val playlists by playlistManager.playlists.collectAsState() var showCreateDialog by remember { mutableStateOf(false) } + + val songsToAdd = when { + selectedSongs.isNotEmpty() -> selectedSongs + selectedSong != null -> listOf(selectedSong) + else -> emptyList() + } + val isMultipleSongs = songsToAdd.size > 1 BottomSheetDialog(onDismissRequest = onDismiss) { Column( @@ -68,7 +77,11 @@ fun PlaylistBottomSheet( verticalAlignment = Alignment.CenterVertically ) { Text( - text = stringResource(R.string.playlists), + text = if (isMultipleSongs) { + "${stringResource(R.string.add_multiple_to_playlist)} (${songsToAdd.size})" + } else { + stringResource(R.string.playlists) + }, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold ) @@ -120,8 +133,28 @@ fun PlaylistBottomSheet( PlaylistItem( playlist = playlist, onPlaylistClick = { - selectedSong?.let { song -> - playlistManager.addSongToPlaylist(playlist.id, song) + if (songsToAdd.isNotEmpty()) { + if (isMultipleSongs) { + val addedCount = playlistManager.addMultipleSongsToPlaylist(playlist.id, songsToAdd) + val duplicateCount = songsToAdd.size - addedCount + + if (duplicateCount > 0) { + globalClass.showMsg( + globalClass.getString(R.string.songs_added_with_duplicates, addedCount, duplicateCount) + ) + } else { + globalClass.showMsg( + globalClass.getString(R.string.songs_added_to_playlist, addedCount) + ) + } + } else { + val wasAdded = playlistManager.addSongToPlaylist(playlist.id, songsToAdd.first()) + if (wasAdded) { + globalClass.showMsg(R.string.song_added_to_playlist) + } else { + globalClass.showMsg(R.string.song_already_in_playlist) + } + } } onPlaylistSelected(playlist) }, @@ -139,8 +172,27 @@ fun PlaylistBottomSheet( CreatePlaylistDialog( onDismiss = { showCreateDialog = false }, onPlaylistCreated = { name -> - val newPlaylist = if (selectedSong != null) { - playlistManager.createPlaylistWithSong(name, selectedSong) + val newPlaylist = if (songsToAdd.isNotEmpty()) { + if (isMultipleSongs) { + val playlist = playlistManager.createPlaylist(name) + val addedCount = playlistManager.addMultipleSongsToPlaylist(playlist.id, songsToAdd) + val duplicateCount = songsToAdd.size - addedCount + + if (duplicateCount > 0) { + globalClass.showMsg( + globalClass.getString(R.string.songs_added_with_duplicates, addedCount, duplicateCount) + ) + } else { + globalClass.showMsg( + globalClass.getString(R.string.songs_added_to_playlist, addedCount) + ) + } + playlist + } else { + val playlist = playlistManager.createPlaylistWithSong(name, songsToAdd.first()) + globalClass.showMsg(R.string.song_added_to_playlist) + playlist + } } else { playlistManager.createPlaylist(name) } diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index f4844c9a..7ddbc8e7 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -409,15 +409,20 @@ Playlists Criar playlist Adicionar à playlist + Adicionar múltiplas à playlist Nenhuma playlist criada Toque em + para criar sua primeira playlist Nome da playlist Digite o nome da playlist Playlist criada com sucesso Música adicionada à playlist + Música já está na playlist + %d músicas adicionadas à playlist + %1$d músicas adicionadas, %2$d duplicatas ignoradas Música removida da playlist Playlist atual Modo aleatório + Aleatório Tocando agora %d músicas Reproduzir tudo diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a9c1959a..85bd67da 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -408,15 +408,20 @@ Playlists Create playlist Add to playlist + Add multiple to playlist No playlists created Tap + to create your first playlist Playlist name Enter playlist name Playlist created successfully Song added to playlist + Song is already in playlist + %d songs added to playlist + %1$d songs added, %2$d duplicates skipped Song removed from playlist Current playlist Shuffle mode + Random Now playing %d Songs Play all From ef32f3d9ea4a4957567e3589d1a3b815dcf5aff3 Mon Sep 17 00:00:00 2001 From: Mraphaelpy Date: Wed, 20 Aug 2025 11:25:56 -0300 Subject: [PATCH 11/12] fix --- .../preferences/ui/AudioPlayerContainer.kt | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 app/src/main/java/com/raival/compose/file/explorer/screen/preferences/ui/AudioPlayerContainer.kt diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/ui/AudioPlayerContainer.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/ui/AudioPlayerContainer.kt new file mode 100644 index 00000000..3cb5ffd7 --- /dev/null +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/ui/AudioPlayerContainer.kt @@ -0,0 +1,23 @@ +package com.raival.compose.file.explorer.screen.preferences.ui + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.PlayArrow +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.raival.compose.file.explorer.App.Companion.globalClass +import com.raival.compose.file.explorer.R + +@Composable +fun AudioPlayerContainer() { + val prefs = globalClass.preferencesManager + + Container(title = stringResource(R.string.title_activity_audio_player)) { + PreferenceItem( + label = stringResource(R.string.auto_play_music), + supportingText = stringResource(R.string.auto_play_music_desc), + icon = Icons.Rounded.PlayArrow, + switchState = prefs.autoPlayMusic, + onSwitchChange = { prefs.autoPlayMusic = it } + ) + } +} From 39b7e8b604eee61507d637d236f8c5a70fddff2c Mon Sep 17 00:00:00 2001 From: Mraphaelpy Date: Wed, 20 Aug 2025 13:36:17 -0300 Subject: [PATCH 12/12] fix(playlist): fix delete function and crash when removing songs; improve UI --- .../screen/playlist/ui/PlaylistDetailSheet.kt | 259 +++++++++++++----- .../playlist/ui/PlaylistHeaderComponents.kt | 257 +++++++++++++---- .../playlist/ui/PlaylistManagerScreen.kt | 161 +++++++++-- .../explorer/screen/playlist/ui/SongItem.kt | 106 +++---- .../viewer/audio/AudioPlayerInstance.kt | 54 +--- .../screen/viewer/audio/PlaylistManager.kt | 12 +- .../audio/ui/PlaylistDetailBottomSheet.kt | 40 ++- app/src/main/res/values-pt-rBR/strings.xml | 6 +- app/src/main/res/values/strings.xml | 5 +- 9 files changed, 641 insertions(+), 259 deletions(-) diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/playlist/ui/PlaylistDetailSheet.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/playlist/ui/PlaylistDetailSheet.kt index 79a50af6..5b476d44 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/playlist/ui/PlaylistDetailSheet.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/playlist/ui/PlaylistDetailSheet.kt @@ -1,8 +1,17 @@ package com.raival.compose.file.explorer.screen.playlist.ui import android.annotation.SuppressLint +import androidx.compose.animation.AnimatedContent import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -13,6 +22,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState @@ -31,10 +41,14 @@ import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource @@ -42,9 +56,11 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.min import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.raival.compose.file.explorer.R import com.raival.compose.file.explorer.screen.viewer.audio.PlaylistManager import com.raival.compose.file.explorer.screen.viewer.audio.model.Playlist +import kotlinx.coroutines.launch @SuppressLint("ConfigurationScreenWidthHeight") @OptIn(ExperimentalFoundationApi::class, ExperimentalAnimationApi::class) @@ -54,12 +70,23 @@ fun PlaylistDetailSheet( onDismiss: () -> Unit, onPlayClick: (Int) -> Unit ) { + val playlistManager = remember { PlaylistManager.getInstance() } + val playlists by playlistManager.playlists.collectAsStateWithLifecycle(initialValue = emptyList()) + + val currentPlaylist = remember(playlists, playlist.id) { + playlists.find { it.id == playlist.id } ?: playlist + } + val dialogScale = remember { Animatable(0.95f) } + val dialogAlpha = remember { Animatable(0f) } + val scope = rememberCoroutineScope() + val configuration = LocalConfiguration.current val density = LocalDensity.current val screenWidth = with(density) { configuration.screenWidthDp.dp } val screenHeight = with(density) { configuration.screenHeightDp.dp } val dialogWidth = min(screenWidth * 0.92f, 480.dp) val dialogHeight = min(screenHeight * 0.85f, 680.dp) + val lazyListState = rememberLazyListState() val isScrolled by remember { derivedStateOf { lazyListState.firstVisibleItemIndex > 0 || lazyListState.firstVisibleItemScrollOffset > 0 } @@ -67,14 +94,42 @@ fun PlaylistDetailSheet( var currentlyPlayingIndex by remember { mutableIntStateOf(-1) } - LaunchedEffect(playlist) { - if (playlist.songs.isNotEmpty()) { + LaunchedEffect(Unit) { + dialogScale.animateTo( + targetValue = 1f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioLowBouncy, + stiffness = Spring.StiffnessLow + ) + ) + dialogAlpha.animateTo( + targetValue = 1f, + animationSpec = tween(300) + ) + } + + LaunchedEffect(currentPlaylist.songs.size) { + if (currentlyPlayingIndex >= currentPlaylist.songs.size) { currentlyPlayingIndex = -1 } } + val dismissWithAnimation: () -> Unit = { + scope.launch { + dialogScale.animateTo( + targetValue = 0.95f, + animationSpec = tween(200) + ) + dialogAlpha.animateTo( + targetValue = 0f, + animationSpec = tween(200) + ) + onDismiss() + } + } + Dialog( - onDismissRequest = onDismiss, + onDismissRequest = dismissWithAnimation, properties = DialogProperties( dismissOnBackPress = true, dismissOnClickOutside = true, @@ -85,22 +140,50 @@ fun PlaylistDetailSheet( modifier = Modifier .width(dialogWidth) .height(dialogHeight) + .graphicsLayer { + scaleX = dialogScale.value + scaleY = dialogScale.value + alpha = dialogAlpha.value + } + .shadow( + elevation = 16.dp, + shape = RoundedCornerShape(28.dp), + clip = false, + ambientColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f), + spotColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f) + ) .clip(RoundedCornerShape(28.dp)), shape = RoundedCornerShape(28.dp), color = MaterialTheme.colorScheme.surface, - shadowElevation = 8.dp + tonalElevation = 2.dp ) { - PlaylistDetailContent( - playlist = playlist, - isScrolled = isScrolled, - currentlyPlayingIndex = currentlyPlayingIndex, - onPlayClick = { index -> - onPlayClick(index) - currentlyPlayingIndex = if (index == currentlyPlayingIndex) -1 else index - }, - onDismiss = onDismiss, - lazyListState = lazyListState - ) + Box(modifier = Modifier.fillMaxSize()) { + Box( + modifier = Modifier + .fillMaxSize() + .background( + brush = Brush.verticalGradient( + colors = listOf( + MaterialTheme.colorScheme.surface, + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + MaterialTheme.colorScheme.surface + ) + ) + ) + ) + + PlaylistDetailContent( + playlist = currentPlaylist, + isScrolled = isScrolled, + currentlyPlayingIndex = currentlyPlayingIndex, + onPlayClick = { index -> + onPlayClick(index) + currentlyPlayingIndex = if (index == currentlyPlayingIndex) -1 else index + }, + onDismiss = dismissWithAnimation, + lazyListState = lazyListState + ) + } } } } @@ -116,31 +199,14 @@ private fun PlaylistDetailContent( lazyListState: LazyListState ) { Box(modifier = Modifier.fillMaxSize()) { - PlaylistHeaderBackground(isScrolled = isScrolled) Column( modifier = Modifier .fillMaxSize() .padding(top = 8.dp) + .systemBarsPadding() ) { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(start = 20.dp, end = 12.dp) - ) { - IconButton( - onClick = onDismiss, - modifier = Modifier - .align(Alignment.CenterEnd) - .size(40.dp) - ) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = stringResource(R.string.close), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } + HeaderBar(onDismiss = onDismiss) Column( modifier = Modifier @@ -150,53 +216,98 @@ private fun PlaylistDetailContent( PlaylistHeader( playlist = playlist, isScrolled = isScrolled, - onPlayAllClick = { onPlayClick(0) } - ) - if (playlist.songs.isEmpty()) { - Box( - modifier = Modifier - .weight(1f) - .fillMaxWidth(), - contentAlignment = Alignment.Center - ) { - EmptyPlaylistContent() + onPlayAllClick = { + if (playlist.songs.isNotEmpty()) onPlayClick(0) } - } else { - LazyColumn( - state = lazyListState, - modifier = Modifier - .weight(1f) - .fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(12.dp), - contentPadding = PaddingValues( - top = 16.dp, - bottom = 24.dp - ) - ) { - itemsIndexed( - items = playlist.songs, - key = { _, song -> song.uid } - ) { index, song -> - val isPlaying = index == currentlyPlayingIndex - - SongItem( - song = song, - index = index, - isPlaying = isPlaying, - modifier = Modifier - .animateItem() - .fillMaxWidth() - .padding(vertical = 1.dp), - onPlayClick = { onPlayClick(index) }, - onRemoveClick = { - PlaylistManager.getInstance().removeSongFromPlaylistAt(playlist.id, index) - } + ) + + AnimatedContent( + targetState = playlist.songs.isEmpty(), + transitionSpec = { + fadeIn(animationSpec = tween(300)) togetherWith + fadeOut(animationSpec = tween(300)) + }, + label = "ContentSwitcher" + ) { isEmpty -> + if (isEmpty) { + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + EmptyPlaylistContent() + } + } else { + LazyColumn( + state = lazyListState, + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues( + top = 16.dp, + bottom = 24.dp ) + ) { + itemsIndexed( + items = playlist.songs, + key = { index, song -> "${song.uid}_$index" } + ) { index, song -> + val isPlaying = index == currentlyPlayingIndex + + SongItem( + song = song, + index = index, + isPlaying = isPlaying, + modifier = Modifier + .animateItem() + .fillMaxWidth() + .padding(vertical = 1.dp), + onPlayClick = { onPlayClick(index) }, + onRemoveClick = { + PlaylistManager.getInstance().removeSongFromPlaylistAt(playlist.id, index) + } + ) + } } } } + Spacer(modifier = Modifier.height(16.dp)) } } } +} + +@Composable +private fun HeaderBar(onDismiss: () -> Unit) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(start = 20.dp, end = 12.dp, bottom = 4.dp) + ) { + IconButton( + onClick = onDismiss, + modifier = Modifier + .align(Alignment.CenterEnd) + .size(42.dp) + .shadow( + elevation = 2.dp, + shape = RoundedCornerShape(12.dp), + clip = false + ) + .background( + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.7f), + shape = RoundedCornerShape(12.dp) + ) + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.close), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp) + ) + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/playlist/ui/PlaylistHeaderComponents.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/playlist/ui/PlaylistHeaderComponents.kt index e50c650c..812068c1 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/playlist/ui/PlaylistHeaderComponents.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/playlist/ui/PlaylistHeaderComponents.kt @@ -1,62 +1,66 @@ package com.raival.compose.file.explorer.screen.playlist.ui import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.EaseInOutCubic +import androidx.compose.animation.core.EaseOutQuint +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.spring 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.togetherWith import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box 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.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.PlaylistPlay import androidx.compose.material.icons.filled.PlayArrow -import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ElevatedButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.raival.compose.file.explorer.R import com.raival.compose.file.explorer.screen.viewer.audio.model.Playlist - -@Composable -fun PlaylistHeaderBackground(isScrolled: Boolean) { - AnimatedVisibility( - visible = isScrolled, - enter = fadeIn(tween(200)), - exit = fadeOut(tween(200)) - ) { - Surface( - color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.95f), - shadowElevation = 4.dp, - modifier = Modifier - .fillMaxWidth() - .height(56.dp) - ) {} - } -} - @OptIn(ExperimentalAnimationApi::class) @Composable fun PlaylistHeader( @@ -64,19 +68,40 @@ fun PlaylistHeader( isScrolled: Boolean, onPlayAllClick: () -> Unit ) { + val bottomPadding by animateDpAsState( + targetValue = if (isScrolled) 8.dp else 16.dp, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessLow + ), + label = "bottomPadding" + ) + Column( modifier = Modifier .fillMaxWidth() - .padding(bottom = if (isScrolled) 0.dp else 16.dp) + .padding(bottom = bottomPadding) ) { HeaderRow(playlist, isScrolled, onPlayAllClick) + + val dividerThickness by animateDpAsState( + targetValue = if (isScrolled) 1.dp else 0.5.dp, + animationSpec = tween(500), + label = "dividerThickness" + ) + + val dividerAlpha by animateFloatAsState( + targetValue = if (isScrolled) 1f else 0.6f, + animationSpec = tween(500), + label = "dividerAlpha" + ) + HorizontalDivider( - thickness = if (isScrolled) 1.dp else 0.5.dp, - color = if (isScrolled) { - MaterialTheme.colorScheme.outlineVariant - } else { - MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) - } + thickness = dividerThickness, + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = dividerAlpha), + modifier = Modifier + .padding(horizontal = if (isScrolled) 0.dp else 8.dp) + .animateContentSize() ) } } @@ -90,11 +115,23 @@ fun HeaderRow( Row( modifier = Modifier .fillMaxWidth() - .padding(top = 8.dp, bottom = 16.dp), + .padding(top = 8.dp, bottom = if (isScrolled) 8.dp else 16.dp) + .animateContentSize( + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessLow + ) + ), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - PlaylistInfo(playlist, isScrolled) + PlaylistInfo( + playlist = playlist, + isScrolled = isScrolled, + modifier = Modifier.weight(1f) + ) + + Spacer(modifier = Modifier.width(16.dp)) if (playlist.songs.isNotEmpty()) { PlayAllButton(onPlayAllClick) @@ -102,6 +139,7 @@ fun HeaderRow( } } +@OptIn(ExperimentalAnimationApi::class) @Composable fun PlaylistInfo(playlist: Playlist, isScrolled: Boolean, modifier: Modifier = Modifier) { Row( @@ -115,11 +153,14 @@ fun PlaylistInfo(playlist: Playlist, isScrolled: Boolean, modifier: Modifier = M targetState = isScrolled, label = "HeaderTextAnimation", transitionSpec = { - fadeIn(tween(300)) togetherWith fadeOut(tween(150)) + fadeIn(tween(300)) + slideInVertically( + initialOffsetY = { if (targetState) 20 else -20 }, + animationSpec = tween(300, easing = EaseOutQuint) + ) togetherWith fadeOut(tween(150)) } ) { scrolled -> Column( - modifier = Modifier.weight(1f) + modifier = Modifier.fillMaxWidth() ) { Text( text = playlist.name, @@ -133,6 +174,8 @@ fun PlaylistInfo(playlist: Playlist, isScrolled: Boolean, modifier: Modifier = M overflow = TextOverflow.Ellipsis ) + Spacer(modifier = Modifier.height(2.dp)) + Text( text = stringResource(R.string.songs_count, playlist.songs.size), style = if (scrolled) { @@ -149,45 +192,159 @@ fun PlaylistInfo(playlist: Playlist, isScrolled: Boolean, modifier: Modifier = M @Composable fun PlaylistIcon(isScrolled: Boolean) { + val size by animateDpAsState( + targetValue = if (isScrolled) 40.dp else 56.dp, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessLow + ), + label = "iconSize" + ) + + val cornerRadius by animateDpAsState( + targetValue = if (isScrolled) 10.dp else 14.dp, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessLow + ), + label = "cornerRadius" + ) + + val infiniteTransition = rememberInfiniteTransition(label = "pulse") + val scale by infiniteTransition.animateFloat( + initialValue = 1f, + targetValue = 1.05f, + animationSpec = infiniteRepeatable( + animation = tween(1500, easing = EaseInOutCubic), + repeatMode = RepeatMode.Reverse + ), + label = "scaleAnimation" + ) + Box( modifier = Modifier - .size(if (isScrolled) 40.dp else 56.dp) - .clip(RoundedCornerShape(12.dp)) + .size(size) + .shadow( + elevation = 4.dp, + shape = RoundedCornerShape(cornerRadius), + clip = false + ) + .clip(RoundedCornerShape(cornerRadius)) .background( - Brush.linearGradient( + brush = Brush.linearGradient( colors = listOf( MaterialTheme.colorScheme.primary, - MaterialTheme.colorScheme.tertiary + MaterialTheme.colorScheme.tertiary, + MaterialTheme.colorScheme.secondary, ) ) + ) + .border( + width = 0.5.dp, + color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.2f), + shape = RoundedCornerShape(cornerRadius) ), contentAlignment = Alignment.Center ) { + Box( + modifier = Modifier + .fillMaxSize() + .background( + brush = Brush.radialGradient( + colors = listOf( + Color.White.copy(alpha = 0.2f * scale), + Color.Transparent + ) + ) + ) + ) + + val iconSize by animateDpAsState( + targetValue = if (isScrolled) 24.dp else 32.dp, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessLow + ), + label = "iconInnerSize" + ) + Icon( imageVector = Icons.AutoMirrored.Filled.PlaylistPlay, contentDescription = null, tint = MaterialTheme.colorScheme.onPrimary, - modifier = Modifier.size(if (isScrolled) 24.dp else 32.dp) + modifier = Modifier + .size(iconSize) + .graphicsLayer { + this.scaleX = scale + this.scaleY = scale + } ) } } @Composable fun PlayAllButton(onPlayAllClick: () -> Unit) { - FilledTonalButton( - onClick = onPlayAllClick, + val buttonScale = remember { Animatable(0.9f) } + + LaunchedEffect(Unit) { + buttonScale.animateTo( + targetValue = 1f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessLow + ) + ) + } + + ElevatedButton( + onClick = { + onPlayAllClick() + }, shape = RoundedCornerShape(20.dp), - modifier = Modifier.height(40.dp) + colors = ButtonDefaults.elevatedButtonColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer + ), + elevation = ButtonDefaults.elevatedButtonElevation( + defaultElevation = 4.dp, + pressedElevation = 2.dp + ), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), + modifier = Modifier + .height(40.dp) + .padding(end = 2.dp) + .graphicsLayer { + scaleX = buttonScale.value + scaleY = buttonScale.value + } ) { - Icon( - imageVector = Icons.Default.PlayArrow, - contentDescription = null, - modifier = Modifier.size(20.dp) - ) - Spacer(modifier = Modifier.width(6.dp)) - Text( - text = stringResource(R.string.play_all), - style = MaterialTheme.typography.labelLarge - ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Box( + modifier = Modifier + .size(24.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.2f)) + .padding(2.dp), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.PlayArrow, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(16.dp) + ) + } + + Spacer(modifier = Modifier.width(8.dp)) + + Text( + text = stringResource(R.string.play_all), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold + ) + } } } \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/playlist/ui/PlaylistManagerScreen.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/playlist/ui/PlaylistManagerScreen.kt index e7e284f9..4e96b7f7 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/playlist/ui/PlaylistManagerScreen.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/playlist/ui/PlaylistManagerScreen.kt @@ -37,11 +37,14 @@ import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.GraphicEq +import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Pause import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material3.AlertDialog import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.FilledTonalButton @@ -88,6 +91,7 @@ fun PlaylistManagerScreen( val currentPlaylist by playlistManager.currentPlaylist.collectAsStateWithLifecycle(initialValue = null) var showCreateDialog by remember { mutableStateOf(false) } + var showEditDialog by remember { mutableStateOf(null) } var showPlaylistDetail by remember { mutableStateOf(null) } val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() @@ -191,6 +195,7 @@ fun PlaylistManagerScreen( .fillMaxWidth(), onClick = { showPlaylistDetail = playlist }, onPlayClick = { onPlayPlaylist(playlist, 0) }, + onEditClick = { showEditDialog = playlist }, onDeleteClick = { playlistManager.deletePlaylist(playlist.id) } ) } @@ -199,7 +204,6 @@ fun PlaylistManagerScreen( } } - // Create Playlist Dialog if (showCreateDialog) { CreatePlaylistDialog( onDismiss = { showCreateDialog = false }, @@ -210,7 +214,17 @@ fun PlaylistManagerScreen( ) } - // Playlist Detail Sheet + showEditDialog?.let { playlist -> + EditPlaylistDialog( + playlist = playlist, + onDismiss = { showEditDialog = null }, + onConfirm = { newName -> + playlistManager.updatePlaylistName(playlist.id, newName) + showEditDialog = null + } + ) + } + showPlaylistDetail?.let { playlist -> PlaylistDetailSheet( playlist = playlist, @@ -298,9 +312,11 @@ private fun PlaylistCard( modifier: Modifier = Modifier, onClick: () -> Unit, onPlayClick: () -> Unit, + onEditClick: () -> Unit, onDeleteClick: () -> Unit ) { var showDeleteConfirmation by remember { mutableStateOf(false) } + var showOptionsMenu by remember { mutableStateOf(false) } val cardColor = if (isCurrentlyPlaying) { MaterialTheme.colorScheme.primaryContainer @@ -398,7 +414,6 @@ private fun PlaylistCard( Row( horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - // Play button Surface( onClick = onPlayClick, enabled = playlist.songs.isNotEmpty(), @@ -432,19 +447,71 @@ private fun PlaylistCard( } } - // Delete button - Surface( - onClick = { showDeleteConfirmation = true }, - shape = CircleShape, - color = Color.Transparent, - contentColor = MaterialTheme.colorScheme.error, - modifier = Modifier.size(40.dp) - ) { - Box(contentAlignment = Alignment.Center) { - Icon( - imageVector = Icons.Default.Delete, - contentDescription = stringResource(R.string.delete), - modifier = Modifier.size(24.dp) + Box { + Surface( + onClick = { showOptionsMenu = true }, + shape = CircleShape, + color = Color.Transparent, + contentColor = if (isCurrentlyPlaying) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = Modifier.size(40.dp) + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = stringResource(R.string.more_options), + modifier = Modifier.size(24.dp) + ) + } + } + + DropdownMenu( + expanded = showOptionsMenu, + onDismissRequest = { showOptionsMenu = false } + ) { + DropdownMenuItem( + text = { + Text( + text = stringResource(R.string.edit), + style = MaterialTheme.typography.bodyMedium + ) + }, + onClick = { + showOptionsMenu = false + onEditClick() + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Edit, + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + } + ) + + DropdownMenuItem( + text = { + Text( + text = stringResource(R.string.delete), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error + ) + }, + onClick = { + showOptionsMenu = false + showDeleteConfirmation = true + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.error + ) + } ) } } @@ -554,4 +621,66 @@ private fun CreatePlaylistDialog( containerColor = MaterialTheme.colorScheme.surface, shape = RoundedCornerShape(28.dp) ) +} + +@Composable +private fun EditPlaylistDialog( + playlist: Playlist, + onDismiss: () -> Unit, + onConfirm: (String) -> Unit +) { + var playlistName by remember { mutableStateOf(playlist.name) } + val density = LocalDensity.current + + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + text = stringResource(R.string.edit_playlist), + fontWeight = FontWeight.Bold + ) + }, + text = { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = stringResource(R.string.enter_playlist_name), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + OutlinedTextField( + value = playlistName, + onValueChange = { playlistName = it }, + label = { Text(stringResource(R.string.playlist_name)) }, + singleLine = true, + shape = RoundedCornerShape(12.dp), + modifier = Modifier.fillMaxWidth() + ) + } + }, + confirmButton = { + TextButton( + onClick = { onConfirm(playlistName.trim()) }, + enabled = playlistName.trim().isNotEmpty() && playlistName.trim() != playlist.name + ) { + Text( + text = stringResource(R.string.save).uppercase(), + fontWeight = FontWeight.Bold + ) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text( + text = stringResource(R.string.cancel).uppercase(), + fontWeight = FontWeight.Medium + ) + } + }, + containerColor = MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape(28.dp) + ) } \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/playlist/ui/SongItem.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/playlist/ui/SongItem.kt index dc523a89..8086c622 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/playlist/ui/SongItem.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/playlist/ui/SongItem.kt @@ -61,6 +61,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.DialogProperties import com.raival.compose.file.explorer.R import com.raival.compose.file.explorer.screen.main.tab.files.holder.LocalFileHolder + @OptIn(ExperimentalAnimationApi::class) @Composable fun SongItem( @@ -74,7 +75,6 @@ fun SongItem( var showRemoveConfirmation by remember { mutableStateOf(false) } var showDropdownMenu by remember { mutableStateOf(false) } - // A animação da elevação do card val elevation by animateDpAsState( targetValue = if (isPlaying) 6.dp else 1.dp, animationSpec = spring( @@ -108,7 +108,6 @@ fun SongItem( ) } - // Diálogo de confirmação para remoção if (showRemoveConfirmation) { RemoveConfirmationDialog( songName = song.displayName, @@ -135,14 +134,14 @@ private fun SongContent( Box(modifier = Modifier.fillMaxWidth()) { if (isPlaying) { LinearProgressIndicator( - progress = { 0.7f }, - modifier = Modifier - .fillMaxWidth() - .height(2.dp) - .align(Alignment.TopCenter), - color = MaterialTheme.colorScheme.primary, - trackColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f), - strokeCap = ProgressIndicatorDefaults.LinearStrokeCap, + progress = { 0.7f }, + modifier = Modifier + .fillMaxWidth() + .height(2.dp) + .align(Alignment.TopCenter), + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f), + strokeCap = ProgressIndicatorDefaults.LinearStrokeCap, ) } @@ -151,13 +150,19 @@ private fun SongContent( .fillMaxWidth() .clickable { onPlayClick() } .padding(vertical = 12.dp, horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween ) { - SongNumberIndicator(index = index, isPlaying = isPlaying) + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically + ) { + SongNumberIndicator(index = index, isPlaying = isPlaying) - Spacer(modifier = Modifier.width(16.dp)) + Spacer(modifier = Modifier.width(16.dp)) - SongDetails(song) + SongDetails(song, Modifier.weight(1f)) + } SongItemActions( isPlaying = isPlaying, @@ -262,45 +267,43 @@ private fun SongItemActions( onRemoveClick: () -> Unit ) { Box { - ActionButtons(isPlaying, onPlayClick, onMenuClick) + Row { + AnimatedContent( + targetState = isPlaying, + label = "PlayButtonState", + transitionSpec = { + fadeIn(tween(300)) togetherWith fadeOut(tween(150)) + } + ) { playing -> + PlayPauseButton(playing, onPlayClick) + } - // Dropdown Menu + IconButton( + onClick = onMenuClick, + modifier = Modifier + .size(48.dp) + .padding(4.dp) + ) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = stringResource(R.string.more), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(24.dp) + ) + } + } DropdownMenu( expanded = showMenu, - onDismissRequest = onDismissMenu + onDismissRequest = onDismissMenu, + modifier = Modifier + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(vertical = 4.dp) ) { RemoveFromPlaylistMenuItem(onDismissMenu, onRemoveClick) } } } -@Composable -private fun ActionButtons( - isPlaying: Boolean, - onPlayClick: () -> Unit, - onMenuClick: () -> Unit -) { - Row { - AnimatedContent( - targetState = isPlaying, - label = "PlayButtonState", - transitionSpec = { - fadeIn(tween(300)) togetherWith fadeOut(tween(150)) - } - ) { playing -> - PlayPauseButton(playing, onPlayClick) - } - - IconButton(onClick = onMenuClick) { - Icon( - imageVector = Icons.Default.MoreVert, - contentDescription = stringResource(R.string.more), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } -} - @Composable private fun PlayPauseButton(isPlaying: Boolean, onPlayClick: () -> Unit) { IconButton( @@ -338,19 +341,25 @@ private fun PlayPauseButton(isPlaying: Boolean, onPlayClick: () -> Unit) { private fun RemoveFromPlaylistMenuItem(onDismissMenu: () -> Unit, onRemoveClick: () -> Unit) { DropdownMenuItem( text = { - Text(stringResource(R.string.remove_from_playlist)) + Text( + text = stringResource(R.string.remove_from_playlist), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface + ) }, leadingIcon = { Icon( imageVector = Icons.Default.PlaylistRemove, contentDescription = null, - tint = MaterialTheme.colorScheme.error + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(20.dp) ) }, onClick = { onDismissMenu() onRemoveClick() - } + }, + modifier = Modifier.padding(horizontal = 8.dp) ) } @@ -399,5 +408,4 @@ fun RemoveConfirmationDialog( ), modifier = Modifier.fillMaxWidth(0.9f) ) -} - +} \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/AudioPlayerInstance.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/AudioPlayerInstance.kt index 458575c3..9ec300a3 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/AudioPlayerInstance.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/AudioPlayerInstance.kt @@ -64,10 +64,8 @@ class AudioPlayerInstance( @OptIn(UnstableApi::class) suspend fun initializePlayer(context: Context, uri: Uri, autoPlay: Boolean = false) { withContext(Dispatchers.Main) { - // Reset state before any operations resetPlayerState() - // Release previous player if it exists exoPlayer?.let { player -> player.stop() player.release() @@ -99,12 +97,11 @@ class AudioPlayerInstance( _playerState.update { it.copy( duration = if (duration != TIME_UNSET) duration else 0L, - currentPosition = 0L // Ensure position starts at 0 + currentPosition = 0L ) } } - // Handle automatic progression to next song if (playbackState == Player.STATE_ENDED) { handleSongEnded() } @@ -116,23 +113,17 @@ class AudioPlayerInstance( extractMetadata(context, uri) startPositionTracking() - // Auto-play if enabled in preferences or explicitly requested if (autoPlay || globalClass.preferencesManager.autoPlayMusic) { withContext(Dispatchers.Main) { - // Small delay to ensure player is ready delay(100) exoPlayer?.play() } } } - // Overloaded method for better metadata extraction from LocalFileHolder suspend fun initializePlayer(context: Context, uri: Uri, fileHolder: LocalFileHolder? = null, autoPlay: Boolean = false) { withContext(Dispatchers.Main) { - // Reset state before any operations resetPlayerState() - - // Release previous player if it exists exoPlayer?.let { player -> player.stop() player.release() @@ -169,7 +160,6 @@ class AudioPlayerInstance( } } - // Handle automatic progression to next song if (playbackState == Player.STATE_ENDED) { handleSongEnded() } @@ -181,7 +171,6 @@ class AudioPlayerInstance( extractMetadata(context, uri, fileHolder) startPositionTracking() - // Auto-play if enabled in preferences or explicitly requested if (autoPlay || globalClass.preferencesManager.autoPlayMusic) { withContext(Dispatchers.Main) { delay(100) @@ -199,7 +188,6 @@ class AudioPlayerInstance( try { val retriever = MediaMetadataRetriever() - // Try to use file path first if available, then URI if (fileHolder != null) { retriever.setDataSource(fileHolder.file.absolutePath) } else { @@ -245,7 +233,6 @@ class AudioPlayerInstance( retriever.release() } catch (e: Exception) { logger.logError(e) - // Fallback metadata using file name from LocalFileHolder or URI val fileName = fileHolder?.displayName ?: uri.lastPathSegment ?: "Unknown" val title = fileName.substringBeforeLast('.').ifEmpty { fileName } @@ -262,7 +249,6 @@ class AudioPlayerInstance( val currentState = _playlistState.value when (_playerState.value.repeatMode) { Player.REPEAT_MODE_ONE -> { - // Repeat current song exoPlayer?.seekTo(0) exoPlayer?.play() } @@ -270,7 +256,6 @@ class AudioPlayerInstance( if (currentState.hasNextSong()) { skipToNext() } else { - // Go back to first song if we've reached the end stopCurrentPlayer() _playlistState.update { it.copy(currentSongIndex = 0) } val firstSong = _playlistState.value.getCurrentSong() @@ -283,7 +268,6 @@ class AudioPlayerInstance( } } else -> { - // Play next song if available if (currentState.hasNextSong()) { skipToNext() } @@ -292,11 +276,8 @@ class AudioPlayerInstance( } private fun startPositionTracking() { - // Cancel any existing tracking job positionTrackingJob?.cancel() - var lastPosition = 0L - positionTrackingJob = CoroutineScope(Dispatchers.Main).launch { while (true) { exoPlayer?.let { player -> @@ -306,9 +287,7 @@ class AudioPlayerInstance( val currentPos = player.currentPosition.coerceAtLeast(0L) val currentDuration = if (player.duration != TIME_UNSET) player.duration else 0L - // If position jumped backwards significantly, it's likely a new song if (currentPos < lastPosition - 5000) { - // Reset position state for new song _playerState.update { it.copy( currentPosition = 0L, @@ -345,7 +324,6 @@ class AudioPlayerInstance( fun seekTo(position: Long) { exoPlayer?.let { player -> player.seekTo(position) - // Update state immediately to provide better user feedback _playerState.update { it.copy(currentPosition = position) } @@ -353,21 +331,17 @@ class AudioPlayerInstance( } fun skipNext() { - // If we have a playlist loaded, use playlist navigation if (_playlistState.value.currentPlaylist != null) { skipToNext() } else { - // Fallback to original ExoPlayer navigation exoPlayer?.seekToNext() } } fun skipPrevious() { - // If we have a playlist loaded, use playlist navigation if (_playlistState.value.currentPlaylist != null) { skipToPrevious() } else { - // Fallback to original ExoPlayer navigation exoPlayer?.seekToPrevious() } } @@ -399,11 +373,9 @@ class AudioPlayerInstance( _isVolumeVisible.value = !_isVolumeVisible.value } - // Playlist management methods fun loadPlaylist(playlist: Playlist, startIndex: Int = 0) { if (playlist.isEmpty()) return - // Stop current playback first stopCurrentPlayer() val shuffledIndices = if (_playlistState.value.isShuffled) { @@ -422,16 +394,13 @@ class AudioPlayerInstance( playlistManager.setCurrentPlaylist(playlist) - // Load the first song val songToPlay = _playlistState.value.getCurrentSong() songToPlay?.let { song -> CoroutineScope(Dispatchers.Main).launch { val songUri = Uri.fromFile(song.file) initializePlayer(globalClass, songUri, song) - // Auto-play if enabled in preferences if (globalClass.preferencesManager.autoPlayMusic) { - // Small delay to ensure player is ready delay(100) exoPlayer?.play() } @@ -443,7 +412,6 @@ class AudioPlayerInstance( val currentState = _playlistState.value currentState.currentPlaylist?.let { _ -> if (currentState.hasNextSong()) { - // Stop current playback first stopCurrentPlayer() val nextIndex = currentState.currentSongIndex + 1 @@ -453,7 +421,6 @@ class AudioPlayerInstance( nextSong?.let { song -> CoroutineScope(Dispatchers.Main).launch { val songUri = Uri.fromFile(song.file) - // Auto-play when skipping to next song initializePlayer(globalClass, songUri, song, autoPlay = true) } } @@ -465,7 +432,6 @@ class AudioPlayerInstance( val currentState = _playlistState.value currentState.currentPlaylist?.let { _ -> if (currentState.hasPreviousSong()) { - // Stop current playback first stopCurrentPlayer() val previousIndex = currentState.currentSongIndex - 1 @@ -475,7 +441,6 @@ class AudioPlayerInstance( previousSong?.let { song -> CoroutineScope(Dispatchers.Main).launch { val songUri = Uri.fromFile(song.file) - // Auto-play when skipping to previous song initializePlayer(globalClass, songUri, song, autoPlay = true) } } @@ -493,7 +458,6 @@ class AudioPlayerInstance( } if (actualIndex in 0 until playlist.songs.size) { - // Stop current playback first stopCurrentPlayer() _playlistState.update { it.copy(currentSongIndex = index) } @@ -501,7 +465,6 @@ class AudioPlayerInstance( val song = playlist.songs[actualIndex] CoroutineScope(Dispatchers.Main).launch { val songUri = Uri.fromFile(song.file) - // Auto-play when jumping to specific song initializePlayer(globalClass, songUri, song, autoPlay = true) } } @@ -522,7 +485,7 @@ class AudioPlayerInstance( it.copy( isShuffled = newShuffledState, shuffledIndices = shuffledIndices, - currentSongIndex = 0 // Reset to first song when toggling shuffle + currentSongIndex = 0 ) } } @@ -534,11 +497,9 @@ class AudioPlayerInstance( } private suspend fun resetPlayerState() { - // Cancel position tracking to prevent race conditions positionTrackingJob?.cancel() - // Reset player state immediately - _playerState.update { + _playerState.update { it.copy( currentPosition = 0L, duration = 0L, @@ -547,7 +508,6 @@ class AudioPlayerInstance( ) } - // Small delay to ensure UI processes the reset delay(50) } @@ -557,27 +517,21 @@ class AudioPlayerInstance( player.stop() } } - // Cancel position tracking to prevent updates from old player positionTrackingJob?.cancel() } override fun onClose() { - // Cancel position tracking positionTrackingJob?.cancel() - - // Release ExoPlayer resources exoPlayer?.let { player -> player.stop() player.release() } exoPlayer = null - // Reset player state - _playerState.update { + _playerState.update { PlayerState() } - // Clear playlist state clearPlaylist() } diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/PlaylistManager.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/PlaylistManager.kt index 78c1d8a5..d3328a01 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/PlaylistManager.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/PlaylistManager.kt @@ -145,15 +145,19 @@ class PlaylistManager private constructor() { } fun removeSongFromPlaylistAt(playlistId: String, index: Int) { - _playlists.value = _playlists.value.map { playlist -> - if (playlist.id == playlistId) { - playlist.copy().apply { removeSongAt(index) } + val updatedPlaylists = _playlists.value.map { playlist -> + if (playlist.id == playlistId && index >= 0 && index < playlist.songs.size) { + val newSongs = playlist.songs.toMutableList() + newSongs.removeAt(index) + playlist.copy(songs = newSongs) } else { playlist } } + _playlists.value = updatedPlaylists + if (_currentPlaylist.value?.id == playlistId) { - _currentPlaylist.value = _playlists.value.find { it.id == playlistId } + _currentPlaylist.value = updatedPlaylists.find { it.id == playlistId } } savePlaylists() } diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/ui/PlaylistDetailBottomSheet.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/ui/PlaylistDetailBottomSheet.kt index 681e83e0..cf54f170 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/ui/PlaylistDetailBottomSheet.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/ui/PlaylistDetailBottomSheet.kt @@ -31,6 +31,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -57,27 +58,32 @@ fun PlaylistDetailBottomSheet( ) { if (isVisible) { val playlistManager = remember { PlaylistManager.getInstance() } + val playlists by playlistManager.playlists.collectAsStateWithLifecycle(initialValue = emptyList()) val playlistState by audioPlayerInstance.playlistState.collectAsState() - val currentPlaylist = playlistState.currentPlaylist - val isCurrentPlaylist = currentPlaylist?.id == playlist.id + + val currentPlaylist = remember(playlists, playlist.id) { + playlists.find { it.id == playlist.id } ?: playlist + } + + val isCurrentPlaylist = playlistState.currentPlaylist?.id == currentPlaylist.id BottomSheetDialog(onDismissRequest = onDismiss) { Column( modifier = Modifier .fillMaxWidth() .padding(16.dp) ) { - PlaylistHeader(playlist, isCurrentPlaylist, audioPlayerInstance, playlistState, onDismiss) + PlaylistHeader(currentPlaylist, isCurrentPlaylist, audioPlayerInstance, playlistState, onDismiss) Spacer(modifier = Modifier.height(16.dp)) - if (playlist.songs.isNotEmpty()) { - PlayAllButton(playlist, audioPlayerInstance, onDismiss) + if (currentPlaylist.songs.isNotEmpty()) { + PlayAllButton(currentPlaylist, audioPlayerInstance, onDismiss) Spacer(modifier = Modifier.height(16.dp)) } - if (playlist.songs.isEmpty()) { + if (currentPlaylist.songs.isEmpty()) { EmptyPlaylistCard() } else { - PlaylistSongsList(playlist, playlistManager, onPlaySong, isCurrentPlaylist, playlistState) + PlaylistSongsList(currentPlaylist, playlistManager, onPlaySong, isCurrentPlaylist, playlistState) } } } @@ -232,7 +238,10 @@ fun PlaylistSongsList( playlistState: PlaylistState ) { LazyColumn { - itemsIndexed(playlist.songs) { index, song -> + itemsIndexed( + items = playlist.songs, + key = { index, song -> "${song.uid}_$index" } + ) { index, song -> PlaylistSongItem( song = song, index = index, @@ -241,7 +250,9 @@ fun PlaylistSongsList( onPlaySong(index) }, onRemoveClick = { - playlistManager.removeSongFromPlaylistAt(playlist.id, index) + if (index >= 0 && index < playlist.songs.size) { + playlistManager.removeSongFromPlaylistAt(playlist.id, index) + } } ) } @@ -280,7 +291,11 @@ fun PlaylistSongItem( Spacer(modifier = Modifier.width(12.dp)) - SongDetails(song = song, isCurrentSong = isCurrentSong) + SongDetails( + song = song, + isCurrentSong = isCurrentSong, + modifier = Modifier.weight(1f) + ) RemoveButton(onRemoveClick) } @@ -349,7 +364,10 @@ fun SongDetails(song: LocalFileHolder, isCurrentSong: Boolean, modifier: Modifie @Composable fun RemoveButton(onRemoveClick: () -> Unit) { - IconButton(onClick = onRemoveClick) { + IconButton( + onClick = onRemoveClick, + modifier = Modifier.size(40.dp) + ) { Icon( Icons.Default.Delete, contentDescription = stringResource(R.string.remove_from_playlist), diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 9f833fc8..40d728bc 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -404,8 +404,6 @@ Baixar nova atualização Usar visualizadores integrados Abrir automaticamente arquivos suportados com visualizadores integrados - - Playlists Criar playlist Adicionar à playlist @@ -438,11 +436,13 @@ Pausar Excluir playlist Deseja excluir esta playlist? + Editar playlist + Mais opçoes + Editar Voltar Gerenciar playlists Nenhuma playlist Música - Modo de exibição Configuração de Visualização Visualização em Grade diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4f3ccc6d..c1c4a563 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -404,7 +404,6 @@ Download new update Use built-in viewers Automatically open supported files with built-in viewers - Playlists Create playlist Add to playlist @@ -437,11 +436,13 @@ Pause Delete playlist Do you want to delete this playlist? + Edit playlist + More options + Edit Back Manage playlists No playlists Song - Display mode View Configuration Grid View