diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppLargePlayer.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppLargePlayer.kt index 5dd986ca..82b45b86 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppLargePlayer.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppLargePlayer.kt @@ -18,18 +18,23 @@ package dev.krtirtho.spotube.modules.shell import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -45,9 +50,14 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.blur.HazeColorEffect +import dev.chrisbanes.haze.blur.blurEffect +import dev.chrisbanes.haze.hazeEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage import dev.krtirtho.spotube.core.audioplayer.AudioPlayer @@ -100,6 +110,7 @@ import kotlin.time.Duration.Companion.milliseconds @Composable fun AppLargePlayer( modifier: Modifier = Modifier, + hazeState: HazeState, onQueue: () -> Unit = {}, onAlternativeSource: () -> Unit = {}, onMoreOptions: () -> Unit = {}, @@ -171,210 +182,231 @@ fun AppLargePlayer( } } + val surfaceTint = MaterialTheme.colorScheme.surface.copy(alpha = 0.85f) + Surface( - modifier = modifier.fillMaxWidth(), - tonalElevation = 0.dp, - shadowElevation = 0.dp + modifier = modifier + .windowInsetsPadding(WindowInsets.navigationBars), + color = Color.Transparent, + shadowElevation = 0.dp, ) { - Row( + HorizontalDivider( + modifier = Modifier.fillMaxWidth(), + color = Color.Gray, + thickness = 1.dp, + ) + Box( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 10.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + .hazeEffect(state = hazeState) { + blurEffect { + blurRadius = 20.dp + colorEffects = listOf( + HazeColorEffect.tint(surfaceTint) + ) + } + } ) { Row( - modifier = Modifier.weight(1f), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - Box( - modifier = Modifier - .size(52.dp) - .clip(RoundedCornerShape(12.dp)) - .background(MaterialTheme.colorScheme.surfaceContainerHighest) - ) { - AsyncImage( - model = coverModel, - contentDescription = playerUiState.title, - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop, - ) - } - Column(modifier = Modifier.padding(start = 12.dp)) { - Text( - text = playerUiState.title, - style = MaterialTheme.typography.bodyLarge, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Text( - text = playerUiState.artists, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - Spacer(modifier = Modifier.width(10.dp)) - PlayerHeartButton( - audioPlayerQueue = audioPlayerQueue, - savedTracksViewModel = savedTracksViewModel, - ) - } - - Column( - modifier = Modifier.weight(1.1f), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Slider( - value = seekProgress, - onValueChange = { - seekProgress = it - isSeeking = true - }, - onValueChangeFinished = { - isSeeking = false - onSeekFinished() - }, - enabled = playerUiState.seekDuration.inWholeMilliseconds > 0L, - modifier = Modifier.fillMaxWidth(), - ) Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically ) { - Text( - text = formatPlayerTime(playerUiState.position), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - text = formatPlayerTime(playerUiState.displayDuration), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier.fillMaxWidth() - ) { - VariableIconButton( - onClick = ::onShuffleToggle, - variant = if (playerUiState.isShuffling) VariableIconButtonVariant.Outline else VariableIconButtonVariant.Ghost - ) { - Icon( - Iconsax.IconsaxShuffle, - contentDescription = if (playerUiState.isShuffling) "Disable shuffle" else "Enable shuffle", - tint = if (playerUiState.isShuffling) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - } - ) - } - GhostIconButton(onClick = ::onSkipPrevious) { - Icon(Iconsax.IconsaxPrevious, contentDescription = "Previous") - } - IconButton( - onClick = ::onPlayPause, - theme = invertedButtonStyle().copyShape(CircleShape), + Box( modifier = Modifier - .size(50.dp), + .size(52.dp) + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.surfaceContainerHighest) ) { - Icon( - if (playerUiState.isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay, - contentDescription = if (playerUiState.isPlaying) "Pause" else "Play or pause", + AsyncImage( + model = coverModel, + contentDescription = playerUiState.title, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, ) } - GhostIconButton(onClick = ::onSkipNext) { - Icon(Iconsax.IconsaxNext, contentDescription = "Next") - } - VariableIconButton( - onClick = ::onLoopToggle, - variant = if (playerUiState.loopState == LoopState.NONE) VariableIconButtonVariant.Ghost else VariableIconButtonVariant.Outline - ) { - Icon( - imageVector = when (playerUiState.loopState) { - LoopState.NONE -> Iconsax.IconsaxRepeateMusic - LoopState.ONE -> Iconsax.IconsaxRepeateOne - LoopState.ALL -> Iconsax.IconsaxRepeatMusic - }, - contentDescription = "Loop mode ${playerUiState.loopState.name}", - tint = if (playerUiState.loopState == LoopState.NONE) { - MaterialTheme.colorScheme.onSurfaceVariant - } else { - MaterialTheme.colorScheme.primary - } + Column(modifier = Modifier.padding(start = 12.dp)) { + Text( + text = playerUiState.title, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = playerUiState.artists, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis ) } + Spacer(modifier = Modifier.width(10.dp)) + PlayerHeartButton( + audioPlayerQueue = audioPlayerQueue, + savedTracksViewModel = savedTracksViewModel, + ) } - } - Column( - modifier = Modifier.weight(1f), - horizontalAlignment = Alignment.End - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - GhostIconButton(onClick = onQueue) { - Icon(Iconsax.IconsaxMusicFilter, contentDescription = "Queue") - } - GhostIconButton(onClick = { - val track = (currentEntry as? QueueEntry.StreamingTrack)?.track - if (track != null) { - downloadsViewModel.downloadTrack(track) - } - }) { - DownloadProgressIcon( - track = (currentEntry as? QueueEntry.StreamingTrack)?.track, - icon = Iconsax.IconsaxDirectboxReceive, - contentDescription = "Download", + Column( + modifier = Modifier.weight(1.1f), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Slider( + value = seekProgress, + onValueChange = { + seekProgress = it + isSeeking = true + }, + onValueChangeFinished = { + isSeeking = false + onSeekFinished() + }, + enabled = playerUiState.seekDuration.inWholeMilliseconds > 0L, + modifier = Modifier.fillMaxWidth(), + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = formatPlayerTime(playerUiState.position), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = formatPlayerTime(playerUiState.displayDuration), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - GhostIconButton(onClick = onAlternativeSource) { - Icon(Iconsax.SwapHorizontal2, contentDescription = "Alternative source") - } - GhostIconButton(onClick = onLyrics) { - Icon(Iconsax.IconsaxMusic, contentDescription = "Lyrics") - } - GhostIconButton(onClick = onMoreOptions) { - Icon(Iconsax.Iconsax3DotsMore, contentDescription = "More options") + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth() + ) { + VariableIconButton( + onClick = ::onShuffleToggle, + variant = if (playerUiState.isShuffling) VariableIconButtonVariant.Outline else VariableIconButtonVariant.Ghost + ) { + Icon( + Iconsax.IconsaxShuffle, + contentDescription = if (playerUiState.isShuffling) "Disable shuffle" else "Enable shuffle", + tint = if (playerUiState.isShuffling) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + ) + } + GhostIconButton(onClick = ::onSkipPrevious) { + Icon(Iconsax.IconsaxPrevious, contentDescription = "Previous") + } + IconButton( + onClick = ::onPlayPause, + theme = invertedButtonStyle().copyShape(CircleShape), + modifier = Modifier + .size(50.dp), + ) { + Icon( + if (playerUiState.isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay, + contentDescription = if (playerUiState.isPlaying) "Pause" else "Play or pause", + ) + } + GhostIconButton(onClick = ::onSkipNext) { + Icon(Iconsax.IconsaxNext, contentDescription = "Next") + } + VariableIconButton( + onClick = ::onLoopToggle, + variant = if (playerUiState.loopState == LoopState.NONE) VariableIconButtonVariant.Ghost else VariableIconButtonVariant.Outline + ) { + Icon( + imageVector = when (playerUiState.loopState) { + LoopState.NONE -> Iconsax.IconsaxRepeateMusic + LoopState.ONE -> Iconsax.IconsaxRepeateOne + LoopState.ALL -> Iconsax.IconsaxRepeatMusic + }, + contentDescription = "Loop mode ${playerUiState.loopState.name}", + tint = if (playerUiState.loopState == LoopState.NONE) { + MaterialTheme.colorScheme.onSurfaceVariant + } else { + MaterialTheme.colorScheme.primary + } + ) + } } } - Row(verticalAlignment = Alignment.CenterVertically) { - GhostIconButton( - onClick = { - scope.launch { - if (playerUiState.volume <= 0f) { - audioPlayer.setVolume(lastNonZeroVolume) - } else { - lastNonZeroVolume = playerUiState.volume - audioPlayer.setVolume(0f) + + Column( + modifier = Modifier.weight(1f), + horizontalAlignment = Alignment.End + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + GhostIconButton(onClick = onQueue) { + Icon(Iconsax.IconsaxMusicFilter, contentDescription = "Queue") + } + GhostIconButton(onClick = { + val track = (currentEntry as? QueueEntry.StreamingTrack)?.track + if (track != null) { + downloadsViewModel.downloadTrack(track) + } + }) { + DownloadProgressIcon( + track = (currentEntry as? QueueEntry.StreamingTrack)?.track, + icon = Iconsax.IconsaxDirectboxReceive, + contentDescription = "Download", + ) + } + GhostIconButton(onClick = onAlternativeSource) { + Icon(Iconsax.SwapHorizontal2, contentDescription = "Alternative source") + } + GhostIconButton(onClick = onLyrics) { + Icon(Iconsax.IconsaxMusic, contentDescription = "Lyrics") + } + GhostIconButton(onClick = onMoreOptions) { + Icon(Iconsax.Iconsax3DotsMore, contentDescription = "More options") + } + } + Row(verticalAlignment = Alignment.CenterVertically) { + GhostIconButton( + onClick = { + scope.launch { + if (playerUiState.volume <= 0f) { + audioPlayer.setVolume(lastNonZeroVolume) + } else { + lastNonZeroVolume = playerUiState.volume + audioPlayer.setVolume(0f) + } } } + ) { + val volumeIcon = when { + playerUiState.volume <= 0f -> Iconsax.IconsaxVolumeCross + playerUiState.volume < 0.5f -> Iconsax.IconsaxVolumeLow + else -> Iconsax.IconsaxVolumeHigh + } + Icon( + imageVector = volumeIcon, + contentDescription = if (playerUiState.volume <= 0f) "Unmute" else "Mute" + ) } - ) { - val volumeIcon = when { - playerUiState.volume <= 0f -> Iconsax.IconsaxVolumeCross - playerUiState.volume < 0.5f -> Iconsax.IconsaxVolumeLow - else -> Iconsax.IconsaxVolumeHigh - } - Icon( - imageVector = volumeIcon, - contentDescription = if (playerUiState.volume <= 0f) "Unmute" else "Mute" + Slider( + value = playerUiState.volume, + onValueChange = { + lastNonZeroVolume = it + scope.launch { + audioPlayer.setVolume(it) + } + }, + modifier = Modifier.fillMaxWidth(0.62f) ) } - Slider( - value = playerUiState.volume, - onValueChange = { - lastNonZeroVolume = it - scope.launch { - audioPlayer.setVolume(it) - } - }, - modifier = Modifier.fillMaxWidth(0.62f) - ) } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShell.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShell.kt index b011557c..37f1f733 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShell.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShell.kt @@ -39,6 +39,7 @@ import androidx.compose.material3.BottomSheetScaffold import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.SheetValue +import androidx.compose.material3.Text import androidx.compose.material3.VerticalDivider import androidx.compose.material3.rememberBottomSheetScaffoldState import androidx.compose.material3.rememberStandardBottomSheetState @@ -57,6 +58,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp +import dev.chrisbanes.haze.hazeSource +import dev.chrisbanes.haze.rememberHazeState import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import dev.krtirtho.spotube.core.navigation.NavigationCommands @@ -116,8 +119,13 @@ fun AppShell( CompositionLocalProvider(LocalAppShellBottomInset provides bottomOverlayInset) { if (useSidebar) { - Column(modifier = Modifier.fillMaxSize()) { - Box(modifier = Modifier.fillMaxSize().weight(1f)) { + val hazeState = rememberHazeState() + Box(modifier = Modifier.fillMaxSize()) { + Box( + modifier = Modifier + .fillMaxSize() + .hazeSource(hazeState) + ) { Row(modifier = Modifier.fillMaxSize()) { AppSidebar( navigator = navigator, navigationState = navigationState @@ -174,13 +182,11 @@ fun AppShell( ) } } - HorizontalDivider( - modifier = Modifier.fillMaxWidth(), - color = Color.Gray.copy(alpha = 0.2f), - thickness = 1.dp, - ) AppLargePlayer( - modifier = Modifier.fillMaxWidth(), + hazeState = hazeState, + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter), onQueue = queueViewModel::toggleQueueVisibility, onAlternativeSource = alternativeViewModel::toggleAlternativeVisibility, onLyrics = { navigatorCommands.navigateTo(Routes.Lyrics) },