diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index a50645fa..59f242c5 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -49,6 +49,7 @@ android {
}
dependencies {
+ implementation(libs.androidx.compose.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)
+
+ // 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) }
var hasFolders = false
tab.selectedFiles.forEach {
@@ -267,6 +282,12 @@ fun FileOptionsMenuDialog(
tab.unselectAllFiles()
}
+ 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()
@@ -281,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)) {
CompressTask(targetFiles).let { task ->
@@ -307,6 +334,24 @@ fun FileOptionsMenuDialog(
}
}
}
+
+ // Playlist dialog for audio files
+ if (isAudioFile || hasAnyAudioFiles) {
+ PlaylistBottomSheet(
+ isVisible = showPlaylistDialog,
+ onDismiss = {
+ showPlaylistDialog = false
+ onDismissRequest()
+ },
+ onPlaylistSelected = { playlist ->
+ showPlaylistDialog = false
+ onDismissRequest()
+ tab.unselectAllFiles()
+ },
+ 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/main/tab/home/HomeTab.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/home/HomeTab.kt
index 5bfa4671..b380dc6f 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,11 +1,17 @@
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
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.InsertDriveFile
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
@@ -108,6 +114,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/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
new file mode 100644
index 00000000..5b476d44
--- /dev/null
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/playlist/ui/PlaylistDetailSheet.kt
@@ -0,0 +1,313 @@
+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
+import androidx.compose.foundation.layout.PaddingValues
+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.systemBarsPadding
+import androidx.compose.foundation.layout.width
+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.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+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.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
+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)
+@Composable
+fun PlaylistDetailSheet(
+ playlist: Playlist,
+ 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 }
+ }
+
+ var currentlyPlayingIndex by remember { mutableIntStateOf(-1) }
+
+ 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 = dismissWithAnimation,
+ properties = DialogProperties(
+ dismissOnBackPress = true,
+ dismissOnClickOutside = true,
+ usePlatformDefaultWidth = false
+ )
+ ) {
+ Surface(
+ 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,
+ tonalElevation = 2.dp
+ ) {
+ 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
+ )
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+private fun PlaylistDetailContent(
+ playlist: Playlist,
+ isScrolled: Boolean,
+ currentlyPlayingIndex: Int,
+ onPlayClick: (Int) -> Unit,
+ onDismiss: () -> Unit,
+ lazyListState: LazyListState
+) {
+ Box(modifier = Modifier.fillMaxSize()) {
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(top = 8.dp)
+ .systemBarsPadding()
+ ) {
+ HeaderBar(onDismiss = onDismiss)
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(horizontal = 20.dp)
+ ) {
+ PlaylistHeader(
+ playlist = playlist,
+ isScrolled = isScrolled,
+ onPlayAllClick = {
+ if (playlist.songs.isNotEmpty()) onPlayClick(0)
+ }
+ )
+
+ 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
new file mode 100644
index 00000000..812068c1
--- /dev/null
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/playlist/ui/PlaylistHeaderComponents.kt
@@ -0,0 +1,350 @@
+package com.raival.compose.file.explorer.screen.playlist.ui
+
+import androidx.compose.animation.AnimatedContent
+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.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.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
+@OptIn(ExperimentalAnimationApi::class)
+@Composable
+fun PlaylistHeader(
+ playlist: Playlist,
+ 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 = 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 = dividerThickness,
+ color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = dividerAlpha),
+ modifier = Modifier
+ .padding(horizontal = if (isScrolled) 0.dp else 8.dp)
+ .animateContentSize()
+ )
+ }
+}
+
+@Composable
+fun HeaderRow(
+ playlist: Playlist,
+ isScrolled: Boolean,
+ onPlayAllClick: () -> Unit
+) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .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 = playlist,
+ isScrolled = isScrolled,
+ modifier = Modifier.weight(1f)
+ )
+
+ Spacer(modifier = Modifier.width(16.dp))
+
+ if (playlist.songs.isNotEmpty()) {
+ PlayAllButton(onPlayAllClick)
+ }
+ }
+}
+
+@OptIn(ExperimentalAnimationApi::class)
+@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)) + slideInVertically(
+ initialOffsetY = { if (targetState) 20 else -20 },
+ animationSpec = tween(300, easing = EaseOutQuint)
+ ) togetherWith fadeOut(tween(150))
+ }
+ ) { scrolled ->
+ Column(
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text(
+ text = playlist.name,
+ style = if (scrolled) {
+ MaterialTheme.typography.titleMedium
+ } else {
+ MaterialTheme.typography.headlineSmall
+ },
+ fontWeight = FontWeight.Bold,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+
+ Spacer(modifier = Modifier.height(2.dp))
+
+ 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) {
+ 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(size)
+ .shadow(
+ elevation = 4.dp,
+ shape = RoundedCornerShape(cornerRadius),
+ clip = false
+ )
+ .clip(RoundedCornerShape(cornerRadius))
+ .background(
+ brush = Brush.linearGradient(
+ colors = listOf(
+ MaterialTheme.colorScheme.primary,
+ 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(iconSize)
+ .graphicsLayer {
+ this.scaleX = scale
+ this.scaleY = scale
+ }
+ )
+ }
+}
+
+@Composable
+fun PlayAllButton(onPlayAllClick: () -> Unit) {
+ 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),
+ 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
+ }
+ ) {
+ 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
new file mode 100644
index 00000000..4e96b7f7
--- /dev/null
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/playlist/ui/PlaylistManagerScreen.kt
@@ -0,0 +1,686 @@
+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.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
+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 showEditDialog by remember { mutableStateOf(null) }
+ 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) },
+ onEditClick = { showEditDialog = playlist },
+ onDeleteClick = { playlistManager.deletePlaylist(playlist.id) }
+ )
+ }
+ }
+ }
+ }
+ }
+
+ if (showCreateDialog) {
+ CreatePlaylistDialog(
+ onDismiss = { showCreateDialog = false },
+ onConfirm = { name ->
+ playlistManager.createPlaylist(name)
+ showCreateDialog = false
+ }
+ )
+ }
+
+ showEditDialog?.let { playlist ->
+ EditPlaylistDialog(
+ playlist = playlist,
+ onDismiss = { showEditDialog = null },
+ onConfirm = { newName ->
+ playlistManager.updatePlaylistName(playlist.id, newName)
+ showEditDialog = null
+ }
+ )
+ }
+
+ 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,
+ onEditClick: () -> Unit,
+ onDeleteClick: () -> Unit
+) {
+ var showDeleteConfirmation by remember { mutableStateOf(false) }
+ var showOptionsMenu 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)
+ ) {
+ 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)
+ )
+ }
+ }
+
+ 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
+ )
+ }
+ )
+ }
+ }
+ }
+ }
+ }
+
+ 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)
+ )
+}
+
+@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
new file mode 100644
index 00000000..8086c622
--- /dev/null
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/playlist/ui/SongItem.kt
@@ -0,0 +1,411 @@
+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.ProgressIndicatorDefaults
+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
+ }
+ )
+ ) {
+ SongContent(
+ song = song,
+ index = index,
+ isPlaying = isPlaying,
+ onPlayClick = onPlayClick,
+ onShowDropdownMenu = { showDropdownMenu = true },
+ showDropdownMenu = showDropdownMenu,
+ onDismissMenu = { showDropdownMenu = false },
+ onRemoveClick = { showRemoveConfirmation = true }
+ )
+ }
+
+ if (showRemoveConfirmation) {
+ RemoveConfirmationDialog(
+ songName = song.displayName,
+ onConfirm = {
+ onRemoveClick()
+ showRemoveConfirmation = false
+ },
+ onDismiss = { showRemoveConfirmation = false }
+ )
+ }
+}
+
+@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,
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Row(
+ modifier = Modifier.weight(1f),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ SongNumberIndicator(index = index, isPlaying = isPlaying)
+
+ Spacer(modifier = Modifier.width(16.dp))
+
+ SongDetails(song, Modifier.weight(1f))
+ }
+
+ 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) {
+ 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 {
+ AnimatedContent(
+ targetState = isPlaying,
+ label = "PlayButtonState",
+ transitionSpec = {
+ fadeIn(tween(300)) togetherWith fadeOut(tween(150))
+ }
+ ) { playing ->
+ PlayPauseButton(playing, onPlayClick)
+ }
+
+ 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,
+ modifier = Modifier
+ .background(MaterialTheme.colorScheme.surfaceVariant)
+ .padding(vertical = 4.dp)
+ ) {
+ RemoveFromPlaylistMenuItem(onDismissMenu, onRemoveClick)
+ }
+ }
+}
+
+@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(
+ 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,
+ modifier = Modifier.size(20.dp)
+ )
+ },
+ onClick = {
+ onDismissMenu()
+ onRemoveClick()
+ },
+ modifier = Modifier.padding(horizontal = 8.dp)
+ )
+}
+
+@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)
+ )
+}
\ 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 d72d91db..f9d08dfa 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
@@ -31,6 +31,7 @@ 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.AppInfoContainer
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
@@ -90,6 +91,7 @@ class PreferencesActivity : BaseActivity() {
FileListContainer()
FileOperationContainer()
BehaviorContainer()
+ AudioPlayerContainer()
RecentFilesContainer()
TextEditorContainer()
AppInfoContainer()
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/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 }
+ )
+ }
+}
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..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(
@@ -16,10 +18,28 @@ 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) {
+ 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 a6264f2c..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
@@ -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,25 @@ 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) {
+ suspend fun initializePlayer(context: Context, uri: Uri, autoPlay: Boolean = false) {
withContext(Dispatchers.Main) {
+ resetPlayerState()
+
+ exoPlayer?.let { player ->
+ player.stop()
+ player.release()
+ }
+
exoPlayer = ExoPlayer.Builder(context).build().apply {
val mediaItem = MediaItem.Builder()
.setUri(uri)
@@ -80,10 +96,15 @@ class AudioPlayerInstance(
if (playbackState == Player.STATE_READY) {
_playerState.update {
it.copy(
- duration = duration
+ duration = if (duration != TIME_UNSET) duration else 0L,
+ currentPosition = 0L
)
}
}
+
+ if (playbackState == Player.STATE_ENDED) {
+ handleSongEnded()
+ }
}
})
}
@@ -91,26 +112,100 @@ class AudioPlayerInstance(
extractMetadata(context, uri)
startPositionTracking()
+
+ if (autoPlay || globalClass.preferencesManager.autoPlayMusic) {
+ withContext(Dispatchers.Main) {
+ delay(100)
+ exoPlayer?.play()
+ }
+ }
+ }
+
+ suspend fun initializePlayer(context: Context, uri: Uri, fileHolder: LocalFileHolder? = null, autoPlay: Boolean = false) {
+ withContext(Dispatchers.Main) {
+ resetPlayerState()
+ exoPlayer?.let { player ->
+ player.stop()
+ player.release()
+ }
+
+ 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 = if (duration != TIME_UNSET) duration else 0L,
+ currentPosition = 0L
+ )
+ }
+ }
+
+ if (playbackState == Player.STATE_ENDED) {
+ handleSongEnded()
+ }
+ }
+ })
+ }
+ }
+
+ extractMetadata(context, uri, fileHolder)
+ startPositionTracking()
+
+ if (autoPlay || globalClass.preferencesManager.autoPlayMusic) {
+ withContext(Dispatchers.Main) {
+ delay(100)
+ exoPlayer?.play()
+ }
+ }
}
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)
+
+ 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,24 +233,77 @@ class AudioPlayerInstance(
retriever.release()
} catch (e: Exception) {
logger.logError(e)
- // Fallback metadata
+ val fileName = fileHolder?.displayName ?: uri.lastPathSegment ?: "Unknown"
+ 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 -> {
+ exoPlayer?.seekTo(0)
+ exoPlayer?.play()
+ }
+ Player.REPEAT_MODE_ALL -> {
+ if (currentState.hasNextSong()) {
+ skipToNext()
+ } else {
+ 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 -> {
+ if (currentState.hasNextSong()) {
+ skipToNext()
+ }
+ }
+ }
+ }
+
private fun startPositionTracking() {
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 (currentPos < lastPosition - 5000) {
+ _playerState.update {
+ it.copy(
+ currentPosition = 0L,
+ duration = currentDuration
+ )
+ }
+ } else {
+ _playerState.update {
+ it.copy(
+ currentPosition = currentPos,
+ duration = currentDuration
+ )
+ }
+ }
+
+ lastPosition = currentPos
}
}
delay(100)
@@ -174,15 +322,28 @@ class AudioPlayerInstance(
}
fun seekTo(position: Long) {
- exoPlayer?.seekTo(position)
+ exoPlayer?.let { player ->
+ player.seekTo(position)
+ _playerState.update {
+ it.copy(currentPosition = position)
+ }
+ }
}
fun skipNext() {
- exoPlayer?.seekToNext()
+ if (_playlistState.value.currentPlaylist != null) {
+ skipToNext()
+ } else {
+ exoPlayer?.seekToNext()
+ }
}
fun skipPrevious() {
- exoPlayer?.seekToPrevious()
+ if (_playlistState.value.currentPlaylist != null) {
+ skipToPrevious()
+ } else {
+ exoPlayer?.seekToPrevious()
+ }
}
fun setPlaybackSpeed(speed: Float) {
@@ -212,9 +373,169 @@ class AudioPlayerInstance(
_isVolumeVisible.value = !_isVolumeVisible.value
}
+ fun loadPlaylist(playlist: Playlist, startIndex: Int = 0) {
+ if (playlist.isEmpty()) return
+
+ 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)
+
+ val songToPlay = _playlistState.value.getCurrentSong()
+ songToPlay?.let { song ->
+ CoroutineScope(Dispatchers.Main).launch {
+ val songUri = Uri.fromFile(song.file)
+ initializePlayer(globalClass, songUri, song)
+
+ if (globalClass.preferencesManager.autoPlayMusic) {
+ delay(100)
+ exoPlayer?.play()
+ }
+ }
+ }
+ }
+
+ fun skipToNext() {
+ val currentState = _playlistState.value
+ currentState.currentPlaylist?.let { _ ->
+ if (currentState.hasNextSong()) {
+ 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, autoPlay = true)
+ }
+ }
+ }
+ }
+ }
+
+ fun skipToPrevious() {
+ val currentState = _playlistState.value
+ currentState.currentPlaylist?.let { _ ->
+ if (currentState.hasPreviousSong()) {
+ 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, autoPlay = true)
+ }
+ }
+ }
+ }
+ }
+
+ 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) {
+ 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, autoPlay = true)
+ }
+ }
+ }
+ }
+
+ 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
+ )
+ }
+ }
+ }
+
+ fun clearPlaylist() {
+ _playlistState.update { PlaylistState() }
+ playlistManager.clearCurrentPlaylist()
+ }
+
+ private suspend fun resetPlayerState() {
+ positionTrackingJob?.cancel()
+
+ _playerState.update {
+ it.copy(
+ currentPosition = 0L,
+ duration = 0L,
+ isLoading = true,
+ isPlaying = false
+ )
+ }
+
+ delay(50)
+ }
+
+ private fun stopCurrentPlayer() {
+ exoPlayer?.let { player ->
+ if (player.isPlaying) {
+ player.stop()
+ }
+ }
+ positionTrackingJob?.cancel()
+ }
+
override fun onClose() {
positionTrackingJob?.cancel()
- exoPlayer?.release()
+ exoPlayer?.let { player ->
+ player.stop()
+ player.release()
+ }
exoPlayer = null
+
+ _playerState.update {
+ PlayerState()
+ }
+
+ 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..d3328a01
--- /dev/null
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/PlaylistManager.kt
@@ -0,0 +1,203 @@
+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 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
+ }
+
+ fun createPlaylistWithSong(name: String, song: LocalFileHolder): Playlist {
+ val playlist = Playlist(name = name)
+ playlist.addSong(song)
+ _playlists.value = _playlists.value + playlist
+ savePlaylists()
+ return playlist
+ }
+
+ fun addSongToPlaylist(playlistId: String, song: LocalFileHolder): Boolean {
+ var wasAdded = false
+ _playlists.value = _playlists.value.map { playlist ->
+ if (playlist.id == playlistId) {
+ playlist.copy().apply { wasAdded = addSong(song) }
+ } else {
+ playlist
+ }
+ }
+ if (_currentPlaylist.value?.id == playlistId) {
+ _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) {
+ 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 = updatedPlaylists.find { it.id == playlistId }
+ }
+ savePlaylists()
+ }
+
+ fun deletePlaylist(playlistId: String) {
+ _playlists.value = _playlists.value.filter { it.id != playlistId }
+ if (_currentPlaylist.value?.id == playlistId) {
+ _currentPlaylist.value = null
+ }
+ savePlaylists()
+ }
+
+ 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
+ }
+ }
+ if (_currentPlaylist.value?.id == playlistId) {
+ _currentPlaylist.value = _playlists.value.find { it.id == playlistId }
+ }
+ savePlaylists()
+ }
+}
\ No newline at end of file
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..037e53b4
--- /dev/null
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/model/Playlist.kt
@@ -0,0 +1,40 @@
+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): Boolean {
+ return if (!songs.contains(song)) {
+ songs.add(song)
+ true
+ } else {
+ false
+ }
+ }
+
+ 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/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
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..3a89e936
--- /dev/null
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/model/PlaylistState.kt
@@ -0,0 +1,27 @@
+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 teste: Boolean = true,
+ 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..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
@@ -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,11 @@ 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 +161,7 @@ fun MusicPlayerScreen(
TopControls(
onEqualizerClick = { audioPlayerInstance.toggleEqualizer() },
onVolumeClick = { audioPlayerInstance.toggleVolume() },
+ onPlaylistClick = { showPlaylistDialog = true },
onCloseClick = onClosed,
audioPlayerColorScheme = customColorScheme
)
@@ -176,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))
@@ -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 = { _ -> },
+ 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,
@@ -404,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 {
@@ -444,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
@@ -827,4 +894,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} ${playlist.size()}${if (isShuffled) " • ${stringResource(R.string.random)}" 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..0d3fd213
--- /dev/null
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/ui/PlaylistBottomSheet.kt
@@ -0,0 +1,305 @@
+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.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
+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,
+ 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(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp)
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = if (isMultipleSongs) {
+ "${stringResource(R.string.add_multiple_to_playlist)} (${songsToAdd.size})"
+ } else {
+ 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 = {
+ 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)
+ },
+ onDeleteClick = {
+ playlistManager.deletePlaylist(playlist.id)
+ }
+ )
+ }
+ }
+ }
+ }
+ }
+
+ if (showCreateDialog) {
+ CreatePlaylistDialog(
+ onDismiss = { showCreateDialog = false },
+ onPlaylistCreated = { name ->
+ 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)
+ }
+ 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..cf54f170
--- /dev/null
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/ui/PlaylistDetailBottomSheet.kt
@@ -0,0 +1,378 @@
+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.lifecycle.compose.collectAsStateWithLifecycle
+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
+import com.raival.compose.file.explorer.screen.viewer.audio.model.PlaylistState
+
+@Composable
+fun PlaylistDetailBottomSheet(
+ isVisible: Boolean,
+ playlist: Playlist,
+ onDismiss: () -> Unit,
+ onPlaySong: (Int) -> Unit,
+ audioPlayerInstance: AudioPlayerInstance
+) {
+ if (isVisible) {
+ val playlistManager = remember { PlaylistManager.getInstance() }
+ val playlists by playlistManager.playlists.collectAsStateWithLifecycle(initialValue = emptyList())
+ val playlistState by audioPlayerInstance.playlistState.collectAsState()
+
+ 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(currentPlaylist, isCurrentPlaylist, audioPlayerInstance, playlistState, onDismiss)
+ Spacer(modifier = Modifier.height(16.dp))
+
+ if (currentPlaylist.songs.isNotEmpty()) {
+ PlayAllButton(currentPlaylist, audioPlayerInstance, onDismiss)
+ Spacer(modifier = Modifier.height(16.dp))
+ }
+
+ if (currentPlaylist.songs.isEmpty()) {
+ EmptyPlaylistCard()
+ } else {
+ PlaylistSongsList(currentPlaylist, 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(
+ items = playlist.songs,
+ key = { index, song -> "${song.uid}_$index" }
+ ) { index, song ->
+ PlaylistSongItem(
+ song = song,
+ index = index,
+ isCurrentSong = isCurrentPlaylist && playlistState.currentSongIndex == index,
+ onSongClick = {
+ onPlaySong(index)
+ },
+ onRemoveClick = {
+ if (index >= 0 && index < playlist.songs.size) {
+ 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
+ ) {
+ SongIndicator(isCurrentSong = isCurrentSong, index = index)
+
+ Spacer(modifier = Modifier.width(12.dp))
+
+ SongDetails(
+ song = song,
+ isCurrentSong = isCurrentSong,
+ modifier = Modifier.weight(1f)
+ )
+
+ 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,
+ modifier = Modifier.size(40.dp)
+ ) {
+ 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 ce127e9e..40d728bc 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
@@ -402,6 +404,45 @@
Baixar nova atualização
Usar visualizadores integrados
Abrir automaticamente arquivos suportados com visualizadores integrados
+ 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
+ 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?
+ 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 3f7a0257..c1c4a563 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
@@ -402,6 +404,45 @@
Download new update
Use built-in viewers
Automatically open supported files with built-in viewers
+ 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
+ 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?
+ Edit playlist
+ More options
+ Edit
+ Back
+ Manage playlists
+ No playlists
+ Song
Display mode
View Configuration
Grid View
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 09a75f43..b01837a7 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -35,6 +35,7 @@ profileinstaller = "1.4.1"
reorderable = "2.5.1"
uiToolingPreviewAndroid = "1.9.0"
zoomableImageCoil3 = "0.16.0"
+material3 = "1.3.2"
[libraries]
accompanist-systemuicontroller = { module = "com.google.accompanist:accompanist-systemuicontroller", version.ref = "accompanistSystemuicontroller" }