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 84b229c1..d18d19c8 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 @@ -23,6 +23,8 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -201,8 +203,10 @@ class MainActivityWithAnimatedPlayer : ComponentActivity() { val navBarAlpha = 1f - navBarSlideProgress SwingMusicTheme { + val snackbarHostState = remember { SnackbarHostState() } Scaffold( modifier = Modifier.fillMaxSize(), + snackbarHost = { SnackbarHost(snackbarHostState) }, bottomBar = { // Only show navigation bar when logged in and not on auth screens if (showBottomNav) { @@ -309,6 +313,7 @@ class MainActivityWithAnimatedPlayer : ComponentActivity() { paddingValues = paddingValues, mediaControllerViewModel = mediaControllerViewModel, navigator = navigator, + snackbarHostState = snackbarHostState, onProgressChange = { progress -> sheetProgress = progress } 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 62fa7849..9c41fbcf 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 @@ -64,6 +64,9 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SheetValue +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult import androidx.compose.material3.Text import androidx.compose.material3.rememberBottomSheetScaffoldState import androidx.compose.material3.rememberModalBottomSheetState @@ -164,6 +167,7 @@ fun AnimatedPlayerSheet( paddingValues: PaddingValues, mediaControllerViewModel: MediaControllerViewModel, navigator: CommonNavigator, + snackbarHostState: SnackbarHostState, onProgressChange: (progress: Float) -> Unit = {}, content: @Composable (PaddingValues) -> Unit ) { @@ -188,6 +192,7 @@ fun AnimatedPlayerSheet( // Lyrics overlay visibility (shown on top of the player without leaving the screen) var showLyrics by remember { mutableStateOf(false) } + // Queue sheet calculations val configuration = LocalConfiguration.current val density = LocalDensity.current @@ -226,9 +231,21 @@ fun AnimatedPlayerSheet( var previousValue = bottomSheetState.bottomSheetState.currentValue snapshotFlow { bottomSheetState.bottomSheetState.currentValue } .collect { currentValue -> - // Clear queue when transitioning TO Hidden + // Clear queue when transitioning TO Hidden (drag-down-to-dismiss), + // with a haptic and an Undo snackbar to restore. if (currentValue == SheetValue.Hidden && previousValue != SheetValue.Hidden) { + // ClearQueue fires the vibrant destructive haptic from the VM. mediaControllerViewModel.onQueueEvent(QueueEvent.ClearQueue) + coroutineScope.launch { + val result = snackbarHostState.showSnackbar( + message = "Queue cleared", + actionLabel = "Undo", + duration = SnackbarDuration.Short + ) + if (result == SnackbarResult.ActionPerformed) { + mediaControllerViewModel.restoreClearedQueue() + } + } } // Reset close permission when sheet settles to any state if (currentValue != previousValue) { @@ -257,22 +274,6 @@ 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, @@ -352,6 +353,24 @@ fun AnimatedPlayerSheet( content(innerPadding) } + // Back press handling — declared AFTER the BottomSheetScaffold (and therefore after the + // NavHost content), so this handler wins over the current screen's nav-back while the + // player is expanded or the queue is open. Close the queue first, then minimize. + 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() + } + } + } + // Queue Sheet - appears when primary sheet is fully expanded and has a track. // Only compose it while it's actually open or animating (offset moved off its parked // position). When fully parked, its offset (screenHeightDp, which omits the system-bar @@ -575,7 +594,10 @@ private fun AnimatedSheetContent( } } - snapshotFlow { pagerState.currentPage }.collect { page -> + // Commit the track change only once the pager has settled (i.e. on release), + // not while dragging. currentPage flips at the halfway point, which caused + // premature / unwanted track switches on partial swipes. + snapshotFlow { pagerState.settledPage }.collect { page -> if (isInitialComposition) { isInitialComposition = false } else { @@ -636,6 +658,9 @@ private fun AnimatedSheetContent( .pointerInput(progress.value < 0.3f, queueProgress < 0.1f) { awaitEachGesture { val down = awaitFirstDown(requireUnconsumed = false) + // Drag-to-dismiss only applies when the gesture starts on the + // mini-player, so minimizing the full player can't reach Hidden. + val startedCollapsed = progress.value < 0.3f var longPressTriggered = false var closePermissionGranted = false var totalDragX = 0f @@ -695,10 +720,13 @@ private fun AnimatedSheetContent( swipeDistance = totalDragX } - // Allow sheet close only when dragging DOWN after long press - if (longPressTriggered && - !closePermissionGranted && - totalDragY > 10f + // Allow the sheet to settle to Hidden on a deliberate downward, + // vertical-dominant drag of the mini-player (no long-press). The + // sheet's own drag then dismisses it, which clears the queue. + if (!closePermissionGranted && + startedCollapsed && + totalDragY > 24f && + kotlin.math.abs(totalDragY) > kotlin.math.abs(totalDragX) ) { onAllowSheetClose() closePermissionGranted = true @@ -969,11 +997,23 @@ private fun AnimatedSheetContent( // Seek bar Column(modifier = Modifier.padding(horizontal = 24.dp)) { + // Seek only on release: while dragging we track the value + // locally and show it, then commit a single seek on finish. + // Avoids a flood of seek/API calls during the drag. + var isSeeking by remember { mutableStateOf(false) } + var seekValue by remember { mutableFloatStateOf(seekPosition) } + WavySlider( modifier = Modifier.height(12.dp), - value = seekPosition, - onValueChangeFinished = {}, - onValueChange = { value -> onSeekPlayBack(value) }, + value = if (isSeeking) seekValue else seekPosition, + onValueChange = { value -> + isSeeking = true + seekValue = value + }, + onValueChangeFinished = { + onSeekPlayBack(seekValue) + isSeeking = false + }, waveLength = 32.dp, waveHeight = if (animateWave) 8.dp else 0.dp, waveVelocity = 16.dp to WaveDirection.HEAD, @@ -1379,12 +1419,12 @@ private fun QueueSheetOverlay( 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) + // available.y > 0 = finger moving down, < 0 = finger moving up. val isAtTop = !lazyColumnState.canScrollBackward - val isDraggingDown = available.y > 0 + val sheetNotFullyOpen = animatedOffset.value > expandedOffset + 1f - if (isAtTop && isDraggingDown) { + // At the top of the list and dragging down → drag the sheet down. + if (isAtTop && available.y > 0) { val newOffset = (animatedOffset.value + available.y) .coerceIn(expandedOffset, initialOffset) @@ -1395,6 +1435,22 @@ private fun QueueSheetOverlay( } return Offset(0f, available.y) } + + // Sheet not fully open and dragging up → drag the sheet back up instead + // of letting the list capture the reversal. Keeps a quick down-then-up + // gesture controlling the sheet rather than scrolling the tracks list. + if (sheetNotFullyOpen && available.y < 0) { + val newOffset = (animatedOffset.value + available.y) + .coerceIn(expandedOffset, initialOffset) + + coroutineScope.launch { + isDraggingUp = true + lastOffset = newOffset + animatedOffset.snapTo(newOffset) + } + return Offset(0f, available.y) + } + return Offset.Zero } @@ -1633,7 +1689,17 @@ private fun QueueSheetOverlay( .padding(horizontal = 12.dp) .clip(RoundedCornerShape(12.dp)) .background(MaterialTheme.colorScheme.onSurface.copy(alpha = .14f)) - .clickable { onTogglePlayerState() } + // Tapping the pinned now-playing closes the queue — the exact inverse of + // the Queue icon — leaving the player sheet expanded behind it. Play/pause + // is handled by the button on the right. + .clickable { + coroutineScope.launch { + animatedOffset.animateTo( + targetValue = initialOffset, + animationSpec = spring(dampingRatio = 0.8f, stiffness = 400f) + ) + } + } .padding(8.dp), contentAlignment = Alignment.CenterStart ) { diff --git a/feature/player/src/main/java/com/android/swingmusic/player/presentation/screen/LyricsScreen.kt b/feature/player/src/main/java/com/android/swingmusic/player/presentation/screen/LyricsScreen.kt index f4aa04b4..a53414fa 100644 --- a/feature/player/src/main/java/com/android/swingmusic/player/presentation/screen/LyricsScreen.kt +++ b/feature/player/src/main/java/com/android/swingmusic/player/presentation/screen/LyricsScreen.kt @@ -1,6 +1,7 @@ package com.android.swingmusic.player.presentation.screen import android.content.res.Configuration +import android.widget.Toast import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateColorAsState @@ -11,8 +12,10 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -56,8 +59,10 @@ import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview @@ -195,7 +200,7 @@ private fun LyricsOverlayContent( progress = { progress }, modifier = Modifier .fillMaxWidth() - .height(2.dp), + .height(1.dp), color = MaterialTheme.colorScheme.primary, trackColor = MaterialTheme.colorScheme.outlineVariant, gapSize = 0.dp, @@ -395,6 +400,7 @@ private fun LyricsBody( } } +@OptIn(ExperimentalFoundationApi::class) @Composable private fun SyncedLyricsList( state: LyricsUiState, @@ -402,6 +408,8 @@ private fun SyncedLyricsList( onUserScrolled: (Boolean) -> Unit ) { val listState = rememberLazyListState() + val clipboard = LocalClipboardManager.current + val context = LocalContext.current LaunchedEffect(listState.isScrollInProgress) { if (listState.isScrollInProgress) onUserScrolled(true) @@ -467,7 +475,15 @@ private fun SyncedLyricsList( Box( modifier = Modifier .fillMaxWidth() - .clickable { onSeek(line.time) } + .combinedClickable( + onClick = { onSeek(line.time) }, + onLongClick = { + if (line.text.isNotBlank()) { + clipboard.setText(AnnotatedString(line.text.trim())) + Toast.makeText(context, "Copied", Toast.LENGTH_SHORT).show() + } + } + ) .padding(horizontal = 24.dp, vertical = 4.dp) ) { Text( @@ -501,10 +517,13 @@ private fun SyncedLyricsList( } } +@OptIn(ExperimentalFoundationApi::class) @Composable private fun UnsyncedLyricsList( state: com.android.swingmusic.player.presentation.state.LyricsUiState ) { + val clipboard = LocalClipboardManager.current + val context = LocalContext.current LazyColumn( modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(horizontal = 24.dp, vertical = 32.dp), @@ -515,7 +534,16 @@ private fun UnsyncedLyricsList( text = line.text.ifBlank { " " }, fontSize = 24.sp, fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onSurface + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.combinedClickable( + onClick = { }, + onLongClick = { + if (line.text.isNotBlank()) { + clipboard.setText(AnnotatedString(line.text.trim())) + Toast.makeText(context, "Copied", Toast.LENGTH_SHORT).show() + } + } + ) ) } if (state.copyright.isNotBlank()) { 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 ec26bbed..dd86862f 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 @@ -165,8 +165,13 @@ class MediaControllerViewModel @Inject constructor( mediaController?.pause() mediaController?.play() } else { - mediaController?.play() - mediaController?.pause() + // Paused on reconnect: seed the UI state directly. A + // play()/pause() toggle here is async and can race, + // intermittently leaving a paused track playing (e.g. when + // the player is first expanded after reconnect). + _playerUiState.value = _playerUiState.value.copy( + playbackState = PlaybackState.PAUSED + ) } currentMediaItemId?.let { @@ -653,6 +658,16 @@ class MediaControllerViewModel @Inject constructor( } fun onPlayerUiEvent(event: PlayerUiEvent) { + // Central soft haptic for committed, state-changing actions. + when (event) { + is OnTogglePlayerState, is OnNext, is OnPrev, is OnSeekPlayBack, + is OnToggleFavorite, is OnToggleRepeatMode, + is PlayerUiEvent.OnToggleShuffleMode, is OnResumePlaybackFromError -> + softHaptic() + + else -> Unit + } + mediaController?.let { controller -> when (event) { is OnSeekPlayBack -> { @@ -848,6 +863,28 @@ class MediaControllerViewModel @Inject constructor( } } + private data class ClearedQueueSnapshot( + val tracks: List, + val index: Int, + val source: QueueSource, + val wasPlaying: Boolean + ) + + // Holds the last cleared queue so a drag-to-dismiss can be undone. + private var clearedQueueSnapshot: ClearedQueueSnapshot? = null + + fun restoreClearedQueue() { + val snapshot = clearedQueueSnapshot ?: return + clearedQueueSnapshot = null + if (snapshot.tracks.isEmpty()) return + createNewQueue( + tracks = snapshot.tracks, + startIndex = snapshot.index.coerceIn(0, snapshot.tracks.lastIndex), + autoPlay = snapshot.wasPlaying, + source = snapshot.source + ) + } + fun onQueueEvent(event: QueueEvent) { when (event) { is QueueEvent.RecreateQueue -> { @@ -898,6 +935,7 @@ class MediaControllerViewModel @Inject constructor( mediaController?.prepare() mediaController?.seekTo(event.index, 0L) mediaController?.playWhenReady = true + softHaptic() } is QueueEvent.PlayNext -> { @@ -911,6 +949,23 @@ class MediaControllerViewModel @Inject constructor( } is QueueEvent.ClearQueue -> { + // Snapshot the queue first so the clear can be undone. + val current = _playerUiState.value + clearedQueueSnapshot = if (current.queue.isNotEmpty()) { + // Vibrant feedback for this destructive action. + destructiveHaptic() + ClearedQueueSnapshot( + // Defensive copy: _playerUiState.queue can be the same list + // instance as workingQueue, which clearQueue() empties in place. + tracks = current.queue.toList(), + index = current.playingTrackIndex, + source = current.source, + wasPlaying = current.playbackState == PlaybackState.PLAYING + ) + } else { + null + } + viewModelScope.launch { pLayerRepository.clearQueue() @@ -1169,6 +1224,20 @@ class MediaControllerViewModel @Inject constructor( vibrator.vibrate(effect) } + /** A single soft tick for committed, state-changing player actions. */ + private fun softHaptic() { + val effect = VibrationEffect.createOneShot(18, 30) + vibrator.vibrate(effect) + } + + /** A pronounced double-buzz for destructive actions (e.g. clearing the queue). */ + private fun destructiveHaptic() { + val timings = longArrayOf(0, 50, 60, 90) + val amplitudes = intArrayOf(0, 200, 0, 255) + val effect = VibrationEffect.createWaveform(timings, amplitudes, -1) + vibrator.vibrate(effect) + } + private fun cancelQueueExpansion() { queueExpansionJob?.cancel() queueExpansionJob = null