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" }