From 09edc5a182736a5c1b0751099c1da15e0f3ff9e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Brian=20L=C3=B3pez=20Medina?= Date: Thu, 29 Oct 2026 03:19:03 -0600 Subject: [PATCH] refactor(player): extract the large player's seek bar, transport and volume controls Moves them into shared composables so another player can reuse the same playback logic. The large player renders exactly as before. Co-Authored-By: Claude Opus 5.5 --- .../spotube/modules/shell/AppLargePlayer.kt | 244 +------------- .../spotube/modules/shell/PlayerControls.kt | 312 ++++++++++++++++++ 2 files changed, 326 insertions(+), 230 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/PlayerControls.kt diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppLargePlayer.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppLargePlayer.kt index 3490f2fa..b54417ae 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppLargePlayer.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppLargePlayer.kt @@ -38,13 +38,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect 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.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -55,17 +49,13 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage import com.composeunstyled.AnchorSide import com.composeunstyled.Text -import com.composeunstyled.LocalTextStyle import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.blur.HazeColorEffect import dev.chrisbanes.haze.blur.blurEffect import dev.chrisbanes.haze.hazeEffect import org.spotube.spotube.core.audioplayer.AudioPlayerInterface import org.spotube.spotube.core.audioplayer.AudioPlayerQueue -import org.spotube.spotube.core.audioplayer.LoopState import org.spotube.spotube.core.audioplayer.QueueEntry -import org.spotube.spotube.core.jam.JamRole -import org.spotube.spotube.core.jam.JamRoomService import org.spotube.spotube.core.navigation.NavigationCommands import org.spotube.spotube.core.navigation.Routes import org.spotube.spotube.core.ui.theming.AppTheme @@ -78,20 +68,7 @@ import org.spotube.spotube.resources.iconsax.Iconsax3DotsMore import org.spotube.spotube.resources.iconsax.IconsaxDirectboxReceive import org.spotube.spotube.resources.iconsax.IconsaxMusic import org.spotube.spotube.resources.iconsax.IconsaxMusicFilter -import org.spotube.spotube.resources.iconsax.IconsaxNext -import org.spotube.spotube.resources.iconsax.IconsaxPause -import org.spotube.spotube.resources.iconsax.IconsaxPlay -import org.spotube.spotube.resources.iconsax.IconsaxPrevious -import org.spotube.spotube.resources.iconsax.IconsaxRepeatMusic -import org.spotube.spotube.resources.iconsax.IconsaxRepeateMusic -import org.spotube.spotube.resources.iconsax.IconsaxRepeateOne -import org.spotube.spotube.resources.iconsax.IconsaxShuffle -import org.spotube.spotube.resources.iconsax.IconsaxVolumeCross -import org.spotube.spotube.resources.iconsax.IconsaxVolumeHigh -import org.spotube.spotube.resources.iconsax.IconsaxVolumeLow import org.spotube.spotube.resources.iconsax.SwapHorizontal2 -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel @@ -101,29 +78,14 @@ import org.spotube.millennium.composables.MillenniumIcon import org.spotube.millennium.composables.MillenniumIconButton import org.spotube.millennium.composables.MillenniumIconButtonStyle import org.spotube.millennium.composables.MillenniumSeparator -import org.spotube.millennium.composables.MillenniumSlider import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.player_go_to_album import spotube.composeapp.generated.resources.player_sleep_timer import spotube.composeapp.generated.resources.player_track_details -import kotlin.time.Duration.Companion.milliseconds import spotube.composeapp.generated.resources.player_alternative_source_content_description -import spotube.composeapp.generated.resources.player_disable_shuffle_content_description import spotube.composeapp.generated.resources.player_download -import spotube.composeapp.generated.resources.player_enable_shuffle_content_description -import spotube.composeapp.generated.resources.player_loop_mode_all -import spotube.composeapp.generated.resources.player_loop_mode_none -import spotube.composeapp.generated.resources.player_loop_mode_one import spotube.composeapp.generated.resources.player_lyrics import spotube.composeapp.generated.resources.player_more_options_content_description -import spotube.composeapp.generated.resources.player_mute_content_description -import spotube.composeapp.generated.resources.player_next_content_description -import spotube.composeapp.generated.resources.player_pause_content_description -import spotube.composeapp.generated.resources.player_play_or_pause_content_description -import spotube.composeapp.generated.resources.player_previous_content_description -import spotube.composeapp.generated.resources.player_seek_label -import spotube.composeapp.generated.resources.player_unmute_content_description -import spotube.composeapp.generated.resources.player_volume_label import spotube.composeapp.generated.resources.queue_title // The main player on large screens shown at the bottom of the screen permanently. It should be @@ -152,77 +114,14 @@ fun AppLargePlayer( ) ) { val playerUiState = rememberPlayerUiState(audioPlayer, audioPlayerQueue) - val jamRoomService: JamRoomService = koinInject() - val isJamGuest by jamRoomService.role - .map { it == JamRole.Guest } - .collectAsStateWithLifecycle(initialValue = false) - val scope = rememberCoroutineScope() val currentEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle() val currentTrack = (currentEntry as? QueueEntry.StreamingTrack)?.track val playerOptionsUiState by playerOptionsViewModel.uiState.collectAsStateWithLifecycle() val navigationCommands: NavigationCommands = koinInject() - var isSeeking by remember { mutableStateOf(false) } - var seekProgress by remember { mutableFloatStateOf(playerUiState.progress) } - var lastNonZeroVolume by remember { mutableFloatStateOf(if (playerUiState.volume > 0f) playerUiState.volume else 0.6f) } val coverModel = playerUiState.coverModel PlayerOptionDialogs(viewModel = playerOptionsViewModel) - LaunchedEffect(playerUiState.progress, isSeeking) { - if (!isSeeking) { - seekProgress = playerUiState.progress - } - } - - LaunchedEffect(playerUiState.volume) { - if (playerUiState.volume > 0f) { - lastNonZeroVolume = playerUiState.volume - } - } - - fun onPlayPause() { - scope.launch { - if (playerUiState.isPlaying) { - audioPlayer.pause() - } else { - audioPlayer.play() - } - } - } - - fun onSkipPrevious() { - if (isJamGuest) return - scope.launch { audioPlayer.skipToPrevious() } - } - - fun onSkipNext() { - if (isJamGuest) return - scope.launch { audioPlayer.skipToNext() } - } - - fun onShuffleToggle() { - if (isJamGuest) return - scope.launch { audioPlayer.shuffle(!playerUiState.isShuffling) } - } - - fun onLoopToggle() { - if (isJamGuest) return - scope.launch { audioPlayer.loop(playerUiState.loopState.next()) } - } - - fun onSeekFinished() { - val durationMillis = playerUiState.seekDuration.inWholeMilliseconds - if (durationMillis <= 0L) return - scope.launch { - audioPlayer.seekTo( - (durationMillis * seekProgress.coerceIn( - 0f, - 1f - )).toLong().milliseconds - ) - } - } - // A translucent tint (not near-opaque) so the blurred art behind still reads through, and // the material's watery caustics sit on top of it. val surfaceTint = AppTheme.colors.surface.copy(alpha = 0.6f) @@ -299,100 +198,16 @@ fun AppLargePlayer( modifier = Modifier.weight(1.1f), horizontalAlignment = Alignment.CenterHorizontally ) { - MillenniumSlider( - value = seekProgress, - onValueChange = { - seekProgress = it - isSeeking = true - }, - onValueChangeFinished = { - isSeeking = false - onSeekFinished() - }, - enabled = playerUiState.seekDuration.inWholeMilliseconds > 0L, - label = stringResource(Res.string.player_seek_label), - modifier = Modifier.fillMaxWidth() - ) - Row( + PlayerSeekBar( + playerUiState = playerUiState, modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = formatPlayerTime(playerUiState.position), - style = AppTheme.typography.labelSmall, - color = AppTheme.colors.onSurfaceVariant - ) - Text( - text = formatPlayerTime(playerUiState.displayDuration), - style = AppTheme.typography.labelSmall, - color = AppTheme.colors.onSurfaceVariant - ) - } - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier.fillMaxWidth() - ) { - MillenniumIconButton( - onClick = ::onShuffleToggle, - enabled = !isJamGuest, - style = if (playerUiState.isShuffling) MillenniumIconButtonStyle.Secondary else MillenniumIconButtonStyle.Quiet - ) { - MillenniumIcon( - Iconsax.IconsaxShuffle, - contentDescription = stringResource(if (playerUiState.isShuffling) Res.string.player_disable_shuffle_content_description else Res.string.player_enable_shuffle_content_description), - tint = if (playerUiState.isShuffling) { - AppTheme.colors.primary - } else { - AppTheme.colors.onSurfaceVariant - } - ) - } - MillenniumIconButton( - onClick = ::onSkipPrevious, - enabled = !isJamGuest, - style = MillenniumIconButtonStyle.Quiet - ) { - MillenniumIcon(Iconsax.IconsaxPrevious, contentDescription = stringResource(Res.string.player_previous_content_description)) - } - MillenniumIconButton( - onClick = ::onPlayPause, - style = MillenniumIconButtonStyle.Primary - ) { - MillenniumIcon( - if (playerUiState.isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay, - contentDescription = stringResource(if (playerUiState.isPlaying) Res.string.player_pause_content_description else Res.string.player_play_or_pause_content_description), - tint = LocalTextStyle.current.color - ) - } - MillenniumIconButton( - onClick = ::onSkipNext, - enabled = !isJamGuest, - style = MillenniumIconButtonStyle.Quiet - ) { - MillenniumIcon(Iconsax.IconsaxNext, contentDescription = stringResource(Res.string.player_next_content_description)) - } - MillenniumIconButton( - onClick = ::onLoopToggle, - enabled = !isJamGuest, - style = if (playerUiState.loopState == LoopState.NONE) MillenniumIconButtonStyle.Quiet else MillenniumIconButtonStyle.Secondary - ) { - MillenniumIcon( - imageVector = when (playerUiState.loopState) { - LoopState.NONE -> Iconsax.IconsaxRepeateMusic - LoopState.ONE -> Iconsax.IconsaxRepeateOne - LoopState.ALL -> Iconsax.IconsaxRepeatMusic - }, - contentDescription = stringResource(when (playerUiState.loopState) { LoopState.NONE -> Res.string.player_loop_mode_none; LoopState.ONE -> Res.string.player_loop_mode_one; LoopState.ALL -> Res.string.player_loop_mode_all }), - tint = if (playerUiState.loopState == LoopState.NONE) { - AppTheme.colors.onSurfaceVariant - } else { - AppTheme.colors.primary - } - ) - } - } + audioPlayer = audioPlayer + ) + PlayerTransportControls( + playerUiState = playerUiState, + modifier = Modifier.fillMaxWidth(), + audioPlayer = audioPlayer + ) } Column( @@ -480,42 +295,11 @@ fun AppLargePlayer( }) } } - Row(verticalAlignment = Alignment.CenterVertically) { - MillenniumIconButton( - style = MillenniumIconButtonStyle.Quiet, - 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 - } - MillenniumIcon( - imageVector = volumeIcon, - contentDescription = stringResource(if (playerUiState.volume <= 0f) Res.string.player_unmute_content_description else Res.string.player_mute_content_description) - ) - } - MillenniumSlider( - value = playerUiState.volume, - onValueChange = { - lastNonZeroVolume = it - scope.launch { - audioPlayer.setVolume(it) - } - }, - label = stringResource(Res.string.player_volume_label), - modifier = Modifier.fillMaxWidth(0.62f) - ) - } + PlayerVolumeControl( + playerUiState = playerUiState, + sliderModifier = Modifier.fillMaxWidth(0.62f), + audioPlayer = audioPlayer + ) } } } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/PlayerControls.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/PlayerControls.kt new file mode 100644 index 00000000..472ed0cc --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/PlayerControls.kt @@ -0,0 +1,312 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.shell + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.composeunstyled.LocalTextStyle +import com.composeunstyled.Text +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.koinInject +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumSlider +import org.spotube.spotube.core.audioplayer.AudioPlayerInterface +import org.spotube.spotube.core.audioplayer.LoopState +import org.spotube.spotube.core.jam.JamRole +import org.spotube.spotube.core.jam.JamRoomService +import org.spotube.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxNext +import org.spotube.spotube.resources.iconsax.IconsaxPause +import org.spotube.spotube.resources.iconsax.IconsaxPlay +import org.spotube.spotube.resources.iconsax.IconsaxPrevious +import org.spotube.spotube.resources.iconsax.IconsaxRepeatMusic +import org.spotube.spotube.resources.iconsax.IconsaxRepeateMusic +import org.spotube.spotube.resources.iconsax.IconsaxRepeateOne +import org.spotube.spotube.resources.iconsax.IconsaxShuffle +import org.spotube.spotube.resources.iconsax.IconsaxVolumeCross +import org.spotube.spotube.resources.iconsax.IconsaxVolumeHigh +import org.spotube.spotube.resources.iconsax.IconsaxVolumeLow +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.player_disable_shuffle_content_description +import spotube.composeapp.generated.resources.player_enable_shuffle_content_description +import spotube.composeapp.generated.resources.player_loop_mode_all +import spotube.composeapp.generated.resources.player_loop_mode_none +import spotube.composeapp.generated.resources.player_loop_mode_one +import spotube.composeapp.generated.resources.player_mute_content_description +import spotube.composeapp.generated.resources.player_next_content_description +import spotube.composeapp.generated.resources.player_pause_content_description +import spotube.composeapp.generated.resources.player_play_or_pause_content_description +import spotube.composeapp.generated.resources.player_previous_content_description +import spotube.composeapp.generated.resources.player_seek_label +import spotube.composeapp.generated.resources.player_unmute_content_description +import spotube.composeapp.generated.resources.player_volume_label +import kotlin.time.Duration.Companion.milliseconds + +// Playback controls shared by the large player and the full screen player, so both drive the +// audio player the same way. + +// The seek slider with the elapsed and total time below it +@Composable +internal fun PlayerSeekBar( + playerUiState: PlayerUiState, + modifier: Modifier = Modifier, + audioPlayer: AudioPlayerInterface = koinInject() +) { + val scope = rememberCoroutineScope() + var isSeeking by remember { mutableStateOf(false) } + var seekProgress by remember { mutableFloatStateOf(playerUiState.progress) } + + LaunchedEffect(playerUiState.progress, isSeeking) { + if (!isSeeking) { + seekProgress = playerUiState.progress + } + } + + fun onSeekFinished() { + val durationMillis = playerUiState.seekDuration.inWholeMilliseconds + if (durationMillis <= 0L) return + scope.launch { + audioPlayer.seekTo( + (durationMillis * seekProgress.coerceIn( + 0f, + 1f + )).toLong().milliseconds + ) + } + } + + Column(modifier = modifier) { + MillenniumSlider( + value = seekProgress, + onValueChange = { + seekProgress = it + isSeeking = true + }, + onValueChangeFinished = { + isSeeking = false + onSeekFinished() + }, + enabled = playerUiState.seekDuration.inWholeMilliseconds > 0L, + label = stringResource(Res.string.player_seek_label), + modifier = Modifier.fillMaxWidth() + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = formatPlayerTime(playerUiState.position), + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant + ) + Text( + text = formatPlayerTime(playerUiState.displayDuration), + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant + ) + } + } +} + +// Shuffle, previous, play/pause, next and loop. Jam guests can only play/pause. +@Composable +internal fun PlayerTransportControls( + playerUiState: PlayerUiState, + modifier: Modifier = Modifier, + horizontalArrangement: Arrangement.Horizontal = Arrangement.SpaceBetween, + playIconSize: Dp = Dp.Unspecified, + audioPlayer: AudioPlayerInterface = koinInject(), + jamRoomService: JamRoomService = koinInject() +) { + val isJamGuest by jamRoomService.role + .map { it == JamRole.Guest } + .collectAsStateWithLifecycle(initialValue = false) + val scope = rememberCoroutineScope() + + fun onPlayPause() { + scope.launch { + if (playerUiState.isPlaying) { + audioPlayer.pause() + } else { + audioPlayer.play() + } + } + } + + fun onSkipPrevious() { + if (isJamGuest) return + scope.launch { audioPlayer.skipToPrevious() } + } + + fun onSkipNext() { + if (isJamGuest) return + scope.launch { audioPlayer.skipToNext() } + } + + fun onShuffleToggle() { + if (isJamGuest) return + scope.launch { audioPlayer.shuffle(!playerUiState.isShuffling) } + } + + fun onLoopToggle() { + if (isJamGuest) return + scope.launch { audioPlayer.loop(playerUiState.loopState.next()) } + } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = horizontalArrangement, + modifier = modifier + ) { + MillenniumIconButton( + onClick = ::onShuffleToggle, + enabled = !isJamGuest, + style = if (playerUiState.isShuffling) MillenniumIconButtonStyle.Secondary else MillenniumIconButtonStyle.Quiet + ) { + MillenniumIcon( + Iconsax.IconsaxShuffle, + contentDescription = stringResource(if (playerUiState.isShuffling) Res.string.player_disable_shuffle_content_description else Res.string.player_enable_shuffle_content_description), + tint = if (playerUiState.isShuffling) { + AppTheme.colors.primary + } else { + AppTheme.colors.onSurfaceVariant + } + ) + } + MillenniumIconButton( + onClick = ::onSkipPrevious, + enabled = !isJamGuest, + style = MillenniumIconButtonStyle.Quiet + ) { + MillenniumIcon(Iconsax.IconsaxPrevious, contentDescription = stringResource(Res.string.player_previous_content_description)) + } + MillenniumIconButton( + onClick = ::onPlayPause, + style = MillenniumIconButtonStyle.Primary + ) { + MillenniumIcon( + if (playerUiState.isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay, + contentDescription = stringResource(if (playerUiState.isPlaying) Res.string.player_pause_content_description else Res.string.player_play_or_pause_content_description), + modifier = if (playIconSize == Dp.Unspecified) Modifier else Modifier.size(playIconSize), + tint = LocalTextStyle.current.color + ) + } + MillenniumIconButton( + onClick = ::onSkipNext, + enabled = !isJamGuest, + style = MillenniumIconButtonStyle.Quiet + ) { + MillenniumIcon(Iconsax.IconsaxNext, contentDescription = stringResource(Res.string.player_next_content_description)) + } + MillenniumIconButton( + onClick = ::onLoopToggle, + enabled = !isJamGuest, + style = if (playerUiState.loopState == LoopState.NONE) MillenniumIconButtonStyle.Quiet else MillenniumIconButtonStyle.Secondary + ) { + MillenniumIcon( + imageVector = when (playerUiState.loopState) { + LoopState.NONE -> Iconsax.IconsaxRepeateMusic + LoopState.ONE -> Iconsax.IconsaxRepeateOne + LoopState.ALL -> Iconsax.IconsaxRepeatMusic + }, + contentDescription = stringResource(when (playerUiState.loopState) { LoopState.NONE -> Res.string.player_loop_mode_none; LoopState.ONE -> Res.string.player_loop_mode_one; LoopState.ALL -> Res.string.player_loop_mode_all }), + tint = if (playerUiState.loopState == LoopState.NONE) { + AppTheme.colors.onSurfaceVariant + } else { + AppTheme.colors.primary + } + ) + } + } +} + +// The mute toggle followed by the volume slider. Unmuting restores the last non-zero volume. +@Composable +internal fun PlayerVolumeControl( + playerUiState: PlayerUiState, + modifier: Modifier = Modifier, + sliderModifier: Modifier = Modifier, + audioPlayer: AudioPlayerInterface = koinInject() +) { + val scope = rememberCoroutineScope() + var lastNonZeroVolume by remember { mutableFloatStateOf(if (playerUiState.volume > 0f) playerUiState.volume else 0.6f) } + + LaunchedEffect(playerUiState.volume) { + if (playerUiState.volume > 0f) { + lastNonZeroVolume = playerUiState.volume + } + } + + Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) { + MillenniumIconButton( + style = MillenniumIconButtonStyle.Quiet, + 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 + } + MillenniumIcon( + imageVector = volumeIcon, + contentDescription = stringResource(if (playerUiState.volume <= 0f) Res.string.player_unmute_content_description else Res.string.player_mute_content_description) + ) + } + MillenniumSlider( + value = playerUiState.volume, + onValueChange = { + lastNonZeroVolume = it + scope.launch { + audioPlayer.setVolume(it) + } + }, + label = stringResource(Res.string.player_volume_label), + modifier = sliderModifier + ) + } +}