From 2eb6e2fe8d00b2df9b60cd997bcc27a06402a091 Mon Sep 17 00:00:00 2001 From: Ericgacoki Date: Sun, 21 Dec 2025 19:29:12 +0300 Subject: [PATCH 01/14] feat: Implement animated player bottom sheet This introduces a new, animated bottom sheet player that replaces the previous mini-player and `NowPlayingScreen`. - **`AnimatedPlayerSheet.kt`**: A new composable for the player UI, which transitions smoothly from a mini-player to a full-screen player with animations for the artwork, controls, and background. - **Queue Sheet**: A secondary, draggable sheet for the track queue, accessible from the expanded player. - **`MainActivityWithAnimatedPlayer.kt`**: A new entry point activity that integrates the `AnimatedPlayerSheet`. The bottom navigation bar now animates out of view as the player sheet expands. - **Updated `AndroidManifest.xml`**: Sets `MainActivityWithAnimatedPlayer` as the launcher activity. - **Horizontal Swiping**: The collapsed mini-player now supports horizontal swiping to skip to the next or previous track. --- app/src/main/AndroidManifest.xml | 5 +- .../MainActivityWithAnimatedPlayer.kt | 410 ++++++ .../artist/presentation/screen/AllArtists.kt | 1 - .../presentation/screen/FoldersAndTracks.kt | 203 +-- .../screen/AnimatedPlayerSheet.kt | 1293 +++++++++++++++++ 5 files changed, 1812 insertions(+), 100 deletions(-) create mode 100644 app/src/main/java/com/android/swingmusic/presentation/activity/MainActivityWithAnimatedPlayer.kt create mode 100644 feature/player/src/main/java/com/android/swingmusic/player/presentation/screen/AnimatedPlayerSheet.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 35d16cdb..6cbb0a60 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -17,7 +17,6 @@ android:fullBackupContent="@xml/backup_rules" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" - android:resizeableActivity="false" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/Theme.SwingMusic" @@ -34,10 +33,8 @@ diff --git a/app/src/main/java/com/android/swingmusic/presentation/activity/MainActivityWithAnimatedPlayer.kt b/app/src/main/java/com/android/swingmusic/presentation/activity/MainActivityWithAnimatedPlayer.kt new file mode 100644 index 00000000..efcaae05 --- /dev/null +++ b/app/src/main/java/com/android/swingmusic/presentation/activity/MainActivityWithAnimatedPlayer.kt @@ -0,0 +1,410 @@ +package com.android.swingmusic.presentation.activity + +import android.annotation.SuppressLint +import android.content.ComponentName +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.viewModels +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +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.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.lifecycle.lifecycleScope +import androidx.media3.session.MediaController +import androidx.media3.session.SessionToken +import androidx.navigation.NavHostController +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController +import com.android.swingmusic.album.presentation.screen.destinations.AlbumWithInfoScreenDestination +import com.android.swingmusic.album.presentation.screen.destinations.AllAlbumScreenDestination +import com.android.swingmusic.artist.presentation.screen.destinations.AllArtistsScreenDestination +import com.android.swingmusic.artist.presentation.screen.destinations.ArtistInfoScreenDestination +import com.android.swingmusic.artist.presentation.screen.destinations.ViewAllScreenOnArtistDestination +import com.android.swingmusic.artist.presentation.viewmodel.ArtistInfoViewModel +import com.android.swingmusic.auth.data.workmanager.scheduleTokenRefreshWork +import com.android.swingmusic.auth.presentation.screen.destinations.LoginWithQrCodeDestination +import com.android.swingmusic.auth.presentation.screen.destinations.LoginWithUsernameScreenDestination +import com.android.swingmusic.auth.presentation.viewmodel.AuthViewModel +import com.android.swingmusic.folder.presentation.event.FolderUiEvent +import com.android.swingmusic.folder.presentation.screen.destinations.FoldersAndTracksScreenDestination +import com.android.swingmusic.folder.presentation.viewmodel.FoldersViewModel +import com.android.swingmusic.player.presentation.screen.AnimatedPlayerSheet +import com.android.swingmusic.player.presentation.viewmodel.MediaControllerViewModel +import com.android.swingmusic.presentation.navigator.BottomNavItem +import com.android.swingmusic.presentation.navigator.CoreNavigator +import com.android.swingmusic.presentation.navigator.NavGraphs +import com.android.swingmusic.presentation.navigator.scaleInEnterTransition +import com.android.swingmusic.presentation.navigator.scaleInPopEnterTransition +import com.android.swingmusic.presentation.navigator.scaleOutExitTransition +import com.android.swingmusic.presentation.navigator.scaleOutPopExitTransition +import com.android.swingmusic.search.presentation.event.SearchUiEvent +import com.android.swingmusic.search.presentation.screen.destinations.SearchScreenDestination +import com.android.swingmusic.search.presentation.screen.destinations.ViewAllSearchResultsDestination +import com.android.swingmusic.search.presentation.viewmodel.SearchViewModel +import com.android.swingmusic.service.PlaybackService +import com.android.swingmusic.service.SessionTokenManager +import com.android.swingmusic.uicomponent.presentation.theme.SwingMusicTheme +import com.google.accompanist.navigation.material.ExperimentalMaterialNavigationApi +import com.google.common.util.concurrent.ListenableFuture +import com.google.common.util.concurrent.MoreExecutors +import com.ramcosta.composedestinations.DestinationsNavHost +import com.ramcosta.composedestinations.animations.defaults.NestedNavGraphDefaultAnimations +import com.ramcosta.composedestinations.animations.defaults.RootNavGraphDefaultAnimations +import com.ramcosta.composedestinations.animations.rememberAnimatedNavHostEngine +import com.ramcosta.composedestinations.navigation.dependency +import android.Manifest +import android.os.Build +import androidx.activity.enableEdgeToEdge +import com.android.swingmusic.BuildConfig +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import kotlin.math.roundToInt + +/** + * MainActivityWithAnimatedPlayer - Alternative MainActivity with animated bottom sheet player. + * + * This activity replaces the traditional MiniPlayer + navigation-based NowPlaying approach + * with a continuous drag-animated bottom sheet that transforms from mini player to full player. + * + * Key differences from MainActivity: + * - Uses AnimatedPlayerSheet instead of MiniPlayer + * - Navigation bar animates based on sheet expansion progress + * - No navigation to NowPlayingScreen - player is always a sheet overlay + * + * To use this activity: + * 1. Update AndroidManifest.xml to set this as the launcher activity + * 2. Or change the activity reference in your launch intent + */ +@AndroidEntryPoint +class MainActivityWithAnimatedPlayer : ComponentActivity() { + private val mediaControllerViewModel: MediaControllerViewModel by viewModels() + private val authViewModel: AuthViewModel by viewModels() + private val foldersViewModel: FoldersViewModel by viewModels() + private val artistInfoViewModel: ArtistInfoViewModel by viewModels() + private val searchViewModel: SearchViewModel by viewModels() + + private lateinit var controllerFuture: ListenableFuture + + override fun onStart() { + super.onStart() + + lifecycleScope.launch { + authViewModel.isUserLoggedIn.collectLatest { + if (it == true) { + initializeMediaController() + } + } + } + } + + @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") + @OptIn(ExperimentalAnimationApi::class) + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + if (BuildConfig.DEBUG && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), 0) + } + + scheduleTokenRefreshWork(applicationContext) + + // enableEdgeToEdge() + + setContent { + val isUserLoggedIn by authViewModel.isUserLoggedIn.collectAsState() + + val playerState = mediaControllerViewModel.playerUiState.collectAsState() + + val navController = rememberNavController() + val newBackStackEntry by navController.currentBackStackEntryAsState() + val route = newBackStackEntry?.destination?.route + + // Destinations where bottom nav should be hidden (check by route string) + val hideForRoutes = listOf( + LoginWithUsernameScreenDestination.route, + LoginWithQrCodeDestination.route + ) + + val showBottomNav = route != null && hideForRoutes.none { route.startsWith(it) } + + val bottomNavItems: List = listOf( + // BottomNavItem.Home, + BottomNavItem.Folder, + BottomNavItem.Album, + // BottomNavItem.Playlist, + BottomNavItem.Artist, + BottomNavItem.Search, + ) + + // Map of BottomNavItem to their route prefixes + val bottomNavRoutePrefixes = mapOf( + // BottomNavItem.Home to listOf(HomeDestination.route), + BottomNavItem.Folder to listOf(FoldersAndTracksScreenDestination.route), + BottomNavItem.Album to listOf( + AllAlbumScreenDestination.route, + AlbumWithInfoScreenDestination.route + ), + BottomNavItem.Artist to listOf( + AllArtistsScreenDestination.route, + ArtistInfoScreenDestination.route, + ViewAllScreenOnArtistDestination.route + ), + BottomNavItem.Search to listOf( + SearchScreenDestination.route, + ViewAllSearchResultsDestination.route + ) + ) + + // Track sheet progress for nav bar animation + var sheetProgress by remember { mutableFloatStateOf(0f) } + + // Calculate nav bar animation values + val navBarSlideProgress = (sheetProgress / 0.2f).coerceIn(0f, 1f) + val navBarAlpha = 1f - navBarSlideProgress + + SwingMusicTheme { + Scaffold( + modifier = Modifier.fillMaxSize(), + bottomBar = { + // Only show navigation bar when logged in and not on auth screens + if (showBottomNav) { + val density = LocalDensity.current + val navBarHeightPx = with(density) { 80.dp.toPx() } + + NavigationBar( + modifier = Modifier + .fillMaxWidth() + .offset { + IntOffset( + 0, + (navBarHeightPx * navBarSlideProgress).roundToInt() + ) + } + .alpha(navBarAlpha), + containerColor = MaterialTheme.colorScheme.inverseOnSurface + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center + ) { + bottomNavItems.forEach { item -> + NavigationBarItem( + icon = { + Icon( + painter = painterResource(id = item.icon), + contentDescription = null + ) + }, + selected = navController.currentDestination?.route?.let { route -> + bottomNavRoutePrefixes[item]?.any { prefix -> + route.startsWith(prefix) + } == true + } == true, + alwaysShowLabel = false, + label = { Text(text = item.title) }, + onClick = { + // Whatever you do, DON'T TOUCH this + if (navController.currentDestination?.route != item.destination.route) { + navController.navigate(item.destination.route) { + launchSingleTop = true + restoreState = false + + popUpTo(navController.graph.startDestinationId) { + saveState = false + inclusive = false + } + } + } + + // refresh folders starting from $home + if (item.destination.route == FoldersAndTracksScreenDestination.route) { + foldersViewModel.onFolderUiEvent( + FolderUiEvent.OnClickNavPath( + folder = foldersViewModel.homeDir + ) + ) + } + + // refresh Search screen + if (item.destination.route == SearchScreenDestination.route) { + searchViewModel.onSearchUiEvent( + SearchUiEvent.OnClearSearchStates + ) + } + } + ) + } + } + } + } + } + ) { paddingValues -> + Surface(modifier = Modifier.fillMaxSize()) { + AnimatedVisibility( + visible = isUserLoggedIn == null, + enter = fadeIn(animationSpec = tween(durationMillis = 100)) + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + + isUserLoggedIn?.let { value -> + // Always use AnimatedPlayerSheet - it handles "no track" case internally + AnimatedPlayerSheet( + paddingValues = paddingValues, + mediaControllerViewModel = mediaControllerViewModel, + onProgressChange = { progress -> + sheetProgress = progress + }, + onClickArtist = { artistHash -> + navController.navigate( + ArtistInfoScreenDestination( + artistHash = artistHash, + loadNewArtist = true + ).route + ) { + launchSingleTop = true + } + } + ) { + SwingMusicAppNavigationWithAnimatedPlayer( + isUserLoggedIn = value, + navController = navController, + authViewModel = authViewModel, + mediaControllerViewModel = mediaControllerViewModel, + foldersViewModel = foldersViewModel, + artistInfoViewModel = artistInfoViewModel, + searchViewModel = searchViewModel + ) + } + } + } + } + } + } + } + + private fun initializeMediaController() { + if ( + mediaControllerViewModel.getMediaController() == null || + (this::controllerFuture.isInitialized).not() + ) { + val sessionToken = SessionTokenManager.sessionToken + if (sessionToken != null) { + // Use the existing session token to build the MediaController + controllerFuture = MediaController.Builder(this, sessionToken).buildAsync() + controllerFuture.addListener( + { + val mediaController = controllerFuture.get() + mediaControllerViewModel.reconnectMediaController(mediaController) + }, MoreExecutors.directExecutor() + ) + } else { + // Create a new session token if no existing token is found + val newSessionToken = + SessionToken(this, ComponentName(this, PlaybackService::class.java)) + + controllerFuture = MediaController.Builder(this, newSessionToken).buildAsync() + controllerFuture.addListener( + { + val mediaController = controllerFuture.get() + mediaControllerViewModel.setMediaController(mediaController) + }, MoreExecutors.directExecutor() + ) + } + } + } + + override fun onDestroy() { + super.onDestroy() + if (this::controllerFuture.isInitialized) { + mediaControllerViewModel.releaseMediaController(controllerFuture) + } + } +} + +@OptIn(ExperimentalMaterialNavigationApi::class) +@ExperimentalAnimationApi +@Composable +internal fun SwingMusicAppNavigationWithAnimatedPlayer( + isUserLoggedIn: Boolean, + navController: NavHostController, + authViewModel: AuthViewModel, + mediaControllerViewModel: MediaControllerViewModel, + foldersViewModel: FoldersViewModel, + artistInfoViewModel: ArtistInfoViewModel, + searchViewModel: SearchViewModel +) { + val navGraph = remember(isUserLoggedIn) { NavGraphs.root(isUserLoggedIn) } + + val animatedNavHostEngine = rememberAnimatedNavHostEngine( + navHostContentAlignment = Alignment.TopCenter, + rootDefaultAnimations = RootNavGraphDefaultAnimations.ACCOMPANIST_FADING, + defaultAnimationsForNestedNavGraph = mapOf( + NavGraphs.root(isUserLoggedIn) to NestedNavGraphDefaultAnimations( + enterTransition = { + scaleInEnterTransition() + }, + exitTransition = { + scaleOutExitTransition() + }, + popEnterTransition = { + scaleInPopEnterTransition() + }, + popExitTransition = { + scaleOutPopExitTransition() + } + ) + ) + ) + + DestinationsNavHost( + engine = animatedNavHostEngine, + navController = navController, + navGraph = navGraph, + dependenciesContainerBuilder = { + dependency( + CoreNavigator(navController = navController) + ) + dependency(authViewModel) + dependency(foldersViewModel) + dependency(mediaControllerViewModel) + dependency(artistInfoViewModel) + dependency(searchViewModel) + } + ) +} diff --git a/feature/artist/src/main/java/com/android/swingmusic/artist/presentation/screen/AllArtists.kt b/feature/artist/src/main/java/com/android/swingmusic/artist/presentation/screen/AllArtists.kt index d36fe832..693a9bd7 100644 --- a/feature/artist/src/main/java/com/android/swingmusic/artist/presentation/screen/AllArtists.kt +++ b/feature/artist/src/main/java/com/android/swingmusic/artist/presentation/screen/AllArtists.kt @@ -103,7 +103,6 @@ private fun AllArtists( var isGridCountMenuExpanded by remember { mutableStateOf(false) } - // TODO: Add pinch to reduce Grid count... as seen in Google Photos Scaffold { Scaffold( modifier = Modifier.padding(it), diff --git a/feature/folder/src/main/java/com/android/swingmusic/folder/presentation/screen/FoldersAndTracks.kt b/feature/folder/src/main/java/com/android/swingmusic/folder/presentation/screen/FoldersAndTracks.kt index 70657901..d6e92a9d 100644 --- a/feature/folder/src/main/java/com/android/swingmusic/folder/presentation/screen/FoldersAndTracks.kt +++ b/feature/folder/src/main/java/com/android/swingmusic/folder/presentation/screen/FoldersAndTracks.kt @@ -57,8 +57,8 @@ import com.android.swingmusic.core.domain.util.BottomSheetAction import com.android.swingmusic.core.domain.util.PlaybackState import com.android.swingmusic.core.domain.util.QueueSource import com.android.swingmusic.folder.presentation.event.FolderUiEvent -import com.android.swingmusic.folder.presentation.state.FoldersContentPagingState import com.android.swingmusic.folder.presentation.model.FolderContentItem +import com.android.swingmusic.folder.presentation.state.FoldersContentPagingState import com.android.swingmusic.folder.presentation.viewmodel.FoldersViewModel import com.android.swingmusic.player.presentation.event.QueueEvent import com.android.swingmusic.player.presentation.viewmodel.MediaControllerViewModel @@ -67,6 +67,7 @@ import com.android.swingmusic.uicomponent.presentation.component.CustomTrackBott import com.android.swingmusic.uicomponent.presentation.component.FolderItem import com.android.swingmusic.uicomponent.presentation.component.PathIndicatorItem import com.android.swingmusic.uicomponent.presentation.component.TrackItem +import com.android.swingmusic.uicomponent.presentation.theme.SwingMusicTheme import com.ramcosta.composedestinations.annotation.Destination import java.util.Locale @@ -97,7 +98,7 @@ private fun FoldersAndTracks( var clickedTrack: Track? by remember { mutableStateOf(null) } val pagingContent = foldersContentPagingState.pagingContent.collectAsLazyPagingItems() - + // Track state updates and reset manual refreshing LaunchedEffect(pagingContent.loadState, pagingContent.itemCount) { // Reset manual refreshing when content loads successfully @@ -109,14 +110,17 @@ private fun FoldersAndTracks( onManualRefreshingChange(false) } } + is LoadState.Error -> { // Error occurred, stop refreshing onManualRefreshingChange(false) } - else -> { /* Keep refreshing */ } + + else -> { /* Keep refreshing */ + } } } - + // Reset bottom sheet if content is refreshed if (pagingContent.loadState.refresh is LoadState.NotLoading) { if (clickedTrack != null) { @@ -288,7 +292,7 @@ private fun FoldersAndTracks( key = { index -> index } ) { index -> val contentItem = pagingContent[index] ?: return@items - + when (contentItem) { is FolderContentItem.FolderItem -> { FolderItem( @@ -299,6 +303,7 @@ private fun FoldersAndTracks( onClickMoreVert = {} ) } + is FolderContentItem.TrackItem -> { val track = contentItem.track TrackItem( @@ -310,8 +315,8 @@ private fun FoldersAndTracks( onClickTrackItem = { // Get all tracks for queue creation val allTracks = (0 until pagingContent.itemCount) - .mapNotNull { i -> - (pagingContent[i] as? FolderContentItem.TrackItem)?.track + .mapNotNull { i -> + (pagingContent[i] as? FolderContentItem.TrackItem)?.track } val trackIndex = allTracks.indexOf(track) onClickTrackItem(trackIndex, allTracks) @@ -324,7 +329,7 @@ private fun FoldersAndTracks( } } } - + // Add bottom spacing item { Spacer(modifier = Modifier.height(200.dp)) @@ -344,6 +349,7 @@ private fun FoldersAndTracks( } } } + is LoadState.Error -> { item { Box( @@ -354,7 +360,8 @@ private fun FoldersAndTracks( ) { Column(horizontalAlignment = Alignment.CenterHorizontally) { Text( - text = appendState.error.message ?: "Error loading more content", + text = appendState.error.message + ?: "Error loading more content", textAlign = TextAlign.Center, style = MaterialTheme.typography.bodyMedium ) @@ -366,6 +373,7 @@ private fun FoldersAndTracks( } } } + else -> {} } @@ -384,6 +392,7 @@ private fun FoldersAndTracks( } } } + is LoadState.Error -> { if (isManualRefreshing) { onManualRefreshingChange(false) @@ -423,11 +432,13 @@ private fun FoldersAndTracks( } } } + is LoadState.NotLoading -> { if (isManualRefreshing) { onManualRefreshingChange(false) } } + else -> {} } @@ -492,10 +503,10 @@ fun FoldersAndTracksScreen( val playerUiState by mediaControllerViewModel.playerUiState.collectAsState() val baseUrl by mediaControllerViewModel.baseUrl.collectAsState() - + val currentTrackHash = playerUiState.nowPlayingTrack?.trackHash ?: "" val playbackState = playerUiState.playbackState - + var isManualRefreshing by remember { mutableStateOf(false) } var routeByGotoFolder by remember { mutableStateOf(false) } var hasInitializedWithBaseUrl by remember { mutableStateOf(false) } @@ -504,17 +515,17 @@ fun FoldersAndTracksScreen( LaunchedEffect(Unit) { mediaControllerViewModel.refreshBaseUrl() } - + LaunchedEffect(baseUrl) { if (baseUrl != null && !hasInitializedWithBaseUrl) { // Trigger root directories fetch now that base URL is available foldersViewModel.fetchRootDirectoriesWhenReady() - + // Only reload content if we're at home directory AND not in a "go to folder" scenario if (currentFolder.path == "\$home" && gotoFolderName == null && gotoFolderPath == null) { foldersViewModel.onFolderUiEvent(FolderUiEvent.OnClickFolder(currentFolder)) } - + hasInitializedWithBaseUrl = true } } @@ -553,94 +564,96 @@ fun FoldersAndTracksScreen( } } - FoldersAndTracks( - currentFolder = currentFolder, - homeDir = homeDir, - foldersContentPagingState = foldersViewModel.foldersContentPaging.value, - currentTrackHash = currentTrackHash, - playbackState = playbackState, - navPaths = navPaths, - onClickNavPath = { folder -> - routeByGotoFolder = false - foldersViewModel.onFolderUiEvent(FolderUiEvent.OnClickNavPath(folder)) - }, - onRetry = { event -> - foldersViewModel.onFolderUiEvent(event) - }, - onPullToRefresh = { event -> - isManualRefreshing = true - foldersViewModel.onFolderUiEvent(event) - }, - isManualRefreshing = isManualRefreshing, - onManualRefreshingChange = { isRefreshing -> - isManualRefreshing = isRefreshing - }, - onClickFolder = { folder -> - routeByGotoFolder = false - foldersViewModel.onFolderUiEvent(FolderUiEvent.OnClickFolder(folder)) - }, - onClickTrackItem = { index, queue -> - mediaControllerViewModel.onQueueEvent( - QueueEvent.RecreateQueue( - source = QueueSource.FOLDER( - path = currentFolder.path, - name = currentFolder.name - ), - queue = queue, - clickedTrackIndex = index - // Remove isPartialQueue - let ViewModel decide + SwingMusicTheme { + FoldersAndTracks( + currentFolder = currentFolder, + homeDir = homeDir, + foldersContentPagingState = foldersViewModel.foldersContentPaging.value, + currentTrackHash = currentTrackHash, + playbackState = playbackState, + navPaths = navPaths, + onClickNavPath = { folder -> + routeByGotoFolder = false + foldersViewModel.onFolderUiEvent(FolderUiEvent.OnClickNavPath(folder)) + }, + onRetry = { event -> + foldersViewModel.onFolderUiEvent(event) + }, + onPullToRefresh = { event -> + isManualRefreshing = true + foldersViewModel.onFolderUiEvent(event) + }, + isManualRefreshing = isManualRefreshing, + onManualRefreshingChange = { isRefreshing -> + isManualRefreshing = isRefreshing + }, + onClickFolder = { folder -> + routeByGotoFolder = false + foldersViewModel.onFolderUiEvent(FolderUiEvent.OnClickFolder(folder)) + }, + onClickTrackItem = { index, queue -> + mediaControllerViewModel.onQueueEvent( + QueueEvent.RecreateQueue( + source = QueueSource.FOLDER( + path = currentFolder.path, + name = currentFolder.name + ), + queue = queue, + clickedTrackIndex = index + // Remove isPartialQueue - let ViewModel decide + ) ) - ) - }, - onToggleTrackFavorite = { trackHash, isFavorite -> - foldersViewModel.onFolderUiEvent( - FolderUiEvent.ToggleTrackFavorite( - trackHash, - isFavorite + }, + onToggleTrackFavorite = { trackHash, isFavorite -> + foldersViewModel.onFolderUiEvent( + FolderUiEvent.ToggleTrackFavorite( + trackHash, + isFavorite + ) ) - ) - }, - onGetSheetAction = { track, sheetAction -> - when (sheetAction) { - is BottomSheetAction.GotoAlbum -> { - albumWithInfoViewModel.onAlbumWithInfoUiEvent( - AlbumWithInfoUiEvent.OnLoadAlbumWithInfo( - track.albumHash + }, + onGetSheetAction = { track, sheetAction -> + when (sheetAction) { + is BottomSheetAction.GotoAlbum -> { + albumWithInfoViewModel.onAlbumWithInfoUiEvent( + AlbumWithInfoUiEvent.OnLoadAlbumWithInfo( + track.albumHash + ) ) - ) - navigator.gotoAlbumWithInfo(track.albumHash) - } + navigator.gotoAlbumWithInfo(track.albumHash) + } - is BottomSheetAction.AddToQueue -> { - mediaControllerViewModel.onQueueEvent( - QueueEvent.AddToQueue( - track = track, - source = QueueSource.FOLDER( - path = currentFolder.path, - name = currentFolder.name + is BottomSheetAction.AddToQueue -> { + mediaControllerViewModel.onQueueEvent( + QueueEvent.AddToQueue( + track = track, + source = QueueSource.FOLDER( + path = currentFolder.path, + name = currentFolder.name + ) ) ) - ) - } + } - is BottomSheetAction.PlayNext -> { - mediaControllerViewModel.onQueueEvent( - QueueEvent.PlayNext( - track = track, - source = QueueSource.FOLDER( - path = currentFolder.path, - name = currentFolder.name + is BottomSheetAction.PlayNext -> { + mediaControllerViewModel.onQueueEvent( + QueueEvent.PlayNext( + track = track, + source = QueueSource.FOLDER( + path = currentFolder.path, + name = currentFolder.name + ) ) ) - ) - } + } - else -> {} - } - }, - onGotoArtist = { hash -> - navigator.gotoArtistInfo(hash) - }, - baseUrl = baseUrl ?: "" - ) -} \ No newline at end of file + else -> {} + } + }, + onGotoArtist = { hash -> + navigator.gotoArtistInfo(hash) + }, + baseUrl = baseUrl ?: "" + ) + } +} diff --git a/feature/player/src/main/java/com/android/swingmusic/player/presentation/screen/AnimatedPlayerSheet.kt b/feature/player/src/main/java/com/android/swingmusic/player/presentation/screen/AnimatedPlayerSheet.kt new file mode 100644 index 00000000..13dcede2 --- /dev/null +++ b/feature/player/src/main/java/com/android/swingmusic/player/presentation/screen/AnimatedPlayerSheet.kt @@ -0,0 +1,1293 @@ +@file:OptIn(ExperimentalMaterial3Api::class) + +package com.android.swingmusic.player.presentation.screen + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationVector1D +import androidx.compose.animation.core.EaseOutQuad +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.LinearOutSlowInEasing +import androidx.compose.animation.core.VectorConverter +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.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.isSystemInDarkTheme +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.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.BottomSheetScaffold +import androidx.compose.material3.BottomSheetScaffoldState +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SheetValue +import androidx.compose.material3.Text +import androidx.compose.material3.rememberBottomSheetScaffoldState +import androidx.compose.material3.rememberStandardBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.lerp +import androidx.compose.ui.unit.sp +import coil.compose.AsyncImage +import coil.request.ImageRequest +import com.android.swingmusic.core.domain.model.Track +import com.android.swingmusic.core.domain.util.PlaybackState +import com.android.swingmusic.core.domain.util.QueueSource +import com.android.swingmusic.core.domain.util.RepeatMode +import com.android.swingmusic.core.domain.util.ShuffleMode +import com.android.swingmusic.player.presentation.event.PlayerUiEvent +import com.android.swingmusic.player.presentation.event.QueueEvent +import com.android.swingmusic.player.presentation.util.calculateCurrentOffsetForPage +import com.android.swingmusic.player.presentation.viewmodel.MediaControllerViewModel +import com.android.swingmusic.uicomponent.R +import com.android.swingmusic.uicomponent.presentation.util.BlurTransformation +import com.android.swingmusic.uicomponent.presentation.util.formatDuration +import ir.mahozad.multiplatform.wavyslider.WaveAnimationSpecs +import ir.mahozad.multiplatform.wavyslider.WaveDirection +import ir.mahozad.multiplatform.wavyslider.material3.WavySlider +import kotlinx.coroutines.launch +import java.util.Locale +import kotlin.math.pow +import kotlin.math.roundToInt +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.ui.platform.LocalWindowInfo +import com.android.swingmusic.uicomponent.presentation.component.SoundSignalBars +import com.android.swingmusic.uicomponent.presentation.component.TrackItem + +// Constants for sheet sizing (matching SheetDemo) +private val INITIAL_IMAGE_SIZE = 38.dp +private val INITIAL_PADDING = 8.dp +private val TOTAL_INITIAL_SIZE = INITIAL_IMAGE_SIZE + INITIAL_PADDING + +/** + * Animated Bottom Sheet Player that transforms from a mini player to full player. + * + * Animation Behavior: + * - Image transforms from 38dp square to full width based on expansion progress + * - Content fades in at 20% expansion with dynamic spacing and padding + * - Sheet corners transition from rounded to square during expansion + * - Navigation bar in parent should animate out based on onProgressChange callback + * + * Queue Sheet: + * - Appears when primary sheet is 95%+ expanded + * - Custom draggable implementation with spring animations + * - Reverse animation effect: queue expansion reverses primary sheet visuals + */ +@Composable +fun AnimatedPlayerSheet( + paddingValues: PaddingValues, + mediaControllerViewModel: MediaControllerViewModel, + onProgressChange: (progress: Float) -> Unit = {}, + onClickArtist: (artistHash: String) -> Unit = {}, + content: @Composable (PaddingValues) -> Unit +) { + val playerUiState by mediaControllerViewModel.playerUiState.collectAsState() + val baseUrl by mediaControllerViewModel.baseUrl.collectAsState() + + val track = playerUiState.nowPlayingTrack + + // If no track playing, just show content without the sheet + if (track == null) { + content(paddingValues) + return + } + + // Dynamic sheet corner shape + var dynamicShape by remember { + mutableStateOf( + RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp) + ) + } + + // Track primary sheet progress for queue sheet trigger + var primarySheetProgress by remember { mutableFloatStateOf(0f) } + + // Queue sheet calculations + val configuration = LocalWindowInfo.current + val density = LocalDensity.current + val screenHeightPx = with(density) { configuration.containerSize.height.dp.toPx() } + val queueInitialOffset = screenHeightPx * 1f // push sheet off-screen + + // Calculate expanded offset based on initial image size + padding + system bar + val systemBarHeight = paddingValues.calculateTopPadding() + val imageHeightWithPadding = INITIAL_IMAGE_SIZE + (INITIAL_PADDING * 2) + systemBarHeight + val queueExpandedOffset = with(density) { imageHeightWithPadding.toPx() } + + val queueSheetOffset = remember { Animatable(queueInitialOffset, Float.VectorConverter) } + + val coroutineScope = rememberCoroutineScope() + + // Peek height: image size + paddings (sits on top of bottom nav) + val calculatedPeekHeight = TOTAL_INITIAL_SIZE + INITIAL_PADDING + (INITIAL_PADDING/2) + paddingValues.calculateBottomPadding() + + val bottomSheetState = rememberBottomSheetScaffoldState( + bottomSheetState = rememberStandardBottomSheetState( + initialValue = SheetValue.PartiallyExpanded, + skipHiddenState = true, + confirmValueChange = { newValue -> + newValue != SheetValue.Hidden + } + ) + ) + + // Check if queue sheet is open + val isQueueSheetOpen = queueSheetOffset.value < (screenHeightPx * 0.25f) + + // Calculate queue progress for primary sheet visual effects + val queueProgress by remember { + derivedStateOf { + val progress = + (queueInitialOffset - queueSheetOffset.value) / (queueInitialOffset - queueExpandedOffset) + progress.coerceIn(0f, 1f) + } + } + + BottomSheetScaffold( + scaffoldState = bottomSheetState, + sheetPeekHeight = calculatedPeekHeight, + sheetMaxWidth = Dp.Unspecified, + sheetDragHandle = {}, + sheetShape = dynamicShape, + sheetContainerColor = MaterialTheme.colorScheme.inverseOnSurface, + sheetSwipeEnabled = !isQueueSheetOpen, + sheetContent = { + AnimatedSheetContent( + track = track, + queue = playerUiState.queue, + playingTrackIndex = playerUiState.playingTrackIndex, + seekPosition = playerUiState.seekPosition, + playbackDuration = playerUiState.playbackDuration, + trackDuration = playerUiState.trackDuration, + playbackState = playerUiState.playbackState, + isBuffering = playerUiState.isBuffering, + repeatMode = playerUiState.repeatMode, + shuffleMode = playerUiState.shuffleMode, + baseUrl = baseUrl ?: "", + bottomSheetState = bottomSheetState, + systemBarHeight = systemBarHeight, + onShapeChange = { shape -> dynamicShape = shape }, + onProgressChange = { progress -> + primarySheetProgress = progress + onProgressChange(progress) + }, + primarySheetProgress = primarySheetProgress, + queueSheetOffset = queueSheetOffset, + queueInitialOffset = queueInitialOffset, + queueExpandedOffset = queueExpandedOffset, + queueProgress = queueProgress, + onPageSelect = { page -> + mediaControllerViewModel.onQueueEvent(QueueEvent.SeekToQueueItem(page)) + }, + onClickArtist = onClickArtist, + onToggleRepeatMode = { + mediaControllerViewModel.onPlayerUiEvent(PlayerUiEvent.OnToggleRepeatMode) + }, + onClickPrev = { + mediaControllerViewModel.onPlayerUiEvent(PlayerUiEvent.OnPrev) + }, + onTogglePlayerState = { + mediaControllerViewModel.onPlayerUiEvent(PlayerUiEvent.OnTogglePlayerState) + }, + onResumePlayBackFromError = { + mediaControllerViewModel.onPlayerUiEvent(PlayerUiEvent.OnResumePlaybackFromError) + }, + onClickNext = { + mediaControllerViewModel.onPlayerUiEvent(PlayerUiEvent.OnNext) + }, + onToggleShuffleMode = { + mediaControllerViewModel.onPlayerUiEvent( + PlayerUiEvent.OnToggleShuffleMode(toggleShuffle = true) + ) + }, + onSeekPlayBack = { value -> + mediaControllerViewModel.onPlayerUiEvent(PlayerUiEvent.OnSeekPlayBack(value)) + }, + onToggleFavorite = { isFavorite, trackHash -> + mediaControllerViewModel.onPlayerUiEvent( + PlayerUiEvent.OnToggleFavorite(isFavorite, trackHash) + ) + } + ) + } + ) { innerPadding -> + content(innerPadding) + } + + // Queue Sheet - appears when primary sheet is fully expanded + if (primarySheetProgress >= 0.95f) { + QueueSheetOverlay( + queue = playerUiState.queue, + source = playerUiState.source, + playingTrackIndex = playerUiState.playingTrackIndex, + playingTrack = track, + playbackState = playerUiState.playbackState, + baseUrl = baseUrl ?: "", + animatedOffset = queueSheetOffset, + initialOffset = queueInitialOffset, + expandedOffset = queueExpandedOffset, + onClickQueueItem = { index -> + mediaControllerViewModel.onQueueEvent(QueueEvent.SeekToQueueItem(index)) + }, + onTogglePlayerState = { + mediaControllerViewModel.onPlayerUiEvent(PlayerUiEvent.OnTogglePlayerState) + } + ) + } +} + +@Composable +private fun AnimatedSheetContent( + track: Track, + queue: List, + playingTrackIndex: Int, + seekPosition: Float, + playbackDuration: String, + trackDuration: String, + playbackState: PlaybackState, + isBuffering: Boolean, + repeatMode: RepeatMode, + shuffleMode: ShuffleMode, + baseUrl: String, + bottomSheetState: BottomSheetScaffoldState, + systemBarHeight: Dp, + onShapeChange: (RoundedCornerShape) -> Unit, + onProgressChange: (Float) -> Unit, + primarySheetProgress: Float, + queueSheetOffset: Animatable, + queueInitialOffset: Float, + queueExpandedOffset: Float, + queueProgress: Float, + onPageSelect: (Int) -> Unit, + onClickArtist: (String) -> Unit, + onToggleRepeatMode: () -> Unit, + onClickPrev: () -> Unit, + onTogglePlayerState: () -> Unit, + onResumePlayBackFromError: () -> Unit, + onClickNext: () -> Unit, + onToggleShuffleMode: () -> Unit, + onSeekPlayBack: (Float) -> Unit, + onToggleFavorite: (Boolean, String) -> Unit +) { + val coroutineScope = rememberCoroutineScope() + val configuration = LocalConfiguration.current + val density = LocalDensity.current + val screenWidthDp = configuration.screenWidthDp.dp + + // Horizontal swipe state for collapsed mode + var swipeDistance by remember { mutableFloatStateOf(0f) } + + // Store initial offset when first available + val initialOffset = remember { mutableStateOf(null) } + + // Calculate progress using actual initial offset + val progress = remember { + derivedStateOf { + try { + val currentOffset = bottomSheetState.bottomSheetState.requireOffset() + + if (initialOffset.value == null && currentOffset.isFinite() && currentOffset > 0f) { + initialOffset.value = currentOffset + } + + val collapsedOffset = initialOffset.value ?: currentOffset + val expandedOffset = 0f + val range = collapsedOffset - expandedOffset + + // Avoid division by zero or NaN + if (range <= 0f || !range.isFinite()) { + when (bottomSheetState.bottomSheetState.currentValue) { + SheetValue.PartiallyExpanded -> 0f + SheetValue.Expanded -> 1f + SheetValue.Hidden -> 0f + } + } else { + val rawProgress = (collapsedOffset - currentOffset) / range + rawProgress.coerceIn(0f, 1f) + } + } catch (e: Exception) { + when (bottomSheetState.bottomSheetState.currentValue) { + SheetValue.PartiallyExpanded -> 0f + SheetValue.Expanded -> 1f + SheetValue.Hidden -> 0f + } + } + } + } + + // Effective progress considers queue sheet position + // When queue is fully up, effective progress becomes 0 (image shrinks back) + val effectiveProgress = progress.value * (1f - queueProgress) + + // Image size interpolation with easing curve + val fraction = effectiveProgress.pow(0.75f) + val imageSize = lerp(INITIAL_IMAGE_SIZE, screenWidthDp, fraction).coerceAtMost(screenWidthDp) + + // Image corner radius + val imageCornerRadius = lerp(8.dp, 16.dp, effectiveProgress) + + // Dynamic top padding to avoid status bar overlay + val imageTopPadding = lerp(0.dp, systemBarHeight, progress.value) + + // Dynamic spacing between image and content + val imageContentSpacing = lerp(60.dp, 16.dp, progress.value) + + // Dynamic sheet corner radius: 12dp at peek → 0dp at 50% progress + val sheetCornerRadius = lerp(12.dp, 0.dp, (progress.value / 0.5f).coerceIn(0f, 1f)) + + // Dynamic container padding + val containerPadding = lerp(INITIAL_PADDING, 24.dp, effectiveProgress) + + // Content opacity: starts at 20%, full at 80% + val contentOpacity = ((effectiveProgress - 0.2f) / 0.6f).coerceIn(0f, 1f) + + // Mini player elements opacity (inverse) + val miniPlayerOpacity = (1f - (progress.value / 0.3f)).coerceIn(0f, 1f) + + // Update the sheet shape + LaunchedEffect(sheetCornerRadius) { + onShapeChange(RoundedCornerShape(topStart = sheetCornerRadius, topEnd = sheetCornerRadius)) + } + + // Notify progress changes + LaunchedEffect(progress.value) { + onProgressChange(progress.value) + } + + // Track info for file type badge + val fileType by remember(track.filepath) { + derivedStateOf { + track.filepath.substringAfterLast(".").uppercase(Locale.ROOT) + } + } + + val isDarkTheme = isSystemInDarkTheme() + val inverseOnSurface = MaterialTheme.colorScheme.inverseOnSurface + val onSurface = MaterialTheme.colorScheme.onSurface + val fileTypeBadgeColor = when (track.bitrate) { + in 321..1023 -> if (isDarkTheme) Color(0xFF172B2E) else Color(0xFFAEFAF4) + in 1024..Int.MAX_VALUE -> if (isDarkTheme) Color(0XFF443E30) else Color(0xFFFFFBCC) + else -> inverseOnSurface + } + val fileTypeTextColor = when (track.bitrate) { + in 321..1023 -> if (isDarkTheme) Color(0XFF33FFEE) else Color(0xFF172B2E) + in 1024..Int.MAX_VALUE -> if (isDarkTheme) Color(0XFFEFE143) else Color(0xFF221700) + else -> onSurface + } + + val animateWave = playbackState == PlaybackState.PLAYING && !isBuffering + val repeatModeIcon = when (repeatMode) { + RepeatMode.REPEAT_ONE -> R.drawable.repeat_one + else -> R.drawable.repeat_all + } + val playbackStateIcon = when (playbackState) { + PlaybackState.PLAYING -> R.drawable.pause_icon + PlaybackState.PAUSED -> R.drawable.play_arrow + PlaybackState.ERROR -> R.drawable.error + } + + // Pager state for expanded mode artwork swiping + val pagerState = rememberPagerState( + initialPage = playingTrackIndex, + pageCount = { if (queue.isEmpty()) 1 else queue.size } + ) + + var isInitialComposition by remember { mutableStateOf(true) } + + LaunchedEffect(playingTrackIndex, pagerState) { + if (playingTrackIndex in queue.indices) { + if (playingTrackIndex != pagerState.currentPage) { + pagerState.animateScrollToPage(playingTrackIndex) + } + } + + snapshotFlow { pagerState.currentPage }.collect { page -> + if (isInitialComposition) { + isInitialComposition = false + } else { + if (playingTrackIndex != page) { + onPageSelect(page) + } + } + } + } + + Box( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + ) { + // Blurred background (only visible when expanded) + if (effectiveProgress > 0.1f) { + AsyncImage( + modifier = Modifier + .fillMaxSize() + .alpha(effectiveProgress), + model = ImageRequest.Builder(LocalContext.current) + .data("${baseUrl}img/thumbnail/${track.image}") + .crossfade(true) + .transformations(listOf(BlurTransformation(scale = 0.25f, radius = 25))) + .build(), + contentDescription = null, + contentScale = ContentScale.Crop + ) + + Box( + modifier = Modifier + .fillMaxSize() + .alpha(effectiveProgress) + .background( + Brush.verticalGradient( + colors = listOf( + MaterialTheme.colorScheme.surface.copy(alpha = .75f), + MaterialTheme.colorScheme.surface.copy(alpha = 1f), + MaterialTheme.colorScheme.surface.copy(alpha = 1f) + ) + ) + ) + ) + } + + Column( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + .padding(containerPadding) + ) { + // Image container with transformation + Box( + modifier = Modifier + .fillMaxWidth() + .padding(top = imageTopPadding) + .then( + // Horizontal swipe for prev/next in collapsed state + if (progress.value < 0.3f) { + Modifier.pointerInput(Unit) { + detectHorizontalDragGestures( + onDragEnd = { + if (swipeDistance > 50) { + onClickPrev() + } else if (swipeDistance < -50) { + onClickNext() + } + swipeDistance = 0f + } + ) { change, dragAmount -> + change.consume() + swipeDistance += dragAmount + } + } + } else Modifier + ) + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) { + if (progress.value < 0.5f && queueProgress < 0.1f) { + coroutineScope.launch { + bottomSheetState.bottomSheetState.expand() + } + } + } + ) { + // Mini player row (visible when collapsed) + if (progress.value < 0.5f) { + Row( + modifier = Modifier + .fillMaxWidth() + .alpha(miniPlayerOpacity) + .offset { IntOffset((swipeDistance / 3).roundToInt(), 0) }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically + ) { + // Image placeholder for collapsed state (actual image below) + Spacer(modifier = Modifier.width(imageSize + 8.dp)) + + Text( + text = track.title, + maxLines = 1, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + overflow = TextOverflow.Ellipsis, + color = if (swipeDistance.toInt() != 0) + MaterialTheme.colorScheme.onSurface.copy(alpha = .25f) + else + MaterialTheme.colorScheme.onSurface.copy(alpha = .84f) + ) + } + + // Play/Pause button (collapsed state) + IconButton( + modifier = Modifier.padding(end = 8.dp), + onClick = { + if (playbackState == PlaybackState.ERROR) { + onResumePlayBackFromError() + } else { + onTogglePlayerState() + } + } + ) { + if (isBuffering) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 0.75.dp, + strokeCap = StrokeCap.Round + ) + } + Icon( + painter = painterResource( + id = if (playbackState == PlaybackState.PLAYING) + R.drawable.pause_icon else R.drawable.play_arrow + ), + contentDescription = "Play/Pause" + ) + } + } + } + + // Animated image (transforms from 38dp to full width) + if (effectiveProgress > 0.3f) { + // Use HorizontalPager for expanded state + HorizontalPager( + modifier = Modifier.fillMaxWidth(), + state = pagerState, + beyondViewportPageCount = 2, + verticalAlignment = Alignment.CenterVertically + ) { page -> + val imageData = if (page == playingTrackIndex) { + "${baseUrl}img/thumbnail/${queue.getOrNull(playingTrackIndex)?.image ?: track.image}" + } else { + "${baseUrl}img/thumbnail/${queue.getOrNull(page)?.image ?: track.image}" + } + val pageOffset = pagerState.calculateCurrentOffsetForPage(page) + + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + AsyncImage( + modifier = Modifier + .width(imageSize) + .heightIn(max = imageSize) + .aspectRatio(1f) + .clip(RoundedCornerShape(imageCornerRadius)) + .graphicsLayer { + val scale = + androidx.compose.ui.util.lerp(1f, 1.25f, pageOffset) + scaleX = scale + scaleY = scale + clip = true + shape = RoundedCornerShape(imageCornerRadius) + }, + model = ImageRequest.Builder(LocalContext.current) + .data(imageData) + .crossfade(true) + .build(), + placeholder = painterResource(R.drawable.audio_fallback), + fallback = painterResource(R.drawable.audio_fallback), + error = painterResource(R.drawable.audio_fallback), + contentDescription = "Track Image", + contentScale = ContentScale.Crop + ) + } + } + } else { + // Simple image for collapsed/transitioning state + AsyncImage( + modifier = Modifier + .width(imageSize) + .heightIn(max = imageSize) + .aspectRatio(1f) + .clip(RoundedCornerShape(imageCornerRadius)) + .align(Alignment.CenterStart), + model = ImageRequest.Builder(LocalContext.current) + .data("${baseUrl}img/thumbnail/small/${track.image}") + .crossfade(true) + .build(), + placeholder = painterResource(R.drawable.audio_fallback), + fallback = painterResource(R.drawable.audio_fallback), + error = painterResource(R.drawable.audio_fallback), + contentDescription = "Track Image", + contentScale = ContentScale.Crop + ) + } + } + + // Progress bar for collapsed state + if (progress.value < 0.3f) { + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .alpha(miniPlayerOpacity), + gapSize = 0.dp, + drawStopIndicator = {}, + progress = { seekPosition }, + strokeCap = StrokeCap.Square + ) + } + + // Image and Content spacer + Spacer(modifier = Modifier.height(imageContentSpacing)) + + // Expanded content (fades in at 20% progress) + AnimatedVisibility( + visible = effectiveProgress > 0.2f, + enter = fadeIn(animationSpec = tween(200)), + exit = fadeOut(animationSpec = tween(200)) + ) { + Box(modifier = Modifier.alpha(contentOpacity)) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.SpaceBetween + ) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // Track title and artist + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.fillMaxWidth(.78f)) { + Text( + text = track.title, + style = MaterialTheme.typography.titleMedium, + fontSize = 18.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + Spacer(modifier = Modifier.height(6.dp)) + + LazyRow(modifier = Modifier.fillMaxWidth()) { + track.trackArtists.forEachIndexed { index, trackArtist -> + item { + Text( + modifier = Modifier.clickable( + onClick = { onClickArtist(trackArtist.artistHash) }, + indication = null, + interactionSource = remember { MutableInteractionSource() } + ), + text = trackArtist.name, + maxLines = 1, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy( + alpha = .84f + ), + overflow = TextOverflow.Ellipsis + ) + if (index != track.trackArtists.lastIndex) { + Text( + text = ", ", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy( + alpha = .84f + ) + ) + } + } + } + } + } + + IconButton( + modifier = Modifier.clip(CircleShape), + onClick = { onToggleFavorite(track.isFavorite, track.trackHash) } + ) { + val icon = if (track.isFavorite) R.drawable.fav_filled + else R.drawable.fav_not_filled + Icon( + painter = painterResource(id = icon), + contentDescription = "Favorite" + ) + } + } + + Spacer(modifier = Modifier.height(28.dp)) + + // Seek bar + Column(modifier = Modifier.padding(horizontal = 24.dp)) { + WavySlider( + modifier = Modifier.height(12.dp), + value = seekPosition, + onValueChangeFinished = {}, + onValueChange = { value -> onSeekPlayBack(value) }, + waveLength = 32.dp, + waveHeight = if (animateWave) 8.dp else 0.dp, + waveVelocity = 16.dp to WaveDirection.HEAD, + waveThickness = 4.dp, + trackThickness = 4.dp, + incremental = false, + animationSpecs = WaveAnimationSpecs( + waveHeightAnimationSpec = tween( + durationMillis = 300, + easing = FastOutSlowInEasing + ), + waveVelocityAnimationSpec = tween( + durationMillis = 300, + easing = LinearOutSlowInEasing + ), + waveAppearanceAnimationSpec = tween( + durationMillis = 300, + easing = EaseOutQuad + ) + ) + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = playbackDuration, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = .84f) + ) + Text( + text = if (playbackState == PlaybackState.ERROR) + track.duration.formatDuration() else trackDuration, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = .84f) + ) + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + // Playback controls + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceEvenly + ) { + IconButton( + modifier = Modifier + .clip(CircleShape) + .background( + MaterialTheme.colorScheme.secondaryContainer.copy(alpha = .5f) + ), + onClick = { onClickPrev() } + ) { + Icon( + painter = painterResource(id = R.drawable.prev), + contentDescription = "Previous" + ) + } + + Box( + modifier = Modifier.clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + onClick = { + if (playbackState != PlaybackState.ERROR) { + onTogglePlayerState() + } else { + onResumePlayBackFromError() + } + } + ) + ) { + Box( + modifier = Modifier.wrapContentSize(), + contentAlignment = Alignment.Center + ) { + if (playbackState == PlaybackState.ERROR) { + Icon( + modifier = Modifier + .padding(horizontal = 5.dp) + .size(70.dp), + painter = painterResource(id = playbackStateIcon), + tint = if (isBuffering) + MaterialTheme.colorScheme.onErrorContainer.copy( + alpha = .25f + ) + else + MaterialTheme.colorScheme.onErrorContainer.copy( + alpha = .75f + ), + contentDescription = "Error state" + ) + } else { + Box( + modifier = Modifier + .height(70.dp) + .width(80.dp) + .clip(RoundedCornerShape(32)) + .background(MaterialTheme.colorScheme.secondaryContainer), + contentAlignment = Alignment.Center + ) { + Icon( + modifier = Modifier.size(44.dp), + tint = MaterialTheme.colorScheme.onSecondaryContainer, + painter = painterResource(id = playbackStateIcon), + contentDescription = "Play/Pause" + ) + } + } + + if (isBuffering) { + CircularProgressIndicator( + modifier = Modifier.size(50.dp), + strokeCap = StrokeCap.Round, + strokeWidth = 1.dp, + color = MaterialTheme.colorScheme.onSecondaryContainer + ) + } + } + } + + IconButton( + modifier = Modifier + .clip(CircleShape) + .background( + MaterialTheme.colorScheme.secondaryContainer.copy(alpha = .5f) + ), + onClick = { onClickNext() } + ) { + Icon( + painter = painterResource(id = R.drawable.next), + tint = MaterialTheme.colorScheme.onSecondaryContainer, + contentDescription = "Next" + ) + } + } + } + + // Bitrate badge + Box( + modifier = Modifier + .clip(RoundedCornerShape(24)) + .background( + if (isDarkTheme) fileTypeTextColor.copy(alpha = .075f) + else fileTypeBadgeColor + ) + .wrapContentSize() + .padding(8.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = fileType, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.SemiBold, + color = fileTypeTextColor + ) + Text( + text = " • ", + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.SemiBold, + color = fileTypeTextColor + ) + Text( + text = "${track.bitrate} Kbps", + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.SemiBold, + color = fileTypeTextColor + ) + } + } + + // Navigation and Control Icons + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp)) + .background(MaterialTheme.colorScheme.inverseOnSurface) + .navigationBarsPadding() + .padding(vertical = 12.dp, horizontal = 32.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + IconButton(onClick = { onToggleRepeatMode() }) { + Icon( + painter = painterResource(id = repeatModeIcon), + tint = if (repeatMode == RepeatMode.REPEAT_OFF) + MaterialTheme.colorScheme.onSurface.copy(alpha = .3f) + else MaterialTheme.colorScheme.onSurface, + contentDescription = "Repeat" + ) + } + + // Queue icon - triggers queue sheet + IconButton(onClick = { + // Queue sheet will appear via drag from bottom zone + }) { + Icon( + painter = painterResource(id = R.drawable.play_list), + contentDescription = "Queue" + ) + } + + IconButton(onClick = { onToggleShuffleMode() }) { + Icon( + painter = painterResource(id = R.drawable.shuffle), + tint = if (shuffleMode == ShuffleMode.SHUFFLE_OFF) + MaterialTheme.colorScheme.onSurface.copy(alpha = .3f) + else MaterialTheme.colorScheme.onSurface, + contentDescription = "Shuffle" + ) + } + } + } + } + } + + // Queue drag zone (only when primary sheet is expanded) + if (primarySheetProgress >= 0.95f) { + var lastDragOffset by remember { mutableFloatStateOf(queueSheetOffset.value) } + var isDraggingUp by remember { mutableStateOf(false) } + + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .pointerInput(Unit) { + detectDragGestures( + onDragStart = { lastDragOffset = queueSheetOffset.value }, + onDragEnd = { + coroutineScope.launch { + val queueSheetProgress = + (queueInitialOffset - queueSheetOffset.value) / + (queueInitialOffset - queueExpandedOffset) + + val threshold = if (isDraggingUp) 0.20f else 0.90f + + val targetOffset = if (queueSheetProgress > threshold) { + queueExpandedOffset + } else { + queueInitialOffset + } + + queueSheetOffset.animateTo( + targetValue = targetOffset, + animationSpec = spring( + dampingRatio = 0.8f, + stiffness = 400f + ) + ) + } + } + ) { _, dragAmount -> + coroutineScope.launch { + val newOffset = (queueSheetOffset.value + dragAmount.y) + .coerceIn(queueExpandedOffset, queueInitialOffset) + + isDraggingUp = newOffset < lastDragOffset + lastDragOffset = newOffset + + queueSheetOffset.snapTo(newOffset) + } + } + } + ) { + // Empty drag zone content + } + } + } + } // End of Box wrapper +} + +/** + * Queue Sheet Overlay - Secondary sheet that appears when primary player is fully expanded. + * + * Behavior: + * - Draggable from bottom with spring animations + * - Direction-aware snapping (20% threshold up, 90% down) + * - Opacity based on drag progress + */ +@Composable +private fun QueueSheetOverlay( + queue: List, + source: QueueSource, + playingTrackIndex: Int, + playingTrack: Track, + playbackState: PlaybackState, + baseUrl: String, + animatedOffset: Animatable, + initialOffset: Float, + expandedOffset: Float, + onClickQueueItem: (Int) -> Unit, + onTogglePlayerState: () -> Unit +) { + val configuration = LocalConfiguration.current + val density = LocalDensity.current + val coroutineScope = rememberCoroutineScope() + val screenHeightPx = with(density) { configuration.screenHeightDp.dp.toPx() } + val lazyColumnState = rememberLazyListState() + + // Track drag direction + var lastOffset by remember { mutableFloatStateOf(animatedOffset.value) } + var isDraggingUp by remember { mutableStateOf(false) } + + // Calculate drag progress + val queueProgress by remember { + derivedStateOf { + val progress = (initialOffset - animatedOffset.value) / (initialOffset - expandedOffset) + progress.coerceIn(0f, 1f) + } + } + + // Opacity based on drag progress - reaches 1.0 at 50% drag + val queueOpacity by remember { + derivedStateOf { + (queueProgress / 0.5f).coerceIn(0f, 1f) + } + } + + // Scroll to playing track when sheet opens + LaunchedEffect(queueProgress) { + if (queueProgress > 0.9f && (playingTrackIndex - 1) in queue.indices) { + lazyColumnState.scrollToItem(playingTrackIndex - 1) + } + } + + // Main Queue Sheet Container + Box( + modifier = Modifier + .fillMaxSize() + .alpha(queueOpacity) + .offset { IntOffset(0, animatedOffset.value.roundToInt()) } + .pointerInput(Unit) { + detectDragGestures( + onDragStart = { lastOffset = animatedOffset.value }, + onDragEnd = { + coroutineScope.launch { + val threshold = if (isDraggingUp) 0.20f else 0.90f + val targetOffset = if (queueProgress > threshold) { + expandedOffset + } else { + initialOffset + } + + animatedOffset.animateTo( + targetValue = targetOffset, + animationSpec = spring( + dampingRatio = 0.8f, + stiffness = 400f + ) + ) + } + }, + onDrag = { _, dragAmount -> + coroutineScope.launch { + val newOffset = (animatedOffset.value + dragAmount.y) + .coerceIn(expandedOffset, initialOffset) + + isDraggingUp = newOffset < lastOffset + lastOffset = newOffset + + animatedOffset.snapTo(newOffset) + } + } + ) + } + ) { + Column( + modifier = Modifier + .fillMaxSize() + .background( + color = MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp) + ) + .padding(horizontal = 12.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // Drag handle + Spacer(modifier = Modifier.height(12.dp)) + Box( + modifier = Modifier + .width(32.dp) + .height(4.dp) + .background( + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + shape = RoundedCornerShape(2.dp) + ) + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // Header + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Queue", + style = MaterialTheme.typography.headlineMedium + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + // Currently playing track card + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.onSurface.copy(alpha = .14f)) + .clickable { onTogglePlayerState() } + .padding(8.dp), + contentAlignment = Alignment.CenterStart + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Row( + modifier = Modifier.fillMaxWidth(0.8f), + verticalAlignment = Alignment.CenterVertically + ) { + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data("${baseUrl}img/thumbnail/small/${playingTrack.image}") + .crossfade(true) + .build(), + placeholder = painterResource(R.drawable.audio_fallback), + fallback = painterResource(R.drawable.audio_fallback), + error = painterResource(R.drawable.audio_fallback), + contentDescription = "Track Image", + contentScale = ContentScale.Crop, + modifier = Modifier + .size(60.dp) + .clip(RoundedCornerShape(6.dp)) + ) + + Column(modifier = Modifier.padding(horizontal = 12.dp)) { + Text( + text = playingTrack.title, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = playingTrack.trackArtists.joinToString(", ") { it.name }, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = .80f), + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + + // Sound Bars + Box( + modifier = Modifier + .size(50.dp) + .padding(end = 8.dp) + ) { + SoundSignalBars(animate = playbackState == PlaybackState.PLAYING) + } + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + // Queue items + LazyColumn( + state = lazyColumnState, + modifier = Modifier + .weight(1f) + .fillMaxWidth() + ) { + itemsIndexed( + items = queue, + key = { index, track -> "$index:${track.filepath}" } + ) { index, track -> + TrackItem( + track = track, + playbackState = playbackState, + isCurrentTrack = index == playingTrackIndex, + baseUrl = baseUrl, + showMenuIcon = false, + onClickTrackItem = { onClickQueueItem(index) }, + onClickMoreVert = {} + ) + + if (index == queue.lastIndex) { + Spacer(modifier = Modifier.height(80.dp)) + } + } + } + } + } +} From f19aa741ed9d87f611dcf4eb0dfef2d97cc283c0 Mon Sep 17 00:00:00 2001 From: Ericgacoki Date: Sun, 21 Dec 2025 20:16:25 +0300 Subject: [PATCH 02/14] Refactor: Unify player sheet layout and animation This commit refactors the `AnimatedPlayerSheet` to unify the collapsed and expanded layouts into a single, cohesive structure. Key changes: - Replaced the separate `AsyncImage` and `HorizontalPager` with a single `HorizontalPager` that animates its size and position. - Combined the mini-player's title and controls with the pager into a single `Row` for a smoother transition. - Simplified the logic for showing and hiding components during the sheet's expansion and collapse animation. - Improved the image scaling and corner radius animations for a cleaner visual effect. - Adjusted the threshold for showing the collapsed progress bar to improve the UI during transitions. --- .../screen/AnimatedPlayerSheet.kt | 910 +++++++++--------- 1 file changed, 452 insertions(+), 458 deletions(-) diff --git a/feature/player/src/main/java/com/android/swingmusic/player/presentation/screen/AnimatedPlayerSheet.kt b/feature/player/src/main/java/com/android/swingmusic/player/presentation/screen/AnimatedPlayerSheet.kt index 13dcede2..a048d29b 100644 --- a/feature/player/src/main/java/com/android/swingmusic/player/presentation/screen/AnimatedPlayerSheet.kt +++ b/feature/player/src/main/java/com/android/swingmusic/player/presentation/screen/AnimatedPlayerSheet.kt @@ -37,7 +37,10 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.CircleShape @@ -78,6 +81,7 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -98,6 +102,8 @@ import com.android.swingmusic.player.presentation.event.QueueEvent import com.android.swingmusic.player.presentation.util.calculateCurrentOffsetForPage import com.android.swingmusic.player.presentation.viewmodel.MediaControllerViewModel import com.android.swingmusic.uicomponent.R +import com.android.swingmusic.uicomponent.presentation.component.SoundSignalBars +import com.android.swingmusic.uicomponent.presentation.component.TrackItem import com.android.swingmusic.uicomponent.presentation.util.BlurTransformation import com.android.swingmusic.uicomponent.presentation.util.formatDuration import ir.mahozad.multiplatform.wavyslider.WaveAnimationSpecs @@ -107,12 +113,6 @@ import kotlinx.coroutines.launch import java.util.Locale import kotlin.math.pow import kotlin.math.roundToInt -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.ui.platform.LocalWindowInfo -import com.android.swingmusic.uicomponent.presentation.component.SoundSignalBars -import com.android.swingmusic.uicomponent.presentation.component.TrackItem // Constants for sheet sizing (matching SheetDemo) private val INITIAL_IMAGE_SIZE = 38.dp @@ -178,7 +178,8 @@ fun AnimatedPlayerSheet( val coroutineScope = rememberCoroutineScope() // Peek height: image size + paddings (sits on top of bottom nav) - val calculatedPeekHeight = TOTAL_INITIAL_SIZE + INITIAL_PADDING + (INITIAL_PADDING/2) + paddingValues.calculateBottomPadding() + val calculatedPeekHeight = + TOTAL_INITIAL_SIZE + INITIAL_PADDING + (INITIAL_PADDING / 2) + paddingValues.calculateBottomPadding() val bottomSheetState = rememberBottomSheetScaffoldState( bottomSheetState = rememberStandardBottomSheetState( @@ -508,65 +509,120 @@ private fun AnimatedSheetContent( } Column( - modifier = Modifier - .fillMaxWidth() - .fillMaxHeight() - .padding(containerPadding) - ) { - // Image container with transformation - Box( modifier = Modifier .fillMaxWidth() - .padding(top = imageTopPadding) - .then( - // Horizontal swipe for prev/next in collapsed state - if (progress.value < 0.3f) { - Modifier.pointerInput(Unit) { - detectHorizontalDragGestures( - onDragEnd = { - if (swipeDistance > 50) { - onClickPrev() - } else if (swipeDistance < -50) { - onClickNext() + .fillMaxHeight() + ) { + // Image container with transformation + Box( + modifier = Modifier + .fillMaxWidth() + .padding(top = imageTopPadding) + .padding(horizontal = containerPadding) + .then( + // Horizontal swipe for prev/next in collapsed state + if (progress.value < 0.3f) { + Modifier.pointerInput(Unit) { + detectHorizontalDragGestures( + onDragEnd = { + if (swipeDistance > 50) { + onClickPrev() + } else if (swipeDistance < -50) { + onClickNext() + } + swipeDistance = 0f } - swipeDistance = 0f + ) { change, dragAmount -> + change.consume() + swipeDistance += dragAmount } - ) { change, dragAmount -> - change.consume() - swipeDistance += dragAmount } - } - } else Modifier - ) - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() } - ) { - if (progress.value < 0.5f && queueProgress < 0.1f) { - coroutineScope.launch { - bottomSheetState.bottomSheetState.expand() + } else Modifier + ) + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) { + if (progress.value < 0.5f && queueProgress < 0.1f) { + coroutineScope.launch { + bottomSheetState.bottomSheetState.expand() + } } } - } - ) { - // Mini player row (visible when collapsed) - if (progress.value < 0.5f) { + ) { + // Unified layout: Pager + Title + Play/Pause in a Row + // Title and icon fade out as sheet expands Row( modifier = Modifier .fillMaxWidth() - .alpha(miniPlayerOpacity) .offset { IntOffset((swipeDistance / 3).roundToInt(), 0) }, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween + verticalAlignment = Alignment.CenterVertically ) { - Row( - modifier = Modifier.weight(1f), + // Pager takes only the space it needs when collapsed, expands when opened + val pagerWidth = lerp( + INITIAL_IMAGE_SIZE, + screenWidthDp - (containerPadding * 2), + effectiveProgress + ) + + HorizontalPager( + modifier = Modifier.width(pagerWidth), + state = pagerState, + pageSize = androidx.compose.foundation.pager.PageSize.Fill, + beyondViewportPageCount = 1, + userScrollEnabled = effectiveProgress > 0.5f, verticalAlignment = Alignment.CenterVertically - ) { - // Image placeholder for collapsed state (actual image below) - Spacer(modifier = Modifier.width(imageSize + 8.dp)) + ) { page -> + val imageData = if (page == playingTrackIndex) { + "${baseUrl}img/thumbnail/${queue.getOrNull(playingTrackIndex)?.image ?: track.image}" + } else { + "${baseUrl}img/thumbnail/${queue.getOrNull(page)?.image ?: track.image}" + } + + val pageOffset = pagerState.calculateCurrentOffsetForPage(page) + + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + AsyncImage( + modifier = Modifier + .width(imageSize) + .heightIn(max = imageSize) + .aspectRatio(1f) + .clip(RoundedCornerShape(imageCornerRadius)) + .graphicsLayer { + val scale = androidx.compose.ui.util.lerp( + 1f, + 1f + (0.25f * effectiveProgress), + pageOffset + ) + scaleX = scale + scaleY = scale + clip = true + shape = RoundedCornerShape(imageCornerRadius) + }, + model = ImageRequest.Builder(LocalContext.current) + .data(imageData) + .crossfade(true) + .build(), + placeholder = painterResource(R.drawable.audio_fallback), + fallback = painterResource(R.drawable.audio_fallback), + error = painterResource(R.drawable.audio_fallback), + contentDescription = "Track Image", + contentScale = ContentScale.Crop + ) + } + } + + // Title and Play/Pause - fade out when expanding + if (miniPlayerOpacity > 0f) { + Spacer(modifier = Modifier.width(8.dp)) Text( + modifier = Modifier + .weight(1f) + .alpha(miniPlayerOpacity), text = track.title, maxLines = 1, style = MaterialTheme.typography.bodyMedium, @@ -577,488 +633,426 @@ private fun AnimatedSheetContent( else MaterialTheme.colorScheme.onSurface.copy(alpha = .84f) ) - } - // Play/Pause button (collapsed state) - IconButton( - modifier = Modifier.padding(end = 8.dp), - onClick = { - if (playbackState == PlaybackState.ERROR) { - onResumePlayBackFromError() - } else { - onTogglePlayerState() + IconButton( + modifier = Modifier + .padding(end = 8.dp) + .alpha(miniPlayerOpacity), + onClick = { + if (playbackState == PlaybackState.ERROR) { + onResumePlayBackFromError() + } else { + onTogglePlayerState() + } } - } - ) { - if (isBuffering) { - CircularProgressIndicator( - modifier = Modifier.size(24.dp), - strokeWidth = 0.75.dp, - strokeCap = StrokeCap.Round + ) { + if (isBuffering) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 0.75.dp, + strokeCap = StrokeCap.Round + ) + } + Icon( + painter = painterResource( + id = if (playbackState == PlaybackState.PLAYING) + R.drawable.pause_icon else R.drawable.play_arrow + ), + contentDescription = "Play/Pause" ) } - Icon( - painter = painterResource( - id = if (playbackState == PlaybackState.PLAYING) - R.drawable.pause_icon else R.drawable.play_arrow - ), - contentDescription = "Play/Pause" - ) } } } - // Animated image (transforms from 38dp to full width) - if (effectiveProgress > 0.3f) { - // Use HorizontalPager for expanded state - HorizontalPager( - modifier = Modifier.fillMaxWidth(), - state = pagerState, - beyondViewportPageCount = 2, - verticalAlignment = Alignment.CenterVertically - ) { page -> - val imageData = if (page == playingTrackIndex) { - "${baseUrl}img/thumbnail/${queue.getOrNull(playingTrackIndex)?.image ?: track.image}" - } else { - "${baseUrl}img/thumbnail/${queue.getOrNull(page)?.image ?: track.image}" - } - val pageOffset = pagerState.calculateCurrentOffsetForPage(page) - - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - AsyncImage( - modifier = Modifier - .width(imageSize) - .heightIn(max = imageSize) - .aspectRatio(1f) - .clip(RoundedCornerShape(imageCornerRadius)) - .graphicsLayer { - val scale = - androidx.compose.ui.util.lerp(1f, 1.25f, pageOffset) - scaleX = scale - scaleY = scale - clip = true - shape = RoundedCornerShape(imageCornerRadius) - }, - model = ImageRequest.Builder(LocalContext.current) - .data(imageData) - .crossfade(true) - .build(), - placeholder = painterResource(R.drawable.audio_fallback), - fallback = painterResource(R.drawable.audio_fallback), - error = painterResource(R.drawable.audio_fallback), - contentDescription = "Track Image", - contentScale = ContentScale.Crop - ) - } - } - } else { - // Simple image for collapsed/transitioning state - AsyncImage( + // Progress bar for collapsed state + if (progress.value < 0.05f) { + LinearProgressIndicator( modifier = Modifier - .width(imageSize) - .heightIn(max = imageSize) - .aspectRatio(1f) - .clip(RoundedCornerShape(imageCornerRadius)) - .align(Alignment.CenterStart), - model = ImageRequest.Builder(LocalContext.current) - .data("${baseUrl}img/thumbnail/small/${track.image}") - .crossfade(true) - .build(), - placeholder = painterResource(R.drawable.audio_fallback), - fallback = painterResource(R.drawable.audio_fallback), - error = painterResource(R.drawable.audio_fallback), - contentDescription = "Track Image", - contentScale = ContentScale.Crop + .fillMaxWidth() + .height(1.dp) + .alpha(miniPlayerOpacity), + gapSize = 0.dp, + drawStopIndicator = {}, + progress = { seekPosition }, + strokeCap = StrokeCap.Square ) } - } - // Progress bar for collapsed state - if (progress.value < 0.3f) { - LinearProgressIndicator( - modifier = Modifier - .fillMaxWidth() - .height(1.dp) - .alpha(miniPlayerOpacity), - gapSize = 0.dp, - drawStopIndicator = {}, - progress = { seekPosition }, - strokeCap = StrokeCap.Square - ) - } + // Image and Content spacer + Spacer(modifier = Modifier.height(imageContentSpacing)) - // Image and Content spacer - Spacer(modifier = Modifier.height(imageContentSpacing)) - - // Expanded content (fades in at 20% progress) - AnimatedVisibility( - visible = effectiveProgress > 0.2f, - enter = fadeIn(animationSpec = tween(200)), - exit = fadeOut(animationSpec = tween(200)) - ) { - Box(modifier = Modifier.alpha(contentOpacity)) { - Column( - modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.SpaceBetween - ) { + // Expanded content (fades in at 20% progress) + AnimatedVisibility( + visible = effectiveProgress > 0.2f, + enter = fadeIn(animationSpec = tween(200)), + exit = fadeOut(animationSpec = tween(200)) + ) { + Box(modifier = Modifier.alpha(contentOpacity)) { Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.SpaceBetween ) { - // Track title and artist - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally ) { - Column(modifier = Modifier.fillMaxWidth(.78f)) { - Text( - text = track.title, - style = MaterialTheme.typography.titleMedium, - fontSize = 18.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) + // Track title and artist + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.fillMaxWidth(.78f)) { + Text( + text = track.title, + style = MaterialTheme.typography.titleMedium, + fontSize = 18.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) - Spacer(modifier = Modifier.height(6.dp)) - - LazyRow(modifier = Modifier.fillMaxWidth()) { - track.trackArtists.forEachIndexed { index, trackArtist -> - item { - Text( - modifier = Modifier.clickable( - onClick = { onClickArtist(trackArtist.artistHash) }, - indication = null, - interactionSource = remember { MutableInteractionSource() } - ), - text = trackArtist.name, - maxLines = 1, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface.copy( - alpha = .84f - ), - overflow = TextOverflow.Ellipsis - ) - if (index != track.trackArtists.lastIndex) { + Spacer(modifier = Modifier.height(6.dp)) + + LazyRow(modifier = Modifier.fillMaxWidth()) { + track.trackArtists.forEachIndexed { index, trackArtist -> + item { Text( - text = ", ", + modifier = Modifier.clickable( + onClick = { onClickArtist(trackArtist.artistHash) }, + indication = null, + interactionSource = remember { MutableInteractionSource() } + ), + text = trackArtist.name, + maxLines = 1, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurface.copy( alpha = .84f - ) + ), + overflow = TextOverflow.Ellipsis ) + if (index != track.trackArtists.lastIndex) { + Text( + text = ", ", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy( + alpha = .84f + ) + ) + } } } } } - } - IconButton( - modifier = Modifier.clip(CircleShape), - onClick = { onToggleFavorite(track.isFavorite, track.trackHash) } - ) { - val icon = if (track.isFavorite) R.drawable.fav_filled - else R.drawable.fav_not_filled - Icon( - painter = painterResource(id = icon), - contentDescription = "Favorite" - ) + IconButton( + modifier = Modifier.clip(CircleShape), + onClick = { + onToggleFavorite( + track.isFavorite, + track.trackHash + ) + } + ) { + val icon = if (track.isFavorite) R.drawable.fav_filled + else R.drawable.fav_not_filled + Icon( + painter = painterResource(id = icon), + contentDescription = "Favorite" + ) + } } - } - Spacer(modifier = Modifier.height(28.dp)) - - // Seek bar - Column(modifier = Modifier.padding(horizontal = 24.dp)) { - WavySlider( - modifier = Modifier.height(12.dp), - value = seekPosition, - onValueChangeFinished = {}, - onValueChange = { value -> onSeekPlayBack(value) }, - waveLength = 32.dp, - waveHeight = if (animateWave) 8.dp else 0.dp, - waveVelocity = 16.dp to WaveDirection.HEAD, - waveThickness = 4.dp, - trackThickness = 4.dp, - incremental = false, - animationSpecs = WaveAnimationSpecs( - waveHeightAnimationSpec = tween( - durationMillis = 300, - easing = FastOutSlowInEasing - ), - waveVelocityAnimationSpec = tween( - durationMillis = 300, - easing = LinearOutSlowInEasing - ), - waveAppearanceAnimationSpec = tween( - durationMillis = 300, - easing = EaseOutQuad + Spacer(modifier = Modifier.height(28.dp)) + + // Seek bar + Column(modifier = Modifier.padding(horizontal = 24.dp)) { + WavySlider( + modifier = Modifier.height(12.dp), + value = seekPosition, + onValueChangeFinished = {}, + onValueChange = { value -> onSeekPlayBack(value) }, + waveLength = 32.dp, + waveHeight = if (animateWave) 8.dp else 0.dp, + waveVelocity = 16.dp to WaveDirection.HEAD, + waveThickness = 4.dp, + trackThickness = 4.dp, + incremental = false, + animationSpecs = WaveAnimationSpecs( + waveHeightAnimationSpec = tween( + durationMillis = 300, + easing = FastOutSlowInEasing + ), + waveVelocityAnimationSpec = tween( + durationMillis = 300, + easing = LinearOutSlowInEasing + ), + waveAppearanceAnimationSpec = tween( + durationMillis = 300, + easing = EaseOutQuad + ) ) ) - ) - Spacer(modifier = Modifier.height(12.dp)) + Spacer(modifier = Modifier.height(12.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = playbackDuration, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = .84f) + ) + Text( + text = if (playbackState == PlaybackState.ERROR) + track.duration.formatDuration() else trackDuration, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = .84f) + ) + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + // Playback controls Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 4.dp), - horizontalArrangement = Arrangement.SpaceBetween + .padding(horizontal = 24.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceEvenly ) { + IconButton( + modifier = Modifier + .clip(CircleShape) + .background( + MaterialTheme.colorScheme.secondaryContainer.copy(alpha = .5f) + ), + onClick = { onClickPrev() } + ) { + Icon( + painter = painterResource(id = R.drawable.prev), + contentDescription = "Previous" + ) + } + + Box( + modifier = Modifier.clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + onClick = { + if (playbackState != PlaybackState.ERROR) { + onTogglePlayerState() + } else { + onResumePlayBackFromError() + } + } + ) + ) { + Box( + modifier = Modifier.wrapContentSize(), + contentAlignment = Alignment.Center + ) { + if (playbackState == PlaybackState.ERROR) { + Icon( + modifier = Modifier + .padding(horizontal = 5.dp) + .size(70.dp), + painter = painterResource(id = playbackStateIcon), + tint = if (isBuffering) + MaterialTheme.colorScheme.onErrorContainer.copy( + alpha = .25f + ) + else + MaterialTheme.colorScheme.onErrorContainer.copy( + alpha = .75f + ), + contentDescription = "Error state" + ) + } else { + Box( + modifier = Modifier + .height(70.dp) + .width(80.dp) + .clip(RoundedCornerShape(32)) + .background(MaterialTheme.colorScheme.secondaryContainer), + contentAlignment = Alignment.Center + ) { + Icon( + modifier = Modifier.size(44.dp), + tint = MaterialTheme.colorScheme.onSecondaryContainer, + painter = painterResource(id = playbackStateIcon), + contentDescription = "Play/Pause" + ) + } + } + + if (isBuffering) { + CircularProgressIndicator( + modifier = Modifier.size(50.dp), + strokeCap = StrokeCap.Round, + strokeWidth = 1.dp, + color = MaterialTheme.colorScheme.onSecondaryContainer + ) + } + } + } + + IconButton( + modifier = Modifier + .clip(CircleShape) + .background( + MaterialTheme.colorScheme.secondaryContainer.copy(alpha = .5f) + ), + onClick = { onClickNext() } + ) { + Icon( + painter = painterResource(id = R.drawable.next), + tint = MaterialTheme.colorScheme.onSecondaryContainer, + contentDescription = "Next" + ) + } + } + } + + // Bitrate badge + Box( + modifier = Modifier + .clip(RoundedCornerShape(24)) + .background( + if (isDarkTheme) fileTypeTextColor.copy(alpha = .075f) + else fileTypeBadgeColor + ) + .wrapContentSize() + .padding(8.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { Text( - text = playbackDuration, + text = fileType, style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = .84f) + fontWeight = FontWeight.SemiBold, + color = fileTypeTextColor ) Text( - text = if (playbackState == PlaybackState.ERROR) - track.duration.formatDuration() else trackDuration, + text = " • ", style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = .84f) + fontWeight = FontWeight.SemiBold, + color = fileTypeTextColor + ) + Text( + text = "${track.bitrate} Kbps", + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.SemiBold, + color = fileTypeTextColor ) } } - Spacer(modifier = Modifier.height(24.dp)) - - // Playback controls + // Navigation and Control Icons Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 24.dp), + .clip(RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp)) + .background(MaterialTheme.colorScheme.inverseOnSurface) + .navigationBarsPadding() + .padding(vertical = 12.dp, horizontal = 32.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceEvenly + horizontalArrangement = Arrangement.SpaceBetween ) { - IconButton( - modifier = Modifier - .clip(CircleShape) - .background( - MaterialTheme.colorScheme.secondaryContainer.copy(alpha = .5f) - ), - onClick = { onClickPrev() } - ) { + IconButton(onClick = { onToggleRepeatMode() }) { Icon( - painter = painterResource(id = R.drawable.prev), - contentDescription = "Previous" + painter = painterResource(id = repeatModeIcon), + tint = if (repeatMode == RepeatMode.REPEAT_OFF) + MaterialTheme.colorScheme.onSurface.copy(alpha = .3f) + else MaterialTheme.colorScheme.onSurface, + contentDescription = "Repeat" ) } - Box( - modifier = Modifier.clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() }, - onClick = { - if (playbackState != PlaybackState.ERROR) { - onTogglePlayerState() - } else { - onResumePlayBackFromError() - } - } + // Queue icon - triggers queue sheet + IconButton(onClick = { + // Queue sheet will appear via drag from bottom zone + }) { + Icon( + painter = painterResource(id = R.drawable.play_list), + contentDescription = "Queue" ) - ) { - Box( - modifier = Modifier.wrapContentSize(), - contentAlignment = Alignment.Center - ) { - if (playbackState == PlaybackState.ERROR) { - Icon( - modifier = Modifier - .padding(horizontal = 5.dp) - .size(70.dp), - painter = painterResource(id = playbackStateIcon), - tint = if (isBuffering) - MaterialTheme.colorScheme.onErrorContainer.copy( - alpha = .25f - ) - else - MaterialTheme.colorScheme.onErrorContainer.copy( - alpha = .75f - ), - contentDescription = "Error state" - ) - } else { - Box( - modifier = Modifier - .height(70.dp) - .width(80.dp) - .clip(RoundedCornerShape(32)) - .background(MaterialTheme.colorScheme.secondaryContainer), - contentAlignment = Alignment.Center - ) { - Icon( - modifier = Modifier.size(44.dp), - tint = MaterialTheme.colorScheme.onSecondaryContainer, - painter = painterResource(id = playbackStateIcon), - contentDescription = "Play/Pause" - ) - } - } - - if (isBuffering) { - CircularProgressIndicator( - modifier = Modifier.size(50.dp), - strokeCap = StrokeCap.Round, - strokeWidth = 1.dp, - color = MaterialTheme.colorScheme.onSecondaryContainer - ) - } - } } - IconButton( - modifier = Modifier - .clip(CircleShape) - .background( - MaterialTheme.colorScheme.secondaryContainer.copy(alpha = .5f) - ), - onClick = { onClickNext() } - ) { + IconButton(onClick = { onToggleShuffleMode() }) { Icon( - painter = painterResource(id = R.drawable.next), - tint = MaterialTheme.colorScheme.onSecondaryContainer, - contentDescription = "Next" + painter = painterResource(id = R.drawable.shuffle), + tint = if (shuffleMode == ShuffleMode.SHUFFLE_OFF) + MaterialTheme.colorScheme.onSurface.copy(alpha = .3f) + else MaterialTheme.colorScheme.onSurface, + contentDescription = "Shuffle" ) } } } - - // Bitrate badge - Box( - modifier = Modifier - .clip(RoundedCornerShape(24)) - .background( - if (isDarkTheme) fileTypeTextColor.copy(alpha = .075f) - else fileTypeBadgeColor - ) - .wrapContentSize() - .padding(8.dp) - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = fileType, - style = MaterialTheme.typography.bodySmall, - fontWeight = FontWeight.SemiBold, - color = fileTypeTextColor - ) - Text( - text = " • ", - style = MaterialTheme.typography.bodySmall, - fontWeight = FontWeight.SemiBold, - color = fileTypeTextColor - ) - Text( - text = "${track.bitrate} Kbps", - style = MaterialTheme.typography.bodySmall, - fontWeight = FontWeight.SemiBold, - color = fileTypeTextColor - ) - } - } - - // Navigation and Control Icons - Row( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp)) - .background(MaterialTheme.colorScheme.inverseOnSurface) - .navigationBarsPadding() - .padding(vertical = 12.dp, horizontal = 32.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - IconButton(onClick = { onToggleRepeatMode() }) { - Icon( - painter = painterResource(id = repeatModeIcon), - tint = if (repeatMode == RepeatMode.REPEAT_OFF) - MaterialTheme.colorScheme.onSurface.copy(alpha = .3f) - else MaterialTheme.colorScheme.onSurface, - contentDescription = "Repeat" - ) - } - - // Queue icon - triggers queue sheet - IconButton(onClick = { - // Queue sheet will appear via drag from bottom zone - }) { - Icon( - painter = painterResource(id = R.drawable.play_list), - contentDescription = "Queue" - ) - } - - IconButton(onClick = { onToggleShuffleMode() }) { - Icon( - painter = painterResource(id = R.drawable.shuffle), - tint = if (shuffleMode == ShuffleMode.SHUFFLE_OFF) - MaterialTheme.colorScheme.onSurface.copy(alpha = .3f) - else MaterialTheme.colorScheme.onSurface, - contentDescription = "Shuffle" - ) - } - } } } - } - // Queue drag zone (only when primary sheet is expanded) - if (primarySheetProgress >= 0.95f) { - var lastDragOffset by remember { mutableFloatStateOf(queueSheetOffset.value) } - var isDraggingUp by remember { mutableStateOf(false) } + // Queue drag zone (only when primary sheet is expanded) + if (primarySheetProgress >= 0.95f) { + var lastDragOffset by remember { mutableFloatStateOf(queueSheetOffset.value) } + var isDraggingUp by remember { mutableStateOf(false) } - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .fillMaxWidth() - .weight(1f) - .pointerInput(Unit) { - detectDragGestures( - onDragStart = { lastDragOffset = queueSheetOffset.value }, - onDragEnd = { - coroutineScope.launch { - val queueSheetProgress = - (queueInitialOffset - queueSheetOffset.value) / - (queueInitialOffset - queueExpandedOffset) + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .pointerInput(Unit) { + detectDragGestures( + onDragStart = { lastDragOffset = queueSheetOffset.value }, + onDragEnd = { + coroutineScope.launch { + val queueSheetProgress = + (queueInitialOffset - queueSheetOffset.value) / + (queueInitialOffset - queueExpandedOffset) - val threshold = if (isDraggingUp) 0.20f else 0.90f + val threshold = if (isDraggingUp) 0.20f else 0.90f - val targetOffset = if (queueSheetProgress > threshold) { - queueExpandedOffset - } else { - queueInitialOffset - } + val targetOffset = if (queueSheetProgress > threshold) { + queueExpandedOffset + } else { + queueInitialOffset + } - queueSheetOffset.animateTo( - targetValue = targetOffset, - animationSpec = spring( - dampingRatio = 0.8f, - stiffness = 400f + queueSheetOffset.animateTo( + targetValue = targetOffset, + animationSpec = spring( + dampingRatio = 0.8f, + stiffness = 400f + ) ) - ) + } } - } - ) { _, dragAmount -> - coroutineScope.launch { - val newOffset = (queueSheetOffset.value + dragAmount.y) - .coerceIn(queueExpandedOffset, queueInitialOffset) + ) { _, dragAmount -> + coroutineScope.launch { + val newOffset = (queueSheetOffset.value + dragAmount.y) + .coerceIn(queueExpandedOffset, queueInitialOffset) - isDraggingUp = newOffset < lastDragOffset - lastDragOffset = newOffset + isDraggingUp = newOffset < lastDragOffset + lastDragOffset = newOffset - queueSheetOffset.snapTo(newOffset) + queueSheetOffset.snapTo(newOffset) + } } } - } - ) { - // Empty drag zone content + ) { + // Empty drag zone content + } } } - } } // End of Box wrapper } @@ -1084,10 +1078,10 @@ private fun QueueSheetOverlay( onClickQueueItem: (Int) -> Unit, onTogglePlayerState: () -> Unit ) { - val configuration = LocalConfiguration.current + val configuration = LocalWindowInfo.current val density = LocalDensity.current val coroutineScope = rememberCoroutineScope() - val screenHeightPx = with(density) { configuration.screenHeightDp.dp.toPx() } + val screenHeightPx = with(density) { configuration.containerSize.height.dp.toPx() } val lazyColumnState = rememberLazyListState() // Track drag direction @@ -1215,7 +1209,7 @@ private fun QueueSheetOverlay( ) { AsyncImage( model = ImageRequest.Builder(LocalContext.current) - .data("${baseUrl}img/thumbnail/small/${playingTrack.image}") + .data("${baseUrl}img/thumbnail/${playingTrack.image}") .crossfade(true) .build(), placeholder = painterResource(R.drawable.audio_fallback), From 931ec5969a6a4b7a643b217c9833e28453ef720d Mon Sep 17 00:00:00 2001 From: Ericgacoki Date: Sun, 21 Dec 2025 21:54:40 +0300 Subject: [PATCH 03/14] feat: Enhance player queue sheet interaction and visuals - Make the mini player visible again when the queue sheet is nearly fully expanded, improving navigation context. - Enable opening the queue sheet by clicking the queue icon, in addition to dragging. - Relocate the drag gesture for the queue sheet to the bitrate badge area for a more intuitive user experience. - Improve drag responsiveness by adding a multiplier. - Ensure the blurred background is always visible when the player sheet is not fully collapsed. - Load the original size for the track's cover art to improve image quality. - Refine the visibility animation for the collapsed progress bar. --- .../screen/AnimatedPlayerSheet.kt | 187 ++++++++++-------- 1 file changed, 103 insertions(+), 84 deletions(-) diff --git a/feature/player/src/main/java/com/android/swingmusic/player/presentation/screen/AnimatedPlayerSheet.kt b/feature/player/src/main/java/com/android/swingmusic/player/presentation/screen/AnimatedPlayerSheet.kt index a048d29b..5e9f2906 100644 --- a/feature/player/src/main/java/com/android/swingmusic/player/presentation/screen/AnimatedPlayerSheet.kt +++ b/feature/player/src/main/java/com/android/swingmusic/player/presentation/screen/AnimatedPlayerSheet.kt @@ -402,7 +402,10 @@ private fun AnimatedSheetContent( val contentOpacity = ((effectiveProgress - 0.2f) / 0.6f).coerceIn(0f, 1f) // Mini player elements opacity (inverse) - val miniPlayerOpacity = (1f - (progress.value / 0.3f)).coerceIn(0f, 1f) + // Visible when: collapsed OR queue sheet is nearly fully expanded + val collapsedOpacity = (1f - (progress.value / 0.3f)).coerceIn(0f, 1f) + val queueExpandedOpacity = ((queueProgress - 0.7f) / 0.3f).coerceIn(0f, 1f) + val miniPlayerOpacity = maxOf(collapsedOpacity, queueExpandedOpacity) // Update the sheet shape LaunchedEffect(sheetCornerRadius) { @@ -478,7 +481,7 @@ private fun AnimatedSheetContent( .fillMaxHeight() ) { // Blurred background (only visible when expanded) - if (effectiveProgress > 0.1f) { + if (effectiveProgress > 0f) { AsyncImage( modifier = Modifier .fillMaxSize() @@ -604,6 +607,7 @@ private fun AnimatedSheetContent( }, model = ImageRequest.Builder(LocalContext.current) .data(imageData) + .size(coil.size.Size.ORIGINAL) .crossfade(true) .build(), placeholder = painterResource(R.drawable.audio_fallback), @@ -666,7 +670,11 @@ private fun AnimatedSheetContent( } // Progress bar for collapsed state - if (progress.value < 0.05f) { + AnimatedVisibility( + visible = progress.value < 0.01f, + enter = fadeIn(animationSpec = tween(200)), + exit = fadeOut(animationSpec = tween(200)) + ) { LinearProgressIndicator( modifier = Modifier .fillMaxWidth() @@ -922,36 +930,91 @@ private fun AnimatedSheetContent( } } - // Bitrate badge + // Queue drag zone - contains bitrate badge + var lastDragOffset by remember { mutableFloatStateOf(queueSheetOffset.value) } + var isDraggingUp by remember { mutableStateOf(false) } + Box( modifier = Modifier - .clip(RoundedCornerShape(24)) - .background( - if (isDarkTheme) fileTypeTextColor.copy(alpha = .075f) - else fileTypeBadgeColor - ) - .wrapContentSize() - .padding(8.dp) + .fillMaxWidth() + .weight(1f) + .pointerInput(Unit) { + detectDragGestures( + onDragStart = { lastDragOffset = queueSheetOffset.value }, + onDragEnd = { + coroutineScope.launch { + val queueSheetProgress = + (queueInitialOffset - queueSheetOffset.value) / + (queueInitialOffset - queueExpandedOffset) + + val threshold = if (isDraggingUp) 0.12f else 0.88f + + val targetOffset = + if (queueSheetProgress > threshold) { + queueExpandedOffset + } else { + queueInitialOffset + } + + queueSheetOffset.animateTo( + targetValue = targetOffset, + animationSpec = spring( + dampingRatio = 0.8f, + stiffness = 400f + ) + ) + } + } + ) { _, dragAmount -> + coroutineScope.launch { + // Multiplier speeds up drag response (2.5x faster) + val newOffset = + (queueSheetOffset.value + (dragAmount.y * 2.5f)) + .coerceIn( + queueExpandedOffset, + queueInitialOffset + ) + + isDraggingUp = newOffset < lastDragOffset + lastDragOffset = newOffset + + queueSheetOffset.snapTo(newOffset) + } + } + }, + contentAlignment = Alignment.Center ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = fileType, - style = MaterialTheme.typography.bodySmall, - fontWeight = FontWeight.SemiBold, - color = fileTypeTextColor - ) - Text( - text = " • ", - style = MaterialTheme.typography.bodySmall, - fontWeight = FontWeight.SemiBold, - color = fileTypeTextColor - ) - Text( - text = "${track.bitrate} Kbps", - style = MaterialTheme.typography.bodySmall, - fontWeight = FontWeight.SemiBold, - color = fileTypeTextColor - ) + // Bitrate badge + Box( + modifier = Modifier + .clip(RoundedCornerShape(24)) + .background( + if (isDarkTheme) fileTypeTextColor.copy(alpha = .075f) + else fileTypeBadgeColor + ) + .wrapContentSize() + .padding(8.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = fileType, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.SemiBold, + color = fileTypeTextColor + ) + Text( + text = " • ", + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.SemiBold, + color = fileTypeTextColor + ) + Text( + text = "${track.bitrate} Kbps", + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.SemiBold, + color = fileTypeTextColor + ) + } } } @@ -978,7 +1041,15 @@ private fun AnimatedSheetContent( // Queue icon - triggers queue sheet IconButton(onClick = { - // Queue sheet will appear via drag from bottom zone + coroutineScope.launch { + queueSheetOffset.animateTo( + targetValue = queueExpandedOffset, + animationSpec = spring( + dampingRatio = 0.8f, + stiffness = 400f + ) + ) + } }) { Icon( painter = painterResource(id = R.drawable.play_list), @@ -1000,58 +1071,6 @@ private fun AnimatedSheetContent( } } - // Queue drag zone (only when primary sheet is expanded) - if (primarySheetProgress >= 0.95f) { - var lastDragOffset by remember { mutableFloatStateOf(queueSheetOffset.value) } - var isDraggingUp by remember { mutableStateOf(false) } - - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .fillMaxWidth() - .weight(1f) - .pointerInput(Unit) { - detectDragGestures( - onDragStart = { lastDragOffset = queueSheetOffset.value }, - onDragEnd = { - coroutineScope.launch { - val queueSheetProgress = - (queueInitialOffset - queueSheetOffset.value) / - (queueInitialOffset - queueExpandedOffset) - - val threshold = if (isDraggingUp) 0.20f else 0.90f - - val targetOffset = if (queueSheetProgress > threshold) { - queueExpandedOffset - } else { - queueInitialOffset - } - - queueSheetOffset.animateTo( - targetValue = targetOffset, - animationSpec = spring( - dampingRatio = 0.8f, - stiffness = 400f - ) - ) - } - } - ) { _, dragAmount -> - coroutineScope.launch { - val newOffset = (queueSheetOffset.value + dragAmount.y) - .coerceIn(queueExpandedOffset, queueInitialOffset) - - isDraggingUp = newOffset < lastDragOffset - lastDragOffset = newOffset - - queueSheetOffset.snapTo(newOffset) - } - } - } - ) { - // Empty drag zone content - } - } } } // End of Box wrapper } @@ -1110,7 +1129,7 @@ private fun QueueSheetOverlay( } } - // Main Queue Sheet Container + // Main Queue Sheet Container - uses direct offset like primary sheet Box( modifier = Modifier .fillMaxSize() From 346db9e3c22cb0fea1f8a77779b548285472891e Mon Sep 17 00:00:00 2001 From: Ericgacoki Date: Sun, 21 Dec 2025 21:57:37 +0300 Subject: [PATCH 04/14] feat: Handle back press in AnimatedPlayerSheet - Implement a `BackHandler` to manage the behavior of the back button. - If the queue sheet is open, the back press will now close it. - If the primary player sheet is expanded, the back press will collapse it to the partial state. --- .../presentation/screen/AnimatedPlayerSheet.kt | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/feature/player/src/main/java/com/android/swingmusic/player/presentation/screen/AnimatedPlayerSheet.kt b/feature/player/src/main/java/com/android/swingmusic/player/presentation/screen/AnimatedPlayerSheet.kt index 5e9f2906..404589b3 100644 --- a/feature/player/src/main/java/com/android/swingmusic/player/presentation/screen/AnimatedPlayerSheet.kt +++ b/feature/player/src/main/java/com/android/swingmusic/player/presentation/screen/AnimatedPlayerSheet.kt @@ -2,6 +2,7 @@ package com.android.swingmusic.player.presentation.screen +import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.AnimationVector1D @@ -203,6 +204,21 @@ fun AnimatedPlayerSheet( } } + // Handle back press: close queue sheet first, then collapse primary sheet + val isPrimarySheetExpanded = bottomSheetState.bottomSheetState.currentValue == SheetValue.Expanded + BackHandler(enabled = isQueueSheetOpen || isPrimarySheetExpanded) { + coroutineScope.launch { + if (isQueueSheetOpen) { + queueSheetOffset.animateTo( + targetValue = queueInitialOffset, + animationSpec = spring(dampingRatio = 0.8f, stiffness = 400f) + ) + } else if (isPrimarySheetExpanded) { + bottomSheetState.bottomSheetState.partialExpand() + } + } + } + BottomSheetScaffold( scaffoldState = bottomSheetState, sheetPeekHeight = calculatedPeekHeight, From 1d7fc46cb8ea3575e37814503279f1a4733b0508 Mon Sep 17 00:00:00 2001 From: Ericgacoki Date: Sun, 21 Dec 2025 23:01:54 +0300 Subject: [PATCH 05/14] feat: Enhance player queue UI and navigation - Add a track options bottom sheet to the player queue, allowing actions like "Go to Artist," "Go to Album," "Play Next," and toggling favorites. - Display the current queue source (e.g., Album, Artist, Folder) with a clickable link to navigate to it. - Refactor player navigation by introducing a `CommonNavigator` to handle actions like navigating to artist or album pages. - Enable the animation on the sound signal bars in the queue's currently playing track item. --- .../MainActivityWithAnimatedPlayer.kt | 13 +- .../screen/AnimatedPlayerSheet.kt | 211 ++++++++++++++++-- .../presentation/component/SoundSignalBars.kt | 2 +- 3 files changed, 198 insertions(+), 28 deletions(-) diff --git a/app/src/main/java/com/android/swingmusic/presentation/activity/MainActivityWithAnimatedPlayer.kt b/app/src/main/java/com/android/swingmusic/presentation/activity/MainActivityWithAnimatedPlayer.kt index efcaae05..0244f1fa 100644 --- a/app/src/main/java/com/android/swingmusic/presentation/activity/MainActivityWithAnimatedPlayer.kt +++ b/app/src/main/java/com/android/swingmusic/presentation/activity/MainActivityWithAnimatedPlayer.kt @@ -283,22 +283,15 @@ class MainActivityWithAnimatedPlayer : ComponentActivity() { } isUserLoggedIn?.let { value -> + val navigator = CoreNavigator(navController) + // Always use AnimatedPlayerSheet - it handles "no track" case internally AnimatedPlayerSheet( paddingValues = paddingValues, mediaControllerViewModel = mediaControllerViewModel, + navigator = navigator, onProgressChange = { progress -> sheetProgress = progress - }, - onClickArtist = { artistHash -> - navController.navigate( - ArtistInfoScreenDestination( - artistHash = artistHash, - loadNewArtist = true - ).route - ) { - launchSingleTop = true - } } ) { SwingMusicAppNavigationWithAnimatedPlayer( diff --git a/feature/player/src/main/java/com/android/swingmusic/player/presentation/screen/AnimatedPlayerSheet.kt b/feature/player/src/main/java/com/android/swingmusic/player/presentation/screen/AnimatedPlayerSheet.kt index 404589b3..a3480827 100644 --- a/feature/player/src/main/java/com/android/swingmusic/player/presentation/screen/AnimatedPlayerSheet.kt +++ b/feature/player/src/main/java/com/android/swingmusic/player/presentation/screen/AnimatedPlayerSheet.kt @@ -49,6 +49,10 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.BottomSheetScaffold import androidx.compose.material3.BottomSheetScaffoldState import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.rememberModalBottomSheetState +import com.android.swingmusic.core.domain.model.BottomSheetItemModel +import com.android.swingmusic.core.domain.util.BottomSheetAction +import com.android.swingmusic.uicomponent.presentation.component.CustomTrackBottomSheet import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -93,6 +97,7 @@ import androidx.compose.ui.unit.lerp import androidx.compose.ui.unit.sp import coil.compose.AsyncImage import coil.request.ImageRequest +import com.android.swingmusic.common.presentation.navigator.CommonNavigator import com.android.swingmusic.core.domain.model.Track import com.android.swingmusic.core.domain.util.PlaybackState import com.android.swingmusic.core.domain.util.QueueSource @@ -107,6 +112,8 @@ import com.android.swingmusic.uicomponent.presentation.component.SoundSignalBars import com.android.swingmusic.uicomponent.presentation.component.TrackItem import com.android.swingmusic.uicomponent.presentation.util.BlurTransformation import com.android.swingmusic.uicomponent.presentation.util.formatDuration +import com.android.swingmusic.uicomponent.presentation.util.getName +import com.android.swingmusic.uicomponent.presentation.util.getSourceType import ir.mahozad.multiplatform.wavyslider.WaveAnimationSpecs import ir.mahozad.multiplatform.wavyslider.WaveDirection import ir.mahozad.multiplatform.wavyslider.material3.WavySlider @@ -138,8 +145,8 @@ private val TOTAL_INITIAL_SIZE = INITIAL_IMAGE_SIZE + INITIAL_PADDING fun AnimatedPlayerSheet( paddingValues: PaddingValues, mediaControllerViewModel: MediaControllerViewModel, + navigator: CommonNavigator, onProgressChange: (progress: Float) -> Unit = {}, - onClickArtist: (artistHash: String) -> Unit = {}, content: @Composable (PaddingValues) -> Unit ) { val playerUiState by mediaControllerViewModel.playerUiState.collectAsState() @@ -255,7 +262,7 @@ fun AnimatedPlayerSheet( onPageSelect = { page -> mediaControllerViewModel.onQueueEvent(QueueEvent.SeekToQueueItem(page)) }, - onClickArtist = onClickArtist, + onClickArtist = { navigator.gotoArtistInfo(it) }, onToggleRepeatMode = { mediaControllerViewModel.onPlayerUiEvent(PlayerUiEvent.OnToggleRepeatMode) }, @@ -307,6 +314,25 @@ fun AnimatedPlayerSheet( }, onTogglePlayerState = { mediaControllerViewModel.onPlayerUiEvent(PlayerUiEvent.OnTogglePlayerState) + }, + onClickArtist = { navigator.gotoArtistInfo(it) }, + onGotoAlbum = { navigator.gotoAlbumWithInfo(it) }, + onGotoFolder = { name, path -> navigator.gotoSourceFolder(name, path) }, + onPlayNext = { nextTrack -> + mediaControllerViewModel.onQueueEvent( + QueueEvent.PlayNext(nextTrack, playerUiState.source) + ) + }, + onToggleTrackFavorite = { trackHash, isFavorite -> + mediaControllerViewModel.onPlayerUiEvent( + PlayerUiEvent.OnToggleFavorite(isFavorite, trackHash) + ) + }, + onCloseSheets = { + coroutineScope.launch { + queueSheetOffset.snapTo(queueInitialOffset) + bottomSheetState.bottomSheetState.partialExpand() + } } ) } @@ -347,7 +373,6 @@ private fun AnimatedSheetContent( ) { val coroutineScope = rememberCoroutineScope() val configuration = LocalConfiguration.current - val density = LocalDensity.current val screenWidthDp = configuration.screenWidthDp.dp // Horizontal swipe state for collapsed mode @@ -746,7 +771,12 @@ private fun AnimatedSheetContent( item { Text( modifier = Modifier.clickable( - onClick = { onClickArtist(trackArtist.artistHash) }, + onClick = { + onClickArtist(trackArtist.artistHash) + coroutineScope.launch { + bottomSheetState.bottomSheetState.partialExpand() + } + }, indication = null, interactionSource = remember { MutableInteractionSource() } ), @@ -1099,6 +1129,7 @@ private fun AnimatedSheetContent( * - Direction-aware snapping (20% threshold up, 90% down) * - Opacity based on drag progress */ +@OptIn(ExperimentalMaterial3Api::class) @Composable private fun QueueSheetOverlay( queue: List, @@ -1111,14 +1142,33 @@ private fun QueueSheetOverlay( initialOffset: Float, expandedOffset: Float, onClickQueueItem: (Int) -> Unit, - onTogglePlayerState: () -> Unit + onTogglePlayerState: () -> Unit, + onClickArtist: (artistHash: String) -> Unit, + onGotoAlbum: (albumHash: String) -> Unit, + onGotoFolder: (name: String, path: String) -> Unit, + onPlayNext: (track: Track) -> Unit, + onToggleTrackFavorite: (trackHash: String, isFavorite: Boolean) -> Unit, + onCloseSheets: () -> Unit ) { - val configuration = LocalWindowInfo.current + val configuration = LocalConfiguration.current val density = LocalDensity.current val coroutineScope = rememberCoroutineScope() - val screenHeightPx = with(density) { configuration.containerSize.height.dp.toPx() } + val screenHeightPx = with(density) { configuration.screenHeightDp.dp.toPx() } val lazyColumnState = rememberLazyListState() + // Bottom sheet state for track menu + val sheetState = rememberModalBottomSheetState() + var showTrackBottomSheet by remember { mutableStateOf(false) } + var clickedTrack: Track? by remember { mutableStateOf(null) } + + // Update clicked track when queue changes + LaunchedEffect(queue) { + clickedTrack?.let { track -> + val updatedTrack = queue.find { it.trackHash == track.trackHash } + clickedTrack = updatedTrack ?: track + } + } + // Track drag direction var lastOffset by remember { mutableFloatStateOf(animatedOffset.value) } var isDraggingUp by remember { mutableStateOf(false) } @@ -1145,6 +1195,73 @@ private fun QueueSheetOverlay( } } + // Track menu bottom sheet + if (showTrackBottomSheet) { + clickedTrack?.let { track -> + CustomTrackBottomSheet( + scope = coroutineScope, + sheetState = sheetState, + clickedTrack = track, + isFavorite = track.isFavorite, + baseUrl = baseUrl, + bottomSheetItems = listOf( + BottomSheetItemModel( + label = "Go to Artist", + enabled = true, + painterId = R.drawable.ic_artist, + track = track, + sheetAction = BottomSheetAction.OpenArtistsDialog(track.trackArtists) + ), + BottomSheetItemModel( + label = "Go to Album", + painterId = R.drawable.ic_album, + track = track, + sheetAction = BottomSheetAction.GotoAlbum + ), + BottomSheetItemModel( + label = "Go to Folder", + enabled = true, + painterId = R.drawable.folder_outlined_open, + track = track, + sheetAction = BottomSheetAction.GotoFolder( + name = track.folder.getFolderName(), + path = track.folder + ) + ), + BottomSheetItemModel( + label = "Play Next", + enabled = true, + painterId = R.drawable.play_next, + track = track, + sheetAction = BottomSheetAction.PlayNext + ) + ), + onHideBottomSheet = { showTrackBottomSheet = it }, + onClickSheetItem = { sheetTrack, sheetAction -> + when (sheetAction) { + is BottomSheetAction.GotoAlbum -> { + onGotoAlbum(sheetTrack.albumHash) + onCloseSheets() + } + is BottomSheetAction.GotoFolder -> { + onGotoFolder(sheetAction.name, sheetAction.path) + onCloseSheets() + } + is BottomSheetAction.PlayNext -> onPlayNext(sheetTrack) + else -> {} + } + }, + onChooseArtist = { hash -> + onClickArtist(hash) + onCloseSheets() + }, + onToggleTrackFavorite = { trackHash, isFavorite -> + onToggleTrackFavorite(trackHash, isFavorite) + } + ) + } + } + // Main Queue Sheet Container - uses direct offset like primary sheet Box( modifier = Modifier @@ -1193,7 +1310,7 @@ private fun QueueSheetOverlay( color = MaterialTheme.colorScheme.surface, shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp) ) - .padding(horizontal = 12.dp), + .navigationBarsPadding(), horizontalAlignment = Alignment.CenterHorizontally ) { // Drag handle @@ -1212,21 +1329,81 @@ private fun QueueSheetOverlay( // Header Row( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp), verticalAlignment = Alignment.CenterVertically ) { Text( - text = "Queue", + text = "Now Playing", style = MaterialTheme.typography.headlineMedium ) } + Spacer(modifier = Modifier.height(4.dp)) + + // Queue source indicator + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp) + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) { + when (source) { + is QueueSource.ALBUM -> { + onGotoAlbum(source.albumHash) + onCloseSheets() + } + is QueueSource.ARTIST -> { + onClickArtist(source.artistHash) + onCloseSheets() + } + is QueueSource.FOLDER -> { + onGotoFolder(source.name, source.path) + onCloseSheets() + } + else -> {} + } + } + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = source.getSourceType(), + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = .94f) + ) + + if (source.getName().isNotEmpty()) { + Box( + modifier = Modifier + .padding(horizontal = 8.dp) + .size(4.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.onSurface.copy(alpha = .36f)) + ) + + Text( + text = source.getName(), + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = .94f) + ) + } + } + Spacer(modifier = Modifier.height(8.dp)) // Currently playing track card Box( modifier = Modifier .fillMaxWidth() + .padding(horizontal = 12.dp) .clip(RoundedCornerShape(12.dp)) .background(MaterialTheme.colorScheme.onSurface.copy(alpha = .14f)) .clickable { onTogglePlayerState() } @@ -1296,7 +1473,8 @@ private fun QueueSheetOverlay( state = lazyColumnState, modifier = Modifier .weight(1f) - .fillMaxWidth() + .fillMaxWidth(), + contentPadding = PaddingValues(bottom = 80.dp) ) { itemsIndexed( items = queue, @@ -1307,14 +1485,13 @@ private fun QueueSheetOverlay( playbackState = playbackState, isCurrentTrack = index == playingTrackIndex, baseUrl = baseUrl, - showMenuIcon = false, + showMenuIcon = true, onClickTrackItem = { onClickQueueItem(index) }, - onClickMoreVert = {} + onClickMoreVert = { + clickedTrack = it + showTrackBottomSheet = true + } ) - - if (index == queue.lastIndex) { - Spacer(modifier = Modifier.height(80.dp)) - } } } } diff --git a/uicomponent/src/main/java/com/android/swingmusic/uicomponent/presentation/component/SoundSignalBars.kt b/uicomponent/src/main/java/com/android/swingmusic/uicomponent/presentation/component/SoundSignalBars.kt index 55eea2db..7a9baed5 100644 --- a/uicomponent/src/main/java/com/android/swingmusic/uicomponent/presentation/component/SoundSignalBars.kt +++ b/uicomponent/src/main/java/com/android/swingmusic/uicomponent/presentation/component/SoundSignalBars.kt @@ -31,7 +31,7 @@ fun SoundSignalBars(animate: Boolean) { initialHeights.map { mutableFloatStateOf(it) } } - LaunchedEffect(Unit) { + LaunchedEffect(animate) { if (animate) { while (true) { barStates.forEach { barState -> From 9d356524e6625be186f0bbf81f1708947cc7e676 Mon Sep 17 00:00:00 2001 From: Ericgacoki Date: Mon, 22 Dec 2025 22:44:40 +0300 Subject: [PATCH 06/14] feat: Lock orientation and refine folder empty state - Lock `MainActivityWithAnimatedPlayer` to portrait mode in the AndroidManifest. - Prevent the "Empty" message from showing in the folder/track list when a loading error occurs. --- app/src/main/AndroidManifest.xml | 1 + .../swingmusic/folder/presentation/screen/FoldersAndTracks.kt | 1 + 2 files changed, 2 insertions(+) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6cbb0a60..c93f4a80 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -35,6 +35,7 @@ diff --git a/feature/folder/src/main/java/com/android/swingmusic/folder/presentation/screen/FoldersAndTracks.kt b/feature/folder/src/main/java/com/android/swingmusic/folder/presentation/screen/FoldersAndTracks.kt index d6e92a9d..a66edde8 100644 --- a/feature/folder/src/main/java/com/android/swingmusic/folder/presentation/screen/FoldersAndTracks.kt +++ b/feature/folder/src/main/java/com/android/swingmusic/folder/presentation/screen/FoldersAndTracks.kt @@ -444,6 +444,7 @@ private fun FoldersAndTracks( if (pagingContent.itemCount == 0 && pagingContent.loadState.refresh !is LoadState.Loading && + pagingContent.loadState.refresh !is LoadState.Error && !isManualRefreshing ) { item { From 9f5a2ed3fdf11c6d85b1818da818c2f657ac99e9 Mon Sep 17 00:00:00 2001 From: Ericgacoki Date: Mon, 22 Dec 2025 22:59:24 +0300 Subject: [PATCH 07/14] feat: Add animated icons to bottom navigation bar - Introduce animated vector drawables (AVDs) for the bottom navigation icons: Folder, Album, Artist, and Search. - Implement animations that trigger on icon click, providing visual feedback to the user. - Add the `androidx.compose.animation:animation-graphics` dependency to support animated icons in Compose. - Update the bottom navigation bar in `MainActivityWithAnimatedPlayer` to use the new animated icons. --- .../MainActivityWithAnimatedPlayer.kt | 33 +++++-- .../presentation/navigator/BottomNavItem.kt | 5 ++ gradle/libs.versions.toml | 1 + uicomponent/build.gradle.kts | 1 + .../src/main/res/drawable/avd_album.xml | 80 +++++++++++++++++ .../src/main/res/drawable/avd_artist.xml | 77 ++++++++++++++++ .../src/main/res/drawable/avd_folder.xml | 63 +++++++++++++ .../src/main/res/drawable/avd_search.xml | 89 +++++++++++++++++++ 8 files changed, 342 insertions(+), 7 deletions(-) create mode 100644 uicomponent/src/main/res/drawable/avd_album.xml create mode 100644 uicomponent/src/main/res/drawable/avd_artist.xml create mode 100644 uicomponent/src/main/res/drawable/avd_folder.xml create mode 100644 uicomponent/src/main/res/drawable/avd_search.xml diff --git a/app/src/main/java/com/android/swingmusic/presentation/activity/MainActivityWithAnimatedPlayer.kt b/app/src/main/java/com/android/swingmusic/presentation/activity/MainActivityWithAnimatedPlayer.kt index 0244f1fa..84b229c1 100644 --- a/app/src/main/java/com/android/swingmusic/presentation/activity/MainActivityWithAnimatedPlayer.kt +++ b/app/src/main/java/com/android/swingmusic/presentation/activity/MainActivityWithAnimatedPlayer.kt @@ -35,6 +35,11 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.platform.LocalDensity +import androidx.compose.animation.graphics.ExperimentalAnimationGraphicsApi +import androidx.compose.animation.graphics.res.animatedVectorResource +import androidx.compose.animation.graphics.res.rememberAnimatedVectorPainter +import androidx.compose.animation.graphics.vector.AnimatedImageVector +import androidx.compose.runtime.mutableStateMapOf import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp @@ -188,6 +193,9 @@ class MainActivityWithAnimatedPlayer : ComponentActivity() { // Track sheet progress for nav bar animation var sheetProgress by remember { mutableFloatStateOf(0f) } + // Track animation state for each nav item + val animationStates = remember { mutableStateMapOf() } + // Calculate nav bar animation values val navBarSlideProgress = (sheetProgress / 0.2f).coerceIn(0f, 1f) val navBarAlpha = 1f - navBarSlideProgress @@ -218,21 +226,32 @@ class MainActivityWithAnimatedPlayer : ComponentActivity() { horizontalArrangement = Arrangement.Center ) { bottomNavItems.forEach { item -> + val isSelected = navController.currentDestination?.route?.let { route -> + bottomNavRoutePrefixes[item]?.any { prefix -> + route.startsWith(prefix) + } == true + } == true + + val animatedIcon = AnimatedImageVector.animatedVectorResource(item.animatedIcon) + val atEnd = animationStates[item] ?: false + NavigationBarItem( icon = { Icon( - painter = painterResource(id = item.icon), - contentDescription = null + painter = rememberAnimatedVectorPainter( + animatedImageVector = animatedIcon, + atEnd = atEnd + ), + contentDescription = item.title ) }, - selected = navController.currentDestination?.route?.let { route -> - bottomNavRoutePrefixes[item]?.any { prefix -> - route.startsWith(prefix) - } == true - } == true, + selected = isSelected, alwaysShowLabel = false, label = { Text(text = item.title) }, onClick = { + // Trigger animation on click + animationStates[item] = !(animationStates[item] ?: false) + // Whatever you do, DON'T TOUCH this if (navController.currentDestination?.route != item.destination.route) { navController.navigate(item.destination.route) { diff --git a/app/src/main/java/com/android/swingmusic/presentation/navigator/BottomNavItem.kt b/app/src/main/java/com/android/swingmusic/presentation/navigator/BottomNavItem.kt index 515b876d..e5a4e948 100644 --- a/app/src/main/java/com/android/swingmusic/presentation/navigator/BottomNavItem.kt +++ b/app/src/main/java/com/android/swingmusic/presentation/navigator/BottomNavItem.kt @@ -11,29 +11,34 @@ import com.android.swingmusic.uicomponent.R as UiComponent sealed class BottomNavItem( var title: String, @param:DrawableRes var icon: Int, + @param:DrawableRes var animatedIcon: Int, var destination: DestinationSpec<*> ) { data object Folder : BottomNavItem( title = "Folders", icon = UiComponent.drawable.folder_filled, + animatedIcon = UiComponent.drawable.avd_folder, destination = FoldersAndTracksScreenDestination ) data object Album : BottomNavItem( title = "Albums", icon = UiComponent.drawable.ic_album, + animatedIcon = UiComponent.drawable.avd_album, destination = AllAlbumScreenDestination ) data object Artist : BottomNavItem( title = "Artists", icon = UiComponent.drawable.ic_artist, + animatedIcon = UiComponent.drawable.avd_artist, destination = AllArtistsScreenDestination ) data object Search : BottomNavItem( title = "Search", icon = UiComponent.drawable.ic_search, + animatedIcon = UiComponent.drawable.avd_search, destination = SearchScreenDestination ) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 33d71801..26cd5adf 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -90,6 +90,7 @@ androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui- androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } +androidx-compose-animation-graphics = { group = "androidx.compose.animation", name = "animation-graphics" } # Hilt hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } diff --git a/uicomponent/build.gradle.kts b/uicomponent/build.gradle.kts index 8dc724eb..cbeb141f 100644 --- a/uicomponent/build.gradle.kts +++ b/uicomponent/build.gradle.kts @@ -46,6 +46,7 @@ dependencies { implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.compose.material3) api(libs.androidx.compose.material.icons.extended) + api(libs.androidx.compose.animation.graphics) implementation(libs.androidx.compose.ui) implementation(libs.androidx.compose.ui.tooling.preview) diff --git a/uicomponent/src/main/res/drawable/avd_album.xml b/uicomponent/src/main/res/drawable/avd_album.xml new file mode 100644 index 00000000..393fc786 --- /dev/null +++ b/uicomponent/src/main/res/drawable/avd_album.xml @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/uicomponent/src/main/res/drawable/avd_artist.xml b/uicomponent/src/main/res/drawable/avd_artist.xml new file mode 100644 index 00000000..a0ac2bfd --- /dev/null +++ b/uicomponent/src/main/res/drawable/avd_artist.xml @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/uicomponent/src/main/res/drawable/avd_folder.xml b/uicomponent/src/main/res/drawable/avd_folder.xml new file mode 100644 index 00000000..c9826089 --- /dev/null +++ b/uicomponent/src/main/res/drawable/avd_folder.xml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/uicomponent/src/main/res/drawable/avd_search.xml b/uicomponent/src/main/res/drawable/avd_search.xml new file mode 100644 index 00000000..fce341b5 --- /dev/null +++ b/uicomponent/src/main/res/drawable/avd_search.xml @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 891ad8428f447de19025bfe1b63e95ddbb58d14a Mon Sep 17 00:00:00 2001 From: Ericgacoki Date: Mon, 22 Dec 2025 23:37:46 +0300 Subject: [PATCH 08/14] feat: Add animated icons for player controls - Add animated vector drawables (AVDs) for the play/pause, next, and previous buttons. - Implement `AnimatedImageVector` in the player screen to provide visual feedback when playback controls are used. - Add a rotation animation to the existing artist icon AVD. --- .../screen/AnimatedPlayerSheet.kt | 44 +++++++-- .../src/main/res/drawable/avd_artist.xml | 34 ++++++- .../src/main/res/drawable/avd_next.xml | 91 +++++++++++++++++++ .../main/res/drawable/avd_pause_to_play.xml | 46 ++++++++++ .../src/main/res/drawable/avd_play_pause.xml | 51 +++++++++++ .../main/res/drawable/avd_play_to_pause.xml | 46 ++++++++++ .../src/main/res/drawable/avd_prev.xml | 91 +++++++++++++++++++ 7 files changed, 391 insertions(+), 12 deletions(-) create mode 100644 uicomponent/src/main/res/drawable/avd_next.xml create mode 100644 uicomponent/src/main/res/drawable/avd_pause_to_play.xml create mode 100644 uicomponent/src/main/res/drawable/avd_play_pause.xml create mode 100644 uicomponent/src/main/res/drawable/avd_play_to_pause.xml create mode 100644 uicomponent/src/main/res/drawable/avd_prev.xml diff --git a/feature/player/src/main/java/com/android/swingmusic/player/presentation/screen/AnimatedPlayerSheet.kt b/feature/player/src/main/java/com/android/swingmusic/player/presentation/screen/AnimatedPlayerSheet.kt index a3480827..482d63cf 100644 --- a/feature/player/src/main/java/com/android/swingmusic/player/presentation/screen/AnimatedPlayerSheet.kt +++ b/feature/player/src/main/java/com/android/swingmusic/player/presentation/screen/AnimatedPlayerSheet.kt @@ -87,6 +87,10 @@ import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalWindowInfo +import androidx.compose.animation.graphics.ExperimentalAnimationGraphicsApi +import androidx.compose.animation.graphics.res.animatedVectorResource +import androidx.compose.animation.graphics.res.rememberAnimatedVectorPainter +import androidx.compose.animation.graphics.vector.AnimatedImageVector import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -338,6 +342,7 @@ fun AnimatedPlayerSheet( } } +@OptIn(ExperimentalAnimationGraphicsApi::class) @Composable private fun AnimatedSheetContent( track: Track, @@ -698,10 +703,11 @@ private fun AnimatedSheetContent( strokeCap = StrokeCap.Round ) } + val animatedPlayPause = AnimatedImageVector.animatedVectorResource(R.drawable.avd_play_pause) Icon( - painter = painterResource( - id = if (playbackState == PlaybackState.PLAYING) - R.drawable.pause_icon else R.drawable.play_arrow + painter = rememberAnimatedVectorPainter( + animatedImageVector = animatedPlayPause, + atEnd = playbackState == PlaybackState.PLAYING ), contentDescription = "Play/Pause" ) @@ -876,6 +882,10 @@ private fun AnimatedSheetContent( Spacer(modifier = Modifier.height(24.dp)) // Playback controls + // Animation toggle states for prev/next + var prevAnimAtEnd by remember { mutableStateOf(false) } + var nextAnimAtEnd by remember { mutableStateOf(false) } + Row( modifier = Modifier .fillMaxWidth() @@ -883,16 +893,23 @@ private fun AnimatedSheetContent( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceEvenly ) { + val animatedPrev = AnimatedImageVector.animatedVectorResource(R.drawable.avd_prev) IconButton( modifier = Modifier .clip(CircleShape) .background( MaterialTheme.colorScheme.secondaryContainer.copy(alpha = .5f) ), - onClick = { onClickPrev() } + onClick = { + prevAnimAtEnd = !prevAnimAtEnd + onClickPrev() + } ) { Icon( - painter = painterResource(id = R.drawable.prev), + painter = rememberAnimatedVectorPainter( + animatedImageVector = animatedPrev, + atEnd = prevAnimAtEnd + ), contentDescription = "Previous" ) } @@ -939,10 +956,14 @@ private fun AnimatedSheetContent( .background(MaterialTheme.colorScheme.secondaryContainer), contentAlignment = Alignment.Center ) { + val animatedPlayPauseLarge = AnimatedImageVector.animatedVectorResource(R.drawable.avd_play_pause) Icon( modifier = Modifier.size(44.dp), tint = MaterialTheme.colorScheme.onSecondaryContainer, - painter = painterResource(id = playbackStateIcon), + painter = rememberAnimatedVectorPainter( + animatedImageVector = animatedPlayPauseLarge, + atEnd = playbackState == PlaybackState.PLAYING + ), contentDescription = "Play/Pause" ) } @@ -959,16 +980,23 @@ private fun AnimatedSheetContent( } } + val animatedNext = AnimatedImageVector.animatedVectorResource(R.drawable.avd_next) IconButton( modifier = Modifier .clip(CircleShape) .background( MaterialTheme.colorScheme.secondaryContainer.copy(alpha = .5f) ), - onClick = { onClickNext() } + onClick = { + nextAnimAtEnd = !nextAnimAtEnd + onClickNext() + } ) { Icon( - painter = painterResource(id = R.drawable.next), + painter = rememberAnimatedVectorPainter( + animatedImageVector = animatedNext, + atEnd = nextAnimAtEnd + ), tint = MaterialTheme.colorScheme.onSecondaryContainer, contentDescription = "Next" ) diff --git a/uicomponent/src/main/res/drawable/avd_artist.xml b/uicomponent/src/main/res/drawable/avd_artist.xml index a0ac2bfd..cae3bea4 100644 --- a/uicomponent/src/main/res/drawable/avd_artist.xml +++ b/uicomponent/src/main/res/drawable/avd_artist.xml @@ -13,10 +13,16 @@ android:pivotY="480" android:scaleX="1" android:scaleY="1"> - + + + @@ -74,4 +80,24 @@ + + + + + + + + diff --git a/uicomponent/src/main/res/drawable/avd_next.xml b/uicomponent/src/main/res/drawable/avd_next.xml new file mode 100644 index 00000000..3eaf89f0 --- /dev/null +++ b/uicomponent/src/main/res/drawable/avd_next.xml @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/uicomponent/src/main/res/drawable/avd_pause_to_play.xml b/uicomponent/src/main/res/drawable/avd_pause_to_play.xml new file mode 100644 index 00000000..c5b5f9b0 --- /dev/null +++ b/uicomponent/src/main/res/drawable/avd_pause_to_play.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/uicomponent/src/main/res/drawable/avd_play_pause.xml b/uicomponent/src/main/res/drawable/avd_play_pause.xml new file mode 100644 index 00000000..8959f2af --- /dev/null +++ b/uicomponent/src/main/res/drawable/avd_play_pause.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/uicomponent/src/main/res/drawable/avd_play_to_pause.xml b/uicomponent/src/main/res/drawable/avd_play_to_pause.xml new file mode 100644 index 00000000..3106bcb0 --- /dev/null +++ b/uicomponent/src/main/res/drawable/avd_play_to_pause.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/uicomponent/src/main/res/drawable/avd_prev.xml b/uicomponent/src/main/res/drawable/avd_prev.xml new file mode 100644 index 00000000..71d1e296 --- /dev/null +++ b/uicomponent/src/main/res/drawable/avd_prev.xml @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From bfb919c8356aeb91d065fbc42d07c027f009fabe Mon Sep 17 00:00:00 2001 From: Ericgacoki Date: Tue, 23 Dec 2025 00:08:57 +0300 Subject: [PATCH 09/14] Add heart icon animations - Add a new `animated-vector` drawable (`avd_heart.xml`) that defines a heartbeat-like scaling animation. - Add another `animated-vector` drawable (`avd_heart_fill.xml`) for animating the transition between an outline and a filled heart. - Implement a heart bounce and crossfade animation on the favorite icon in the `CustomTrackBottomSheet`. - Apply the same heart bounce and crossfade animation to the favorite icon in the full `AnimatedPlayerSheet`. --- .../screen/AnimatedPlayerSheet.kt | 45 ++++++-- .../component/CustomTrackBottomSheet.kt | 54 ++++++++-- .../src/main/res/drawable/avd_heart.xml | 100 ++++++++++++++++++ .../src/main/res/drawable/avd_heart_fill.xml | 96 +++++++++++++++++ 4 files changed, 279 insertions(+), 16 deletions(-) create mode 100644 uicomponent/src/main/res/drawable/avd_heart.xml create mode 100644 uicomponent/src/main/res/drawable/avd_heart_fill.xml diff --git a/feature/player/src/main/java/com/android/swingmusic/player/presentation/screen/AnimatedPlayerSheet.kt b/feature/player/src/main/java/com/android/swingmusic/player/presentation/screen/AnimatedPlayerSheet.kt index 482d63cf..a5e26549 100644 --- a/feature/player/src/main/java/com/android/swingmusic/player/presentation/screen/AnimatedPlayerSheet.kt +++ b/feature/player/src/main/java/com/android/swingmusic/player/presentation/screen/AnimatedPlayerSheet.kt @@ -10,6 +10,9 @@ import androidx.compose.animation.core.EaseOutQuad import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.LinearOutSlowInEasing import androidx.compose.animation.core.VectorConverter +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn @@ -77,6 +80,7 @@ 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.draw.scale import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap @@ -753,6 +757,7 @@ private fun AnimatedSheetContent( modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally ) { + // Track title and artist Row( modifier = Modifier @@ -808,8 +813,27 @@ private fun AnimatedSheetContent( } } + var heartBounce by remember { mutableStateOf(false) } + val heartScale by animateFloatAsState( + targetValue = if (heartBounce) 1.3f else 1f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMedium + ), + finishedListener = { heartBounce = false }, + label = "heartScale" + ) + + LaunchedEffect(track.isFavorite) { + if (track.isFavorite) { + heartBounce = true + } + } + IconButton( - modifier = Modifier.clip(CircleShape), + modifier = Modifier + .scale(heartScale) + .clip(CircleShape), onClick = { onToggleFavorite( track.isFavorite, @@ -817,12 +841,19 @@ private fun AnimatedSheetContent( ) } ) { - val icon = if (track.isFavorite) R.drawable.fav_filled - else R.drawable.fav_not_filled - Icon( - painter = painterResource(id = icon), - contentDescription = "Favorite" - ) + Crossfade( + targetState = track.isFavorite, + animationSpec = tween(200), + label = "heartCrossfade" + ) { isFavorite -> + Icon( + painter = painterResource( + id = if (isFavorite) R.drawable.fav_filled + else R.drawable.fav_not_filled + ), + contentDescription = "Favorite" + ) + } } } diff --git a/uicomponent/src/main/java/com/android/swingmusic/uicomponent/presentation/component/CustomTrackBottomSheet.kt b/uicomponent/src/main/java/com/android/swingmusic/uicomponent/presentation/component/CustomTrackBottomSheet.kt index 9bf14eb8..962ef6d8 100644 --- a/uicomponent/src/main/java/com/android/swingmusic/uicomponent/presentation/component/CustomTrackBottomSheet.kt +++ b/uicomponent/src/main/java/com/android/swingmusic/uicomponent/presentation/component/CustomTrackBottomSheet.kt @@ -1,5 +1,10 @@ package com.android.swingmusic.uicomponent.presentation.component +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource @@ -25,6 +30,7 @@ import androidx.compose.material3.SheetState import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -32,6 +38,7 @@ 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.scale import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorMatrix import androidx.compose.ui.graphics.ColorMatrixColorFilter @@ -95,15 +102,44 @@ fun CustomTrackBottomSheet( Box( modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterEnd ) { - IconButton(modifier = Modifier.clip(CircleShape), onClick = { - onToggleTrackFavorite(track.trackHash, track.isFavorite) - }) { - val icon = if (isFavorite) R.drawable.fav_filled - else R.drawable.fav_not_filled - Icon( - painter = painterResource(id = icon), - contentDescription = "Favorite" - ) + var heartBounce by remember { mutableStateOf(false) } + val heartScale by animateFloatAsState( + targetValue = if (heartBounce) 1.3f else 1f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMedium + ), + finishedListener = { heartBounce = false }, + label = "heartScale" + ) + + LaunchedEffect(isFavorite) { + if (isFavorite) { + heartBounce = true + } + } + + IconButton( + modifier = Modifier + .scale(heartScale) + .clip(CircleShape), + onClick = { + onToggleTrackFavorite(track.trackHash, track.isFavorite) + } + ) { + Crossfade( + targetState = isFavorite, + animationSpec = tween(200), + label = "heartCrossfade" + ) { favorite -> + Icon( + painter = painterResource( + id = if (favorite) R.drawable.fav_filled + else R.drawable.fav_not_filled + ), + contentDescription = "Favorite" + ) + } } } } diff --git a/uicomponent/src/main/res/drawable/avd_heart.xml b/uicomponent/src/main/res/drawable/avd_heart.xml new file mode 100644 index 00000000..1cb803eb --- /dev/null +++ b/uicomponent/src/main/res/drawable/avd_heart.xml @@ -0,0 +1,100 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/uicomponent/src/main/res/drawable/avd_heart_fill.xml b/uicomponent/src/main/res/drawable/avd_heart_fill.xml new file mode 100644 index 00000000..0f28928d --- /dev/null +++ b/uicomponent/src/main/res/drawable/avd_heart_fill.xml @@ -0,0 +1,96 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From e915f0115c41ad8450d8ba80a95da8bf5aa79a5a Mon Sep 17 00:00:00 2001 From: Ericgacoki Date: Tue, 23 Dec 2025 00:52:00 +0300 Subject: [PATCH 10/14] refactor: Update LaunchedEffect key in FoldersAndTracks The `LaunchedEffect` in `FoldersAndTracks.kt` is updated to re-trigger when `gotoFolderName` or `gotoFolderPath` changes, instead of only running once. This ensures that navigation logic for a "go to folder" action is correctly executed whenever the target folder changes. --- .../swingmusic/folder/presentation/screen/FoldersAndTracks.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/feature/folder/src/main/java/com/android/swingmusic/folder/presentation/screen/FoldersAndTracks.kt b/feature/folder/src/main/java/com/android/swingmusic/folder/presentation/screen/FoldersAndTracks.kt index a66edde8..2fac5c7d 100644 --- a/feature/folder/src/main/java/com/android/swingmusic/folder/presentation/screen/FoldersAndTracks.kt +++ b/feature/folder/src/main/java/com/android/swingmusic/folder/presentation/screen/FoldersAndTracks.kt @@ -548,7 +548,7 @@ fun FoldersAndTracksScreen( foldersViewModel.onFolderUiEvent(FolderUiEvent.OnBackNav(currentFolder)) } - LaunchedEffect(key1 = Unit) { + LaunchedEffect(gotoFolderName, gotoFolderPath) { if (gotoFolderName != null && gotoFolderPath != null) { routeByGotoFolder = true foldersViewModel.resetNavPathsForGotoFolder(gotoFolderPath) From b07c8cb2ce49a1258a98374c431030ae2d486ba3 Mon Sep 17 00:00:00 2001 From: Ericgacoki Date: Wed, 24 Dec 2025 00:50:11 +0300 Subject: [PATCH 11/14] feat: Implement nested scrolling and enhance queue sheet in player - Implement `NestedScrollConnection` in `AnimatedPlayerSheet` to allow dragging the sheet down from the top of the queue list. - Deprecate the legacy `MainActivity`, `NowPlayingScreen`, and `QueueScreen` in favor of the new `AnimatedPlayerSheet`. - Adjust the queue sheet drag response by reducing the drag multiplier. - Refine animations by changing the queue opacity to reach its maximum at 80% of the drag progress. - Ensure the queue list scrolls to the currently playing track only once per session when opened. - Add `application.backup` to `.gitignore`. --- .gitignore | 1 + .../presentation/activity/MainActivity.kt | 6 ++ .../screen/AnimatedPlayerSheet.kt | 72 ++++++++++++++++--- .../player/presentation/screen/NowPlaying.kt | 7 +- .../player/presentation/screen/Queue.kt | 7 +- 5 files changed, 80 insertions(+), 13 deletions(-) diff --git a/.gitignore b/.gitignore index 36913b57..ea5cc6c0 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ local.properties *.keystore keystore-base64.txt /.kotlin/sessions +/application.backup diff --git a/app/src/main/java/com/android/swingmusic/presentation/activity/MainActivity.kt b/app/src/main/java/com/android/swingmusic/presentation/activity/MainActivity.kt index 1606bf16..40bedbd1 100644 --- a/app/src/main/java/com/android/swingmusic/presentation/activity/MainActivity.kt +++ b/app/src/main/java/com/android/swingmusic/presentation/activity/MainActivity.kt @@ -1,3 +1,5 @@ +@file:Suppress("DEPRECATION") + package com.android.swingmusic.presentation.activity import android.annotation.SuppressLint @@ -87,6 +89,10 @@ import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch +@Deprecated( + message = "Legacy MainActivity. Use MainActivityWithAnimatedPlayer instead.", + replaceWith = ReplaceWith("MainActivityWithAnimatedPlayer") +) @AndroidEntryPoint class MainActivity : ComponentActivity() { private val mediaControllerViewModel: MediaControllerViewModel by viewModels() diff --git a/feature/player/src/main/java/com/android/swingmusic/player/presentation/screen/AnimatedPlayerSheet.kt b/feature/player/src/main/java/com/android/swingmusic/player/presentation/screen/AnimatedPlayerSheet.kt index a5e26549..a682ea94 100644 --- a/feature/player/src/main/java/com/android/swingmusic/player/presentation/screen/AnimatedPlayerSheet.kt +++ b/feature/player/src/main/java/com/android/swingmusic/player/presentation/screen/AnimatedPlayerSheet.kt @@ -85,12 +85,16 @@ import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.unit.Velocity import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.animation.graphics.ExperimentalAnimationGraphicsApi import androidx.compose.animation.graphics.res.animatedVectorResource import androidx.compose.animation.graphics.res.rememberAnimatedVectorPainter @@ -179,9 +183,9 @@ fun AnimatedPlayerSheet( var primarySheetProgress by remember { mutableFloatStateOf(0f) } // Queue sheet calculations - val configuration = LocalWindowInfo.current + val configuration = LocalConfiguration.current val density = LocalDensity.current - val screenHeightPx = with(density) { configuration.containerSize.height.dp.toPx() } + val screenHeightPx = with(density) { configuration.screenHeightDp.dp.toPx() } val queueInitialOffset = screenHeightPx * 1f // push sheet off-screen // Calculate expanded offset based on initial image size + padding + system bar @@ -1072,9 +1076,8 @@ private fun AnimatedSheetContent( } ) { _, dragAmount -> coroutineScope.launch { - // Multiplier speeds up drag response (2.5x faster) val newOffset = - (queueSheetOffset.value + (dragAmount.y * 2.5f)) + (queueSheetOffset.value + (dragAmount.y * 1.5f)) .coerceIn( queueExpandedOffset, queueInitialOffset @@ -1240,17 +1243,63 @@ private fun QueueSheetOverlay( } } - // Opacity based on drag progress - reaches 1.0 at 50% drag + // Opacity based on drag progress - reaches 1.0 at 80% drag val queueOpacity by remember { derivedStateOf { - (queueProgress / 0.5f).coerceIn(0f, 1f) + (queueProgress / 0.8f).coerceIn(0f, 1f) } } - // Scroll to playing track when sheet opens + // Track if we've scrolled to playing track this session + var hasScrolledToPlayingTrack by remember { mutableStateOf(false) } + + // Scroll to playing track only on first open LaunchedEffect(queueProgress) { - if (queueProgress > 0.9f && (playingTrackIndex - 1) in queue.indices) { + if (!hasScrolledToPlayingTrack && queueProgress > 0.9f && (playingTrackIndex - 1) in queue.indices) { lazyColumnState.scrollToItem(playingTrackIndex - 1) + hasScrolledToPlayingTrack = true + } + } + + // Nested scroll connection to drag sheet down when list is at top + val nestedScrollConnection = remember { + object : NestedScrollConnection { + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + // When at top of list and user drags down, move sheet instead + // available.y > 0 means finger moving down (trying to scroll up/backward) + val isAtTop = !lazyColumnState.canScrollBackward + val isDraggingDown = available.y > 0 + + if (isAtTop && isDraggingDown) { + val newOffset = (animatedOffset.value + available.y) + .coerceIn(expandedOffset, initialOffset) + + coroutineScope.launch { + isDraggingUp = false + lastOffset = newOffset + animatedOffset.snapTo(newOffset) + } + return Offset(0f, available.y) + } + return Offset.Zero + } + + override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { + // Snap sheet to position after fling + if (animatedOffset.value > expandedOffset) { + val threshold = 0.90f + val targetOffset = if (queueProgress > threshold) { + expandedOffset + } else { + initialOffset + } + animatedOffset.animateTo( + targetValue = targetOffset, + animationSpec = spring(dampingRatio = 0.8f, stiffness = 400f) + ) + } + return super.onPostFling(consumed, available) + } } } @@ -1532,8 +1581,9 @@ private fun QueueSheetOverlay( state = lazyColumnState, modifier = Modifier .weight(1f) - .fillMaxWidth(), - contentPadding = PaddingValues(bottom = 80.dp) + .fillMaxWidth() + .nestedScroll(nestedScrollConnection), + contentPadding = PaddingValues(bottom = 120.dp) ) { itemsIndexed( items = queue, diff --git a/feature/player/src/main/java/com/android/swingmusic/player/presentation/screen/NowPlaying.kt b/feature/player/src/main/java/com/android/swingmusic/player/presentation/screen/NowPlaying.kt index 1ce3a159..c70e2a74 100644 --- a/feature/player/src/main/java/com/android/swingmusic/player/presentation/screen/NowPlaying.kt +++ b/feature/player/src/main/java/com/android/swingmusic/player/presentation/screen/NowPlaying.kt @@ -1,3 +1,5 @@ +@file:Suppress("DEPRECATION") + package com.android.swingmusic.player.presentation.screen import android.content.res.Configuration @@ -598,7 +600,10 @@ private fun NowPlaying( /** * Expose a public Composable tied to MediaControllerViewModel * **/ - +@Deprecated( + message = "Legacy NowPlayingScreen. Use AnimatedPlayerSheet instead.", + replaceWith = ReplaceWith("AnimatedPlayerSheet") +) @Destination @Composable fun NowPlayingScreen( diff --git a/feature/player/src/main/java/com/android/swingmusic/player/presentation/screen/Queue.kt b/feature/player/src/main/java/com/android/swingmusic/player/presentation/screen/Queue.kt index 0fdb0b4b..223ee716 100644 --- a/feature/player/src/main/java/com/android/swingmusic/player/presentation/screen/Queue.kt +++ b/feature/player/src/main/java/com/android/swingmusic/player/presentation/screen/Queue.kt @@ -1,3 +1,5 @@ +@file:Suppress("DEPRECATION") + package com.android.swingmusic.player.presentation.screen import android.content.res.Configuration @@ -412,7 +414,10 @@ private fun Queue( /** * A Composable that ties [Queue] to [MediaControllerViewModel] where its sates are hoisted * */ - +@Deprecated( + message = "Legacy QueueScreen. Use AnimatedPlayerSheet with QueueSheetOverlay instead.", + replaceWith = ReplaceWith("AnimatedPlayerSheet") +) @Destination @Composable fun QueueScreen( From dbe13e18675aabd344de31155faa2d4d056eda7f Mon Sep 17 00:00:00 2001 From: Ericgacoki Date: Sun, 18 Jan 2026 18:20:31 +0300 Subject: [PATCH 12/14] feat: Improve player sheet lifecycle and queue handling - Reset player UI state when the queue is cleared, ensuring a clean slate by nullifying the current track, resetting positions, and setting the playback state to `PAUSED`. - The player sheet now hides completely (`peekHeight = 0.dp`) when no track is playing and automatically expands when a track is added to the queue. - Implement logic to clear the queue by swiping the player sheet down to its hidden state. - Refine the sheet expansion progress calculation by capturing the initial offset only after the sheet has settled, leading to more reliable animations. --- .../screen/AnimatedPlayerSheet.kt | 112 ++++++++++-------- .../viewmodel/MediaControllerViewModel.kt | 13 ++ 2 files changed, 75 insertions(+), 50 deletions(-) diff --git a/feature/player/src/main/java/com/android/swingmusic/player/presentation/screen/AnimatedPlayerSheet.kt b/feature/player/src/main/java/com/android/swingmusic/player/presentation/screen/AnimatedPlayerSheet.kt index a682ea94..9da3994f 100644 --- a/feature/player/src/main/java/com/android/swingmusic/player/presentation/screen/AnimatedPlayerSheet.kt +++ b/feature/player/src/main/java/com/android/swingmusic/player/presentation/screen/AnimatedPlayerSheet.kt @@ -129,6 +129,7 @@ import com.android.swingmusic.uicomponent.presentation.util.getSourceType import ir.mahozad.multiplatform.wavyslider.WaveAnimationSpecs import ir.mahozad.multiplatform.wavyslider.WaveDirection import ir.mahozad.multiplatform.wavyslider.material3.WavySlider +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import java.util.Locale import kotlin.math.pow @@ -164,13 +165,7 @@ fun AnimatedPlayerSheet( val playerUiState by mediaControllerViewModel.playerUiState.collectAsState() val baseUrl by mediaControllerViewModel.baseUrl.collectAsState() - val track = playerUiState.nowPlayingTrack - - // If no track playing, just show content without the sheet - if (track == null) { - content(paddingValues) - return - } + val playingTrack = playerUiState.nowPlayingTrack // Dynamic sheet corner shape var dynamicShape by remember { @@ -197,20 +192,39 @@ fun AnimatedPlayerSheet( val coroutineScope = rememberCoroutineScope() - // Peek height: image size + paddings (sits on top of bottom nav) - val calculatedPeekHeight = - TOTAL_INITIAL_SIZE + INITIAL_PADDING + (INITIAL_PADDING / 2) + paddingValues.calculateBottomPadding() + // Peek height: 0 when no track, otherwise bottom nav + image size + paddings + val calculatedPeekHeight = if (playingTrack != null) { + paddingValues.calculateBottomPadding() + TOTAL_INITIAL_SIZE + INITIAL_PADDING + } else { + 0.dp + } val bottomSheetState = rememberBottomSheetScaffoldState( bottomSheetState = rememberStandardBottomSheetState( initialValue = SheetValue.PartiallyExpanded, - skipHiddenState = true, - confirmValueChange = { newValue -> - newValue != SheetValue.Hidden - } + skipHiddenState = false ) ) + // Clear queue only when transitioning TO Hidden (not on re-entry) + LaunchedEffect(Unit) { + var previousValue = bottomSheetState.bottomSheetState.currentValue + snapshotFlow { bottomSheetState.bottomSheetState.currentValue } + .collect { currentValue -> + if (currentValue == SheetValue.Hidden && previousValue != SheetValue.Hidden) { + mediaControllerViewModel.onQueueEvent(QueueEvent.ClearQueue) + } + previousValue = currentValue + } + } + + // Show sheet when queue has tracks + LaunchedEffect(playerUiState.queue) { + if (playerUiState.queue.isNotEmpty() && bottomSheetState.bottomSheetState.currentValue == SheetValue.Hidden) { + bottomSheetState.bottomSheetState.partialExpand() + } + } + // Check if queue sheet is open val isQueueSheetOpen = queueSheetOffset.value < (screenHeightPx * 0.25f) @@ -247,10 +261,11 @@ fun AnimatedPlayerSheet( sheetContainerColor = MaterialTheme.colorScheme.inverseOnSurface, sheetSwipeEnabled = !isQueueSheetOpen, sheetContent = { - AnimatedSheetContent( - track = track, - queue = playerUiState.queue, - playingTrackIndex = playerUiState.playingTrackIndex, + if (playingTrack != null) { + AnimatedSheetContent( + track = playingTrack, + queue = playerUiState.queue, + playingTrackIndex = playerUiState.playingTrackIndex, seekPosition = playerUiState.seekPosition, playbackDuration = playerUiState.playbackDuration, trackDuration = playerUiState.trackDuration, @@ -303,19 +318,20 @@ fun AnimatedPlayerSheet( PlayerUiEvent.OnToggleFavorite(isFavorite, trackHash) ) } - ) + ) + } } ) { innerPadding -> content(innerPadding) } - // Queue Sheet - appears when primary sheet is fully expanded - if (primarySheetProgress >= 0.95f) { + // Queue Sheet - appears when primary sheet is fully expanded and has a track + if (primarySheetProgress >= 0.95f && playingTrack != null) { QueueSheetOverlay( queue = playerUiState.queue, source = playerUiState.source, playingTrackIndex = playerUiState.playingTrackIndex, - playingTrack = track, + playingTrack = playingTrack, playbackState = playerUiState.playbackState, baseUrl = baseUrl ?: "", animatedOffset = queueSheetOffset, @@ -386,44 +402,40 @@ private fun AnimatedSheetContent( ) { val coroutineScope = rememberCoroutineScope() val configuration = LocalConfiguration.current + // Whatever AS says about this, don't be tempted to change it... ref: Eric val screenWidthDp = configuration.screenWidthDp.dp // Horizontal swipe state for collapsed mode var swipeDistance by remember { mutableFloatStateOf(0f) } - // Store initial offset when first available + // Store initial offset after sheet settles val initialOffset = remember { mutableStateOf(null) } - // Calculate progress using actual initial offset + // Capture initial offset after sheet reaches PartiallyExpanded state + LaunchedEffect(Unit) { + initialOffset.value = null + // Wait for sheet to reach PartiallyExpanded state + snapshotFlow { bottomSheetState.bottomSheetState.currentValue } + .first { it == SheetValue.PartiallyExpanded } + kotlinx.coroutines.delay(50) // Extra settling time after reaching state + try { + initialOffset.value = bottomSheetState.bottomSheetState.requireOffset() + } catch (_: Exception) {} + } + + // Calculate progress using captured initial offset val progress = remember { derivedStateOf { - try { - val currentOffset = bottomSheetState.bottomSheetState.requireOffset() - - if (initialOffset.value == null && currentOffset.isFinite() && currentOffset > 0f) { - initialOffset.value = currentOffset - } - - val collapsedOffset = initialOffset.value ?: currentOffset - val expandedOffset = 0f - val range = collapsedOffset - expandedOffset - - // Avoid division by zero or NaN - if (range <= 0f || !range.isFinite()) { - when (bottomSheetState.bottomSheetState.currentValue) { - SheetValue.PartiallyExpanded -> 0f - SheetValue.Expanded -> 1f - SheetValue.Hidden -> 0f - } - } else { - val rawProgress = (collapsedOffset - currentOffset) / range + val captured = initialOffset.value + if (captured == null) { + 0f // Return 0 until initialized + } else { + try { + val currentOffset = bottomSheetState.bottomSheetState.requireOffset() + val rawProgress = (captured - currentOffset) / captured rawProgress.coerceIn(0f, 1f) - } - } catch (e: Exception) { - when (bottomSheetState.bottomSheetState.currentValue) { - SheetValue.PartiallyExpanded -> 0f - SheetValue.Expanded -> 1f - SheetValue.Hidden -> 0f + } catch (_: Exception) { + 0f } } } diff --git a/feature/player/src/main/java/com/android/swingmusic/player/presentation/viewmodel/MediaControllerViewModel.kt b/feature/player/src/main/java/com/android/swingmusic/player/presentation/viewmodel/MediaControllerViewModel.kt index 0631cbf7..901cabb0 100644 --- a/feature/player/src/main/java/com/android/swingmusic/player/presentation/viewmodel/MediaControllerViewModel.kt +++ b/feature/player/src/main/java/com/android/swingmusic/player/presentation/viewmodel/MediaControllerViewModel.kt @@ -908,6 +908,19 @@ class MediaControllerViewModel @Inject constructor( shuffledQueue.clear() mediaController?.clearMediaItems() trackToLog = null + + _playerUiState.update { currentState -> + currentState.copy( + nowPlayingTrack = null, + queue = emptyList(), + playingTrackIndex = 0, + seekPosition = 0f, + playbackDuration = "00:00", + trackDuration = "00:00", + isBuffering = false, + playbackState = PlaybackState.PAUSED + ) + } } activateHapticResponse() From af51f8cf4bc2b396ae138c903ef5d5cb7bf9b1da Mon Sep 17 00:00:00 2001 From: Ericgacoki Date: Sun, 18 Jan 2026 21:14:49 +0300 Subject: [PATCH 13/14] feat: Implement long-press to close player and refine gestures - Implement a long-press gesture on the collapsed player bar to enable closing the player. A haptic feedback confirms activation, after which a downward drag will hide the sheet. - Refactor the player bar's gesture handling to reliably distinguish between taps (to expand), horizontal swipes (for prev/next), and the new long-press-to-close gesture. - Remove haptic feedback logic from the `MediaControllerViewModel`, moving it to the UI layer within the long-press gesture. - Refine the sheet's corner radius animation to use an exponent, making it flatten more quickly only as it nears full expansion. --- .../screen/AnimatedPlayerSheet.kt | 282 +++++++++++------- .../viewmodel/MediaControllerViewModel.kt | 2 - 2 files changed, 181 insertions(+), 103 deletions(-) diff --git a/feature/player/src/main/java/com/android/swingmusic/player/presentation/screen/AnimatedPlayerSheet.kt b/feature/player/src/main/java/com/android/swingmusic/player/presentation/screen/AnimatedPlayerSheet.kt index 9da3994f..a69c7234 100644 --- a/feature/player/src/main/java/com/android/swingmusic/player/presentation/screen/AnimatedPlayerSheet.kt +++ b/feature/player/src/main/java/com/android/swingmusic/player/presentation/screen/AnimatedPlayerSheet.kt @@ -4,23 +4,29 @@ package com.android.swingmusic.player.presentation.screen import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.Crossfade import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.AnimationVector1D import androidx.compose.animation.core.EaseOutQuad import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.LinearOutSlowInEasing -import androidx.compose.animation.core.VectorConverter -import androidx.compose.animation.Crossfade import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.VectorConverter import androidx.compose.animation.core.animateFloatAsState 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.graphics.ExperimentalAnimationGraphicsApi +import androidx.compose.animation.graphics.res.animatedVectorResource +import androidx.compose.animation.graphics.res.rememberAnimatedVectorPainter +import androidx.compose.animation.graphics.vector.AnimatedImageVector +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.detectDragGestures -import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement @@ -52,10 +58,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.BottomSheetScaffold import androidx.compose.material3.BottomSheetScaffoldState import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.rememberModalBottomSheetState -import com.android.swingmusic.core.domain.model.BottomSheetItemModel -import com.android.swingmusic.core.domain.util.BottomSheetAction -import com.android.swingmusic.uicomponent.presentation.component.CustomTrackBottomSheet import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -64,6 +66,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SheetValue import androidx.compose.material3.Text import androidx.compose.material3.rememberBottomSheetScaffoldState +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberStandardBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -81,36 +84,37 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.scale +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.input.pointer.changedToUp import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.unit.Velocity import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity -import androidx.compose.animation.graphics.ExperimentalAnimationGraphicsApi -import androidx.compose.animation.graphics.res.animatedVectorResource -import androidx.compose.animation.graphics.res.rememberAnimatedVectorPainter -import androidx.compose.animation.graphics.vector.AnimatedImageVector +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.lerp import androidx.compose.ui.unit.sp import coil.compose.AsyncImage import coil.request.ImageRequest import com.android.swingmusic.common.presentation.navigator.CommonNavigator +import com.android.swingmusic.core.domain.model.BottomSheetItemModel import com.android.swingmusic.core.domain.model.Track +import com.android.swingmusic.core.domain.util.BottomSheetAction import com.android.swingmusic.core.domain.util.PlaybackState import com.android.swingmusic.core.domain.util.QueueSource import com.android.swingmusic.core.domain.util.RepeatMode @@ -120,6 +124,7 @@ import com.android.swingmusic.player.presentation.event.QueueEvent import com.android.swingmusic.player.presentation.util.calculateCurrentOffsetForPage import com.android.swingmusic.player.presentation.viewmodel.MediaControllerViewModel import com.android.swingmusic.uicomponent.R +import com.android.swingmusic.uicomponent.presentation.component.CustomTrackBottomSheet import com.android.swingmusic.uicomponent.presentation.component.SoundSignalBars import com.android.swingmusic.uicomponent.presentation.component.TrackItem import com.android.swingmusic.uicomponent.presentation.util.BlurTransformation @@ -177,6 +182,9 @@ fun AnimatedPlayerSheet( // Track primary sheet progress for queue sheet trigger var primarySheetProgress by remember { mutableFloatStateOf(0f) } + // Track if closing sheet is allowed (set by long-press) + var allowSheetClose by remember { mutableStateOf(false) } + // Queue sheet calculations val configuration = LocalConfiguration.current val density = LocalDensity.current @@ -202,18 +210,27 @@ fun AnimatedPlayerSheet( val bottomSheetState = rememberBottomSheetScaffoldState( bottomSheetState = rememberStandardBottomSheetState( initialValue = SheetValue.PartiallyExpanded, - skipHiddenState = false + skipHiddenState = false, + confirmValueChange = { newValue -> + // Only allow Hidden if long-press activated it + newValue != SheetValue.Hidden || allowSheetClose + } ) ) - // Clear queue only when transitioning TO Hidden (not on re-entry) + // Handle sheet state changes LaunchedEffect(Unit) { var previousValue = bottomSheetState.bottomSheetState.currentValue snapshotFlow { bottomSheetState.bottomSheetState.currentValue } .collect { currentValue -> + // Clear queue when transitioning TO Hidden if (currentValue == SheetValue.Hidden && previousValue != SheetValue.Hidden) { mediaControllerViewModel.onQueueEvent(QueueEvent.ClearQueue) } + // Reset close permission when sheet settles to any state + if (currentValue != previousValue) { + allowSheetClose = false + } previousValue = currentValue } } @@ -238,7 +255,8 @@ fun AnimatedPlayerSheet( } // Handle back press: close queue sheet first, then collapse primary sheet - val isPrimarySheetExpanded = bottomSheetState.bottomSheetState.currentValue == SheetValue.Expanded + val isPrimarySheetExpanded = + bottomSheetState.bottomSheetState.currentValue == SheetValue.Expanded BackHandler(enabled = isQueueSheetOpen || isPrimarySheetExpanded) { coroutineScope.launch { if (isQueueSheetOpen) { @@ -266,58 +284,61 @@ fun AnimatedPlayerSheet( track = playingTrack, queue = playerUiState.queue, playingTrackIndex = playerUiState.playingTrackIndex, - seekPosition = playerUiState.seekPosition, - playbackDuration = playerUiState.playbackDuration, - trackDuration = playerUiState.trackDuration, - playbackState = playerUiState.playbackState, - isBuffering = playerUiState.isBuffering, - repeatMode = playerUiState.repeatMode, - shuffleMode = playerUiState.shuffleMode, - baseUrl = baseUrl ?: "", - bottomSheetState = bottomSheetState, - systemBarHeight = systemBarHeight, - onShapeChange = { shape -> dynamicShape = shape }, - onProgressChange = { progress -> - primarySheetProgress = progress - onProgressChange(progress) - }, - primarySheetProgress = primarySheetProgress, - queueSheetOffset = queueSheetOffset, - queueInitialOffset = queueInitialOffset, - queueExpandedOffset = queueExpandedOffset, - queueProgress = queueProgress, - onPageSelect = { page -> - mediaControllerViewModel.onQueueEvent(QueueEvent.SeekToQueueItem(page)) - }, - onClickArtist = { navigator.gotoArtistInfo(it) }, - onToggleRepeatMode = { - mediaControllerViewModel.onPlayerUiEvent(PlayerUiEvent.OnToggleRepeatMode) - }, - onClickPrev = { - mediaControllerViewModel.onPlayerUiEvent(PlayerUiEvent.OnPrev) - }, - onTogglePlayerState = { - mediaControllerViewModel.onPlayerUiEvent(PlayerUiEvent.OnTogglePlayerState) - }, - onResumePlayBackFromError = { - mediaControllerViewModel.onPlayerUiEvent(PlayerUiEvent.OnResumePlaybackFromError) - }, - onClickNext = { - mediaControllerViewModel.onPlayerUiEvent(PlayerUiEvent.OnNext) - }, - onToggleShuffleMode = { - mediaControllerViewModel.onPlayerUiEvent( - PlayerUiEvent.OnToggleShuffleMode(toggleShuffle = true) - ) - }, - onSeekPlayBack = { value -> - mediaControllerViewModel.onPlayerUiEvent(PlayerUiEvent.OnSeekPlayBack(value)) - }, - onToggleFavorite = { isFavorite, trackHash -> - mediaControllerViewModel.onPlayerUiEvent( - PlayerUiEvent.OnToggleFavorite(isFavorite, trackHash) - ) - } + seekPosition = playerUiState.seekPosition, + playbackDuration = playerUiState.playbackDuration, + trackDuration = playerUiState.trackDuration, + playbackState = playerUiState.playbackState, + isBuffering = playerUiState.isBuffering, + repeatMode = playerUiState.repeatMode, + shuffleMode = playerUiState.shuffleMode, + baseUrl = baseUrl ?: "", + bottomSheetState = bottomSheetState, + systemBarHeight = systemBarHeight, + onShapeChange = { shape -> dynamicShape = shape }, + onProgressChange = { progress -> + primarySheetProgress = progress + onProgressChange(progress) + }, + primarySheetProgress = primarySheetProgress, + queueSheetOffset = queueSheetOffset, + queueInitialOffset = queueInitialOffset, + queueExpandedOffset = queueExpandedOffset, + queueProgress = queueProgress, + onPageSelect = { page -> + mediaControllerViewModel.onQueueEvent(QueueEvent.SeekToQueueItem(page)) + }, + onClickArtist = { navigator.gotoArtistInfo(it) }, + onToggleRepeatMode = { + mediaControllerViewModel.onPlayerUiEvent(PlayerUiEvent.OnToggleRepeatMode) + }, + onClickPrev = { + mediaControllerViewModel.onPlayerUiEvent(PlayerUiEvent.OnPrev) + }, + onTogglePlayerState = { + mediaControllerViewModel.onPlayerUiEvent(PlayerUiEvent.OnTogglePlayerState) + }, + onResumePlayBackFromError = { + mediaControllerViewModel.onPlayerUiEvent(PlayerUiEvent.OnResumePlaybackFromError) + }, + onClickNext = { + mediaControllerViewModel.onPlayerUiEvent(PlayerUiEvent.OnNext) + }, + onToggleShuffleMode = { + mediaControllerViewModel.onPlayerUiEvent( + PlayerUiEvent.OnToggleShuffleMode(toggleShuffle = true) + ) + }, + onSeekPlayBack = { value -> + mediaControllerViewModel.onPlayerUiEvent(PlayerUiEvent.OnSeekPlayBack(value)) + }, + onToggleFavorite = { isFavorite, trackHash -> + mediaControllerViewModel.onPlayerUiEvent( + PlayerUiEvent.OnToggleFavorite(isFavorite, trackHash) + ) + }, + onAllowSheetClose = { + allowSheetClose = true + } ) } } @@ -366,7 +387,7 @@ fun AnimatedPlayerSheet( } } -@OptIn(ExperimentalAnimationGraphicsApi::class) +@OptIn(ExperimentalAnimationGraphicsApi::class, ExperimentalFoundationApi::class) @Composable private fun AnimatedSheetContent( track: Track, @@ -398,12 +419,14 @@ private fun AnimatedSheetContent( onClickNext: () -> Unit, onToggleShuffleMode: () -> Unit, onSeekPlayBack: (Float) -> Unit, - onToggleFavorite: (Boolean, String) -> Unit + onToggleFavorite: (Boolean, String) -> Unit, + onAllowSheetClose: () -> Unit ) { val coroutineScope = rememberCoroutineScope() val configuration = LocalConfiguration.current // Whatever AS says about this, don't be tempted to change it... ref: Eric val screenWidthDp = configuration.screenWidthDp.dp + val hapticFeedback = LocalHapticFeedback.current // Horizontal swipe state for collapsed mode var swipeDistance by remember { mutableFloatStateOf(0f) } @@ -420,7 +443,8 @@ private fun AnimatedSheetContent( kotlinx.coroutines.delay(50) // Extra settling time after reaching state try { initialOffset.value = bottomSheetState.bottomSheetState.requireOffset() - } catch (_: Exception) {} + } catch (_: Exception) { + } } // Calculate progress using captured initial offset @@ -458,8 +482,9 @@ private fun AnimatedSheetContent( // Dynamic spacing between image and content val imageContentSpacing = lerp(60.dp, 16.dp, progress.value) - // Dynamic sheet corner radius: 12dp at peek → 0dp at 50% progress - val sheetCornerRadius = lerp(12.dp, 0.dp, (progress.value / 0.5f).coerceIn(0f, 1f)) + // Dynamic sheet corner radius: 12dp at peek → 0dp at full expansion + // Using pow(3) easing to maintain curved shape longer, then flatten quickly near the top + val sheetCornerRadius = lerp(12.dp, 0.dp, progress.value.pow(3)) // Dynamic container padding val containerPadding = lerp(INITIAL_PADDING, 24.dp, effectiveProgress) @@ -588,34 +613,80 @@ private fun AnimatedSheetContent( .fillMaxWidth() .padding(top = imageTopPadding) .padding(horizontal = containerPadding) - .then( - // Horizontal swipe for prev/next in collapsed state - if (progress.value < 0.3f) { - Modifier.pointerInput(Unit) { - detectHorizontalDragGestures( - onDragEnd = { - if (swipeDistance > 50) { - onClickPrev() - } else if (swipeDistance < -50) { - onClickNext() + .pointerInput(progress.value < 0.3f, queueProgress < 0.1f) { + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + var longPressTriggered = false + var closePermissionGranted = false + var totalDragX = 0f + var totalDragY = 0f + var lastX = down.position.x + var lastY = down.position.y + + // Launch coroutine to trigger haptic after 500ms + val longPressJob = coroutineScope.launch { + kotlinx.coroutines.delay(500L) + if (progress.value < 0.3f) { + longPressTriggered = true + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + swipeDistance = 0f + } + } + + while (true) { + val event = awaitPointerEvent() + val change = event.changes.firstOrNull { it.id == down.id } + + if (change == null || change.changedToUp()) { + // Finger lifted - cancel long press job if still pending + longPressJob.cancel() + + if (!longPressTriggered && progress.value < 0.3f) { + // Check for tap vs horizontal swipe + if (kotlin.math.abs(totalDragX) > 50) { + // Horizontal swipe + if (totalDragX > 50) onClickPrev() + else onClickNext() + } else if (progress.value < 0.5f && queueProgress < 0.1f) { + // Tap - expand sheet + coroutineScope.launch { + bottomSheetState.bottomSheetState.expand() + } } - swipeDistance = 0f } - ) { change, dragAmount -> - change.consume() - swipeDistance += dragAmount + break + } + + // Track movement + val currentX = change.position.x + val currentY = change.position.y + totalDragX += currentX - lastX + totalDragY += currentY - lastY + lastX = currentX + lastY = currentY + + // Cancel long-press if user starts dragging (not a hold gesture) + if (kotlin.math.abs(totalDragY) > 20f || kotlin.math.abs(totalDragX) > 20f) { + longPressJob.cancel() + } + + // Update swipe distance for visual feedback + if (progress.value < 0.3f && !longPressTriggered) { + swipeDistance = totalDragX + } + + // Allow sheet close only when dragging DOWN after long press + if (longPressTriggered && + !closePermissionGranted && + totalDragY > 10f + ) { + onAllowSheetClose() + closePermissionGranted = true } } - } else Modifier - ) - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() } - ) { - if (progress.value < 0.5f && queueProgress < 0.1f) { - coroutineScope.launch { - bottomSheetState.bottomSheetState.expand() - } + + // Reset swipe distance on gesture end + swipeDistance = 0f } } ) { @@ -723,7 +794,8 @@ private fun AnimatedSheetContent( strokeCap = StrokeCap.Round ) } - val animatedPlayPause = AnimatedImageVector.animatedVectorResource(R.drawable.avd_play_pause) + val animatedPlayPause = + AnimatedImageVector.animatedVectorResource(R.drawable.avd_play_pause) Icon( painter = rememberAnimatedVectorPainter( animatedImageVector = animatedPlayPause, @@ -940,7 +1012,8 @@ private fun AnimatedSheetContent( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceEvenly ) { - val animatedPrev = AnimatedImageVector.animatedVectorResource(R.drawable.avd_prev) + val animatedPrev = + AnimatedImageVector.animatedVectorResource(R.drawable.avd_prev) IconButton( modifier = Modifier .clip(CircleShape) @@ -1003,7 +1076,8 @@ private fun AnimatedSheetContent( .background(MaterialTheme.colorScheme.secondaryContainer), contentAlignment = Alignment.Center ) { - val animatedPlayPauseLarge = AnimatedImageVector.animatedVectorResource(R.drawable.avd_play_pause) + val animatedPlayPauseLarge = + AnimatedImageVector.animatedVectorResource(R.drawable.avd_play_pause) Icon( modifier = Modifier.size(44.dp), tint = MaterialTheme.colorScheme.onSecondaryContainer, @@ -1027,7 +1101,8 @@ private fun AnimatedSheetContent( } } - val animatedNext = AnimatedImageVector.animatedVectorResource(R.drawable.avd_next) + val animatedNext = + AnimatedImageVector.animatedVectorResource(R.drawable.avd_next) IconButton( modifier = Modifier .clip(CircleShape) @@ -1363,10 +1438,12 @@ private fun QueueSheetOverlay( onGotoAlbum(sheetTrack.albumHash) onCloseSheets() } + is BottomSheetAction.GotoFolder -> { onGotoFolder(sheetAction.name, sheetAction.path) onCloseSheets() } + is BottomSheetAction.PlayNext -> onPlayNext(sheetTrack) else -> {} } @@ -1476,14 +1553,17 @@ private fun QueueSheetOverlay( onGotoAlbum(source.albumHash) onCloseSheets() } + is QueueSource.ARTIST -> { onClickArtist(source.artistHash) onCloseSheets() } + is QueueSource.FOLDER -> { onGotoFolder(source.name, source.path) onCloseSheets() } + else -> {} } } diff --git a/feature/player/src/main/java/com/android/swingmusic/player/presentation/viewmodel/MediaControllerViewModel.kt b/feature/player/src/main/java/com/android/swingmusic/player/presentation/viewmodel/MediaControllerViewModel.kt index 901cabb0..44e7ca3f 100644 --- a/feature/player/src/main/java/com/android/swingmusic/player/presentation/viewmodel/MediaControllerViewModel.kt +++ b/feature/player/src/main/java/com/android/swingmusic/player/presentation/viewmodel/MediaControllerViewModel.kt @@ -922,8 +922,6 @@ class MediaControllerViewModel @Inject constructor( ) } } - - activateHapticResponse() } } } From 621d2354e7a643e2133918b3bc26f4e339d9117c Mon Sep 17 00:00:00 2001 From: Ericgacoki Date: Sun, 18 Jan 2026 22:44:31 +0300 Subject: [PATCH 14/14] refactor: Disable minify in library modules for release builds Minification (`isMinifyEnabled = false`) is disabled for all library modules (`core`, `database`, `network`, `uicomponent`, and all `feature/*` modules) in their release build configurations. This addresses issues with Hilt's annotation processing, which requires access to unobfuscated class names (like DAOs) from the app module. The final APK's code will still be minified by the app module's R8 process. --- auth/build.gradle.kts | 2 +- core/build.gradle.kts | 2 +- database/build.gradle.kts | 4 +++- feature/album/build.gradle.kts | 2 +- feature/artist/build.gradle.kts | 2 +- feature/folder/build.gradle.kts | 2 +- feature/home/build.gradle.kts | 2 +- feature/player/build.gradle.kts | 2 +- .../player/presentation/screen/AnimatedPlayerSheet.kt | 2 +- feature/search/build.gradle.kts | 2 +- feature/settings/build.gradle.kts | 2 +- network/build.gradle.kts | 2 +- uicomponent/build.gradle.kts | 2 +- 13 files changed, 15 insertions(+), 13 deletions(-) diff --git a/auth/build.gradle.kts b/auth/build.gradle.kts index 07b4cf4c..1462d88f 100644 --- a/auth/build.gradle.kts +++ b/auth/build.gradle.kts @@ -18,7 +18,7 @@ android { buildTypes { release { - isMinifyEnabled = true + isMinifyEnabled = false } } compileOptions { diff --git a/core/build.gradle.kts b/core/build.gradle.kts index b1e4ccc4..75b0f78a 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -15,7 +15,7 @@ android { buildTypes { release { - isMinifyEnabled = true + isMinifyEnabled = false } } compileOptions { diff --git a/database/build.gradle.kts b/database/build.gradle.kts index cfaace7f..50776682 100644 --- a/database/build.gradle.kts +++ b/database/build.gradle.kts @@ -16,7 +16,9 @@ android { buildTypes { release { - isMinifyEnabled = true + // Disabled: Hilt annotation processing in app module needs to see DAO classes + // App module's R8 will still minify this code in the final APK + isMinifyEnabled = false } } compileOptions { diff --git a/feature/album/build.gradle.kts b/feature/album/build.gradle.kts index 38cf142f..d9db41ee 100644 --- a/feature/album/build.gradle.kts +++ b/feature/album/build.gradle.kts @@ -18,7 +18,7 @@ android { buildTypes { release { - isMinifyEnabled = true + isMinifyEnabled = false } } compileOptions { diff --git a/feature/artist/build.gradle.kts b/feature/artist/build.gradle.kts index 3032442c..c7e89e87 100644 --- a/feature/artist/build.gradle.kts +++ b/feature/artist/build.gradle.kts @@ -18,7 +18,7 @@ android { buildTypes { release { - isMinifyEnabled = true + isMinifyEnabled = false } } compileOptions { diff --git a/feature/folder/build.gradle.kts b/feature/folder/build.gradle.kts index da450128..211485b9 100644 --- a/feature/folder/build.gradle.kts +++ b/feature/folder/build.gradle.kts @@ -18,7 +18,7 @@ android { buildTypes { release { - isMinifyEnabled = true + isMinifyEnabled = false } } compileOptions { diff --git a/feature/home/build.gradle.kts b/feature/home/build.gradle.kts index 905ad7c0..e471a977 100644 --- a/feature/home/build.gradle.kts +++ b/feature/home/build.gradle.kts @@ -17,7 +17,7 @@ android { buildTypes { release { - isMinifyEnabled = true + isMinifyEnabled = false } } compileOptions { diff --git a/feature/player/build.gradle.kts b/feature/player/build.gradle.kts index 55c06117..273c4350 100644 --- a/feature/player/build.gradle.kts +++ b/feature/player/build.gradle.kts @@ -18,7 +18,7 @@ android { buildTypes { release { - isMinifyEnabled = true + isMinifyEnabled = false } } compileOptions { diff --git a/feature/player/src/main/java/com/android/swingmusic/player/presentation/screen/AnimatedPlayerSheet.kt b/feature/player/src/main/java/com/android/swingmusic/player/presentation/screen/AnimatedPlayerSheet.kt index a69c7234..a8bb55a2 100644 --- a/feature/player/src/main/java/com/android/swingmusic/player/presentation/screen/AnimatedPlayerSheet.kt +++ b/feature/player/src/main/java/com/android/swingmusic/player/presentation/screen/AnimatedPlayerSheet.kt @@ -140,7 +140,7 @@ import java.util.Locale import kotlin.math.pow import kotlin.math.roundToInt -// Constants for sheet sizing (matching SheetDemo) +// Constants for sheet sizing private val INITIAL_IMAGE_SIZE = 38.dp private val INITIAL_PADDING = 8.dp private val TOTAL_INITIAL_SIZE = INITIAL_IMAGE_SIZE + INITIAL_PADDING diff --git a/feature/search/build.gradle.kts b/feature/search/build.gradle.kts index f030a88c..46be56db 100644 --- a/feature/search/build.gradle.kts +++ b/feature/search/build.gradle.kts @@ -18,7 +18,7 @@ android { buildTypes { release { - isMinifyEnabled = true + isMinifyEnabled = false } } compileOptions { diff --git a/feature/settings/build.gradle.kts b/feature/settings/build.gradle.kts index c5c771d3..f8173ae1 100644 --- a/feature/settings/build.gradle.kts +++ b/feature/settings/build.gradle.kts @@ -19,7 +19,7 @@ android { buildTypes { release { - isMinifyEnabled = true + isMinifyEnabled = false } } compileOptions { diff --git a/network/build.gradle.kts b/network/build.gradle.kts index 5bfbc739..fd78781a 100644 --- a/network/build.gradle.kts +++ b/network/build.gradle.kts @@ -17,7 +17,7 @@ android { buildTypes { release { - isMinifyEnabled = true + isMinifyEnabled = false } } diff --git a/uicomponent/build.gradle.kts b/uicomponent/build.gradle.kts index cbeb141f..69cbaa20 100644 --- a/uicomponent/build.gradle.kts +++ b/uicomponent/build.gradle.kts @@ -16,7 +16,7 @@ android { buildTypes { release { - isMinifyEnabled = true + isMinifyEnabled = false } } compileOptions {