From cf387e1e73e213adcf7432e06947754a9bf10c64 Mon Sep 17 00:00:00 2001 From: Ericgacoki Date: Sun, 14 Jun 2026 11:46:20 +0300 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20player=20improvements=20=E2=80=94?= =?UTF-8?q?=20gestures,=20haptics,=20queue,=20lyrics=20&=20back-handling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Player screen: - Pager commits the track change on release (settledPage) instead of at the 50% point, fixing premature/unwanted track switches. - Seekbar seeks only on release; dragging no longer fires a flood of seek/API calls. - Drag the mini-player down to dismiss: clears the queue (player + VM cache) with a vibrant haptic and an Undo snackbar (hosted at the app Scaffold so it stays visible after dismiss). Axis-based, so it doesn't clash with the prev/next swipe (ref #68). - Expanding the mini-player no longer auto-starts a paused track (replaced the racy play()/pause() seed on session reconnect with a direct state seed). - Central soft haptic on committed actions (play/pause, next/prev, seek-release, shuffle, repeat, favorite). Queue sheet: - Tapping the pinned now-playing closes the queue (exact reverse of the Queue icon); the player stays expanded. - Reversing a downward drag to upward pulls the sheet back up instead of the tracks list capturing it. Lyrics: - Reduced the progress-bar (divider) height. - Long-press a lyric line to copy its text. Back navigation: - With the player expanded over another screen, device Back minimizes the player (closing the queue first if open) instead of navigating the screen behind — the BackHandler is now registered after the NavHost so it wins. Follow-up: - Migrate haptics off direct Vibrator calls to Compose's native HapticFeedback (#123). --- .../MainActivityWithAnimatedPlayer.kt | 5 + .../screen/AnimatedPlayerSheet.kt | 126 +++++++++++++----- .../presentation/screen/LyricsScreen.kt | 34 ++++- .../viewmodel/MediaControllerViewModel.kt | 73 +++++++++- 4 files changed, 203 insertions(+), 35 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 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 From 80b2e025b7d8bcf20be42fc793a8c9ee64a2aeee Mon Sep 17 00:00:00 2001 From: Ericgacoki Date: Sat, 25 Jul 2026 14:54:13 +0300 Subject: [PATCH 2/2] fix: uniform lyrics text sizing and bump default versionCode - Render synced and unsynced lyrics at a uniform 26sp with matching padding and start alignment - Disable per-line scaling animation on synced lyrics - Add an unsynced lyrics preview - Default versionCode to 2 when not supplied by a gradle property --- app/build.gradle.kts | 2 +- .../presentation/screen/LyricsScreen.kt | 104 +++++++++++++----- 2 files changed, 80 insertions(+), 26 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 62a6f49b..9427f47e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -23,7 +23,7 @@ android { applicationId = "com.android.swingmusic" minSdk = libs.versions.minSdk.get().toInt() targetSdk = libs.versions.targetSdk.get().toInt() - versionCode = project.findProperty("versionCode")?.toString()?.toInt() ?: 1 + versionCode = project.findProperty("versionCode")?.toString()?.toInt() ?: 2 versionName = project.findProperty("versionName")?.toString() ?: "1.0.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 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 a53414fa..957abeba 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 @@ -64,6 +64,7 @@ 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.text.style.TextAlign import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewDynamicColors @@ -447,12 +448,21 @@ private fun SyncedLyricsList( val distance = if (current < 0) Int.MAX_VALUE else abs(index - current) val isPast = current >= 0 && index < current + // Per-line scaling (active line larger than neighbours). Disabled for now — + // all lines render at a uniform size. Keep for a future "animated lyrics" setting. + /* val targetScale = when { isActive -> 1F distance == 1 -> 0.72F distance == 2 -> 0.62F else -> 0.56F } + val animatedScale by animateFloatAsState( + targetValue = targetScale, + animationSpec = tween(durationMillis = 450, easing = FastOutSlowInEasing), + label = "lyricScale" + ) + */ val targetAlpha = when { isActive -> 1F isPast -> 0.35F @@ -461,11 +471,6 @@ private fun SyncedLyricsList( else -> 0.4F } - val animatedScale by animateFloatAsState( - targetValue = targetScale, - animationSpec = tween(durationMillis = 450, easing = FastOutSlowInEasing), - label = "lyricScale" - ) val animatedColor by animateColorAsState( targetValue = MaterialTheme.colorScheme.onSurface.copy(alpha = targetAlpha), animationSpec = tween(durationMillis = 450, easing = FastOutSlowInEasing), @@ -484,21 +489,24 @@ private fun SyncedLyricsList( } } ) - .padding(horizontal = 24.dp, vertical = 4.dp) + .padding(horizontal = 24.dp, vertical = 3.dp) ) { Text( text = line.text.ifBlank { "♪" }, - fontSize = 32.sp, - lineHeight = 40.sp, + fontSize = 26.sp, + lineHeight = 36.sp, fontWeight = if (isActive) FontWeight.Bold else FontWeight.SemiBold, color = animatedColor, + textAlign = TextAlign.Start, modifier = Modifier .fillMaxWidth() + /* .graphicsLayer { scaleX = animatedScale scaleY = animatedScale transformOrigin = TransformOrigin(0F, 0.5F) } + */ ) } } @@ -520,38 +528,48 @@ private fun SyncedLyricsList( @OptIn(ExperimentalFoundationApi::class) @Composable private fun UnsyncedLyricsList( - state: com.android.swingmusic.player.presentation.state.LyricsUiState + state: LyricsUiState ) { val clipboard = LocalClipboardManager.current val context = LocalContext.current LazyColumn( modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(horizontal = 24.dp, vertical = 32.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) + contentPadding = PaddingValues(vertical = 32.dp), + verticalArrangement = Arrangement.spacedBy(0.dp) ) { items(state.lines) { line -> - Text( - text = line.text.ifBlank { " " }, - fontSize = 24.sp, - fontWeight = FontWeight.Medium, - 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() + Box( + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { }, + onLongClick = { + if (line.text.isNotBlank()) { + clipboard.setText(AnnotatedString(line.text.trim())) + Toast.makeText(context, "Copied", Toast.LENGTH_SHORT).show() + } } - } + ) + .padding(horizontal = 24.dp, vertical = 3.dp) + ) { + Text( + text = line.text.ifBlank { "♪" }, + fontSize = 26.sp, + lineHeight = 32.sp, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Start, + modifier = Modifier.fillMaxWidth() ) - ) + } } if (state.copyright.isNotBlank()) { item { Text( text = state.copyright, style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5F) + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5F), + modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 24.dp) ) } } @@ -662,3 +680,39 @@ fun LyricsOverlayPreview() { } } } + +@PreviewDynamicColors +@Preview( + uiMode = Configuration.UI_MODE_NIGHT_YES, + device = Devices.PIXEL_5, + showBackground = true +) +@Composable +fun UnsyncedLyricsListPreview() { + val lines = listOf( + "Holding my breath against the cold", + "Waiting for the morning to call", + "But there's a fire in my chest", + "Burning brighter than before", + "", + "I will rise above the noise", + "And walk straight through the open door", + "No more hiding, no more fear", + "Let the silence break apart" + ).map { text -> LyricsLine(time = 0L, text = text) } + + val state = LyricsUiState( + lines = lines, + synced = false, + exists = true, + currentLine = -1, + copyright = "Lyrics licensed & provided by LyricFind", + trackHash = "trackHash123" + ) + + SwingMusicTheme(dynamicColor = true) { + Surface { + UnsyncedLyricsList(state = state) + } + } +}