mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-11-04 10:13:59 +00:00
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 <noreply@anthropic.com>
This commit is contained in:
parent
8d4ea82a7e
commit
09edc5a182
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user