mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-11-04 10:13:59 +00:00
Merge 9785c93b6a into 2c1148a126
This commit is contained in:
commit
e461f7547f
@ -758,6 +758,8 @@
|
||||
<string name="player_collapse_content_description">Collapse player</string>
|
||||
<string name="player_expand_lyrics_content_description">Expand lyrics</string>
|
||||
<string name="player_now_playing">Now Playing</string>
|
||||
<string name="player_fullscreen_open_content_description">Open full screen player</string>
|
||||
<string name="player_fullscreen_close_content_description">Close full screen player</string>
|
||||
<string name="player_lyrics">Lyrics</string>
|
||||
<string name="player_alternative_source">Alternative Source</string>
|
||||
<string name="player_alternative_source_content_description">Alternative source</string>
|
||||
@ -963,6 +965,8 @@
|
||||
<string name="app_window_maximize">Maximize</string>
|
||||
<string name="app_window_restore">Restore</string>
|
||||
<string name="app_window_close">Close</string>
|
||||
<string name="app_window_enter_fullscreen">Enter full screen</string>
|
||||
<string name="app_window_exit_fullscreen">Exit full screen</string>
|
||||
|
||||
<!-- Android playback notification -->
|
||||
<string name="notification_playback_channel_name">Playback</string>
|
||||
|
||||
@ -0,0 +1,25 @@
|
||||
/*
|
||||
* 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.core.ui.component
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
// Toggles the OS full screen mode of the app window. Renders nothing where the app doesn't own
|
||||
// a window (mobile).
|
||||
@Composable
|
||||
expect fun WindowFullscreenButton()
|
||||
@ -0,0 +1,287 @@
|
||||
/*
|
||||
* 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.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
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.WindowInsets
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
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.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.BlurredEdgeTreatment
|
||||
import androidx.compose.ui.draw.blur
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import com.composeunstyled.Text
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.koin.compose.koinInject
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import org.koin.core.parameter.parametersOf
|
||||
import org.spotube.millennium.composables.MillenniumIcon
|
||||
import org.spotube.millennium.composables.MillenniumIconButton
|
||||
import org.spotube.millennium.composables.MillenniumIconButtonStyle
|
||||
import org.spotube.spotube.core.audioplayer.AudioPlayerInterface
|
||||
import org.spotube.spotube.core.audioplayer.AudioPlayerQueue
|
||||
import org.spotube.spotube.core.navigation.NavigationCommands
|
||||
import org.spotube.spotube.core.navigation.Routes
|
||||
import org.spotube.spotube.core.ui.component.ApplicationMainBar
|
||||
import org.spotube.spotube.core.ui.component.WindowFullscreenButton
|
||||
import org.spotube.spotube.core.ui.theming.AppTheme
|
||||
import org.spotube.spotube.modules.saved_tracks.SAVED_TRACKS_COLLECTION_ID
|
||||
import org.spotube.spotube.modules.saved_tracks.SavedTracksViewModel
|
||||
import org.spotube.spotube.resources.iconsax.Iconsax
|
||||
import org.spotube.spotube.resources.iconsax.IconsaxArrowDown4
|
||||
import org.spotube.spotube.resources.iconsax.IconsaxMusic
|
||||
import org.spotube.spotube.resources.iconsax.IconsaxMusicFilter
|
||||
import spotube.composeapp.generated.resources.Res
|
||||
import spotube.composeapp.generated.resources.player_fullscreen_close_content_description
|
||||
import spotube.composeapp.generated.resources.player_lyrics
|
||||
import spotube.composeapp.generated.resources.queue_title
|
||||
|
||||
// The "now playing" view on large screens, opened from the large player. It covers the whole
|
||||
// window with the album art in the middle over a blurred copy of it, like the expanded player
|
||||
// does on small screens.
|
||||
//
|
||||
// The top bar has no title, so it stays clear of the macOS window buttons, and on the right it
|
||||
// has the window full screen toggle and the button to close the view (Esc too)
|
||||
// Below the album art the song title and artists are shown with the like button on the right
|
||||
// Then the progress bar, and below it the queue and lyrics buttons on the left, the playback
|
||||
// controls in the center and the volume slider on the right
|
||||
@Composable
|
||||
fun AppFullscreenPlayer(
|
||||
onClose: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
onQueue: () -> Unit = {},
|
||||
onLyrics: () -> Unit = {},
|
||||
audioPlayer: AudioPlayerInterface = koinInject(),
|
||||
audioPlayerQueue: AudioPlayerQueue = koinInject(),
|
||||
savedTracksViewModel: SavedTracksViewModel = koinViewModel<SavedTracksViewModel>(
|
||||
key = SAVED_TRACKS_COLLECTION_ID,
|
||||
parameters = { parametersOf() }
|
||||
)
|
||||
) {
|
||||
val playerUiState = rememberPlayerUiState(audioPlayer, audioPlayerQueue)
|
||||
val navigationCommands: NavigationCommands = koinInject()
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val background = AppTheme.colors.background
|
||||
|
||||
// Takes the keyboard focus so Esc reaches this view
|
||||
LaunchedEffect(focusRequester) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.clipToBounds()
|
||||
.background(background)
|
||||
// Keeps clicks from reaching the screen underneath
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClick = {})
|
||||
.onPreviewKeyEvent { event ->
|
||||
if (event.type == KeyEventType.KeyDown && event.key == Key.Escape) {
|
||||
onClose()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
.focusRequester(focusRequester)
|
||||
.focusable()
|
||||
) {
|
||||
AsyncImage(
|
||||
model = playerUiState.coverModel,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.blur(96.dp, BlurredEdgeTreatment.Rectangle),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
// Tones the art down towards the theme background, so the text stays readable in light
|
||||
// and dark themes alike
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
listOf(background.copy(alpha = 0.45f), background.copy(alpha = 0.9f))
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
ApplicationMainBar(
|
||||
actions = {
|
||||
WindowFullscreenButton()
|
||||
MillenniumIconButton(
|
||||
onClick = onClose,
|
||||
style = MillenniumIconButtonStyle.Quiet
|
||||
) {
|
||||
MillenniumIcon(
|
||||
Iconsax.IconsaxArrowDown4,
|
||||
contentDescription = stringResource(Res.string.player_fullscreen_close_content_description)
|
||||
)
|
||||
}
|
||||
},
|
||||
backButton = false,
|
||||
transparent = true
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 48.dp, vertical = 16.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.widthIn(max = 640.dp)
|
||||
.aspectRatio(1f, matchHeightConstraintsFirst = true)
|
||||
.shadow(elevation = 24.dp, shape = RoundedCornerShape(16.dp))
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(AppTheme.colors.surfaceContainerHighest)
|
||||
) {
|
||||
AsyncImage(
|
||||
model = playerUiState.coverModel,
|
||||
contentDescription = playerUiState.title,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.widthIn(max = 720.dp)
|
||||
.fillMaxWidth()
|
||||
.padding(start = 32.dp, end = 32.dp, bottom = 32.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = playerUiState.title,
|
||||
style = AppTheme.typography.headlineSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
PlayerArtistsText(
|
||||
artists = playerUiState.artistList,
|
||||
onArtistClick = { artistId ->
|
||||
navigationCommands.navigateTo(Routes.Artist(artistId))
|
||||
// The page opens beneath this view, so get out of the way
|
||||
onClose()
|
||||
},
|
||||
style = AppTheme.typography.bodyLarge,
|
||||
color = AppTheme.colors.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
PlayerHeartButton(
|
||||
audioPlayerQueue = audioPlayerQueue,
|
||||
savedTracksViewModel = savedTracksViewModel
|
||||
)
|
||||
}
|
||||
|
||||
PlayerSeekBar(
|
||||
playerUiState = playerUiState,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 20.dp),
|
||||
audioPlayer = audioPlayer
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 12.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.align(Alignment.CenterStart),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
MillenniumIconButton(
|
||||
onClick = onQueue,
|
||||
style = MillenniumIconButtonStyle.Quiet
|
||||
) {
|
||||
MillenniumIcon(Iconsax.IconsaxMusicFilter, contentDescription = stringResource(Res.string.queue_title))
|
||||
}
|
||||
MillenniumIconButton(
|
||||
onClick = onLyrics,
|
||||
style = MillenniumIconButtonStyle.Quiet
|
||||
) {
|
||||
MillenniumIcon(Iconsax.IconsaxMusic, contentDescription = stringResource(Res.string.player_lyrics))
|
||||
}
|
||||
}
|
||||
PlayerTransportControls(
|
||||
playerUiState = playerUiState,
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
playIconSize = 30.dp,
|
||||
audioPlayer = audioPlayer
|
||||
)
|
||||
PlayerVolumeControl(
|
||||
playerUiState = playerUiState,
|
||||
modifier = Modifier.align(Alignment.CenterEnd),
|
||||
sliderModifier = Modifier.width(120.dp),
|
||||
audioPlayer = audioPlayer
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
@ -76,22 +66,10 @@ import org.spotube.spotube.modules.saved_tracks.SavedTracksViewModel
|
||||
import org.spotube.spotube.resources.iconsax.Iconsax
|
||||
import org.spotube.spotube.resources.iconsax.Iconsax3DotsMore
|
||||
import org.spotube.spotube.resources.iconsax.IconsaxDirectboxReceive
|
||||
import org.spotube.spotube.resources.iconsax.IconsaxFullscreen
|
||||
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 +79,15 @@ 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_fullscreen_open_content_description
|
||||
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
|
||||
@ -134,6 +98,7 @@ import spotube.composeapp.generated.resources.queue_title
|
||||
// On the center it contains the playback controls and the progress bar
|
||||
// On the right it contains the queue btn, download button btn, alternative track source button
|
||||
// and three dots for options grouped together and below these buttons the volume slider will be shown
|
||||
// next to the button that opens the full screen player (AppFullscreenPlayer)
|
||||
@Composable
|
||||
fun AppLargePlayer(
|
||||
modifier: Modifier = Modifier,
|
||||
@ -142,6 +107,7 @@ fun AppLargePlayer(
|
||||
onAlternativeSource: () -> Unit = {},
|
||||
onMoreOptions: () -> Unit = {},
|
||||
onLyrics: () -> Unit = {},
|
||||
onFullscreen: () -> Unit = {},
|
||||
audioPlayer: AudioPlayerInterface = koinInject(),
|
||||
audioPlayerQueue: AudioPlayerQueue = koinInject(),
|
||||
downloadsViewModel: DownloadsViewModel = koinViewModel(),
|
||||
@ -152,77 +118,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 +202,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(
|
||||
@ -481,40 +300,19 @@ fun AppLargePlayer(
|
||||
}
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
PlayerVolumeControl(
|
||||
playerUiState = playerUiState,
|
||||
sliderModifier = Modifier.fillMaxWidth(0.62f),
|
||||
audioPlayer = audioPlayer
|
||||
)
|
||||
MillenniumIconButton(
|
||||
style = MillenniumIconButtonStyle.Quiet,
|
||||
onClick = {
|
||||
scope.launch {
|
||||
if (playerUiState.volume <= 0f) {
|
||||
audioPlayer.setVolume(lastNonZeroVolume)
|
||||
} else {
|
||||
lastNonZeroVolume = playerUiState.volume
|
||||
audioPlayer.setVolume(0f)
|
||||
}
|
||||
}
|
||||
}
|
||||
style = MillenniumIconButtonStyle.Quiet, onClick = onFullscreen
|
||||
) {
|
||||
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)
|
||||
Iconsax.IconsaxFullscreen,
|
||||
contentDescription = stringResource(Res.string.player_fullscreen_open_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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -17,7 +17,12 @@
|
||||
|
||||
package org.spotube.spotube.modules.shell
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||
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
|
||||
@ -98,6 +103,7 @@ fun AppShell(
|
||||
val isQueueVisible by queueViewModel.isQueueVisible.collectAsState()
|
||||
val isAlternativeVisible by alternativeViewModel.isAlternativeVisible.collectAsState()
|
||||
val isLyricsOverlayVisible by viewModel.isLyricsOverlayVisible.collectAsState()
|
||||
val isFullscreenPlayerVisible by viewModel.isFullscreenPlayerVisible.collectAsState()
|
||||
|
||||
LaunchedEffect(navigatorCommands, navigator) {
|
||||
launch {
|
||||
@ -171,7 +177,25 @@ fun AppShell(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
onQueue = queueViewModel::toggleQueueVisibility,
|
||||
onAlternativeSource = alternativeViewModel::toggleAlternativeVisibility,
|
||||
onLyrics = { navigatorCommands.navigateTo(Routes.Lyrics) })
|
||||
onLyrics = { navigatorCommands.navigateTo(Routes.Lyrics) },
|
||||
onFullscreen = viewModel::showFullscreenPlayer)
|
||||
}
|
||||
|
||||
// Above the content and the large player, but below the queue and
|
||||
// alternative source sheets so those can still be opened from it
|
||||
AnimatedVisibility(
|
||||
visible = isFullscreenPlayerVisible,
|
||||
enter = fadeIn() + slideInVertically { it / 8 },
|
||||
exit = fadeOut() + slideOutVertically { it / 8 }
|
||||
) {
|
||||
AppFullscreenPlayer(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
onClose = viewModel::hideFullscreenPlayer,
|
||||
onQueue = queueViewModel::toggleQueueVisibility,
|
||||
onLyrics = {
|
||||
viewModel.hideFullscreenPlayer()
|
||||
navigatorCommands.navigateTo(Routes.Lyrics)
|
||||
})
|
||||
}
|
||||
|
||||
if (isQueueVisible) {
|
||||
|
||||
@ -54,6 +54,7 @@ class AppShellViewModel : ViewModel() {
|
||||
val expandedPlayerVisibilityThreshold = 0.001f
|
||||
|
||||
private val lyricsOverlayVisible = MutableStateFlow(false)
|
||||
private val fullscreenPlayerVisible = MutableStateFlow(false)
|
||||
private val compactChrome = MutableStateFlow(CompactChromeUiState())
|
||||
val compactChromeUiState = compactChrome.asStateFlow()
|
||||
|
||||
@ -62,6 +63,7 @@ class AppShellViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
val isLyricsOverlayVisible: StateFlow<Boolean> = lyricsOverlayVisible.asStateFlow()
|
||||
val isFullscreenPlayerVisible: StateFlow<Boolean> = fullscreenPlayerVisible.asStateFlow()
|
||||
|
||||
@Composable
|
||||
fun useSidebar(maxWidth: Dp? = null): Boolean {
|
||||
@ -113,4 +115,12 @@ class AppShellViewModel : ViewModel() {
|
||||
fun toggleLyricsOverlay() {
|
||||
lyricsOverlayVisible.update { !it }
|
||||
}
|
||||
|
||||
fun showFullscreenPlayer() {
|
||||
fullscreenPlayerVisible.value = true
|
||||
}
|
||||
|
||||
fun hideFullscreenPlayer() {
|
||||
fullscreenPlayerVisible.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,90 @@
|
||||
/*
|
||||
* 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.resources.iconsax
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.StrokeJoin
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.graphics.vector.path
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
val Iconsax.IconsaxFullscreen: ImageVector
|
||||
get() {
|
||||
if (_IconsaxFullscreen != null) {
|
||||
return _IconsaxFullscreen!!
|
||||
}
|
||||
_IconsaxFullscreen = ImageVector.Builder(
|
||||
name = "IconsaxFullscreen",
|
||||
defaultWidth = 24.dp,
|
||||
defaultHeight = 24.dp,
|
||||
viewportWidth = 24f,
|
||||
viewportHeight = 24f
|
||||
).apply {
|
||||
path(
|
||||
stroke = SolidColor(Color.White),
|
||||
strokeLineWidth = 1.5f,
|
||||
strokeLineCap = StrokeCap.Round,
|
||||
strokeLineJoin = StrokeJoin.Round
|
||||
) {
|
||||
moveTo(3f, 9f)
|
||||
verticalLineTo(5f)
|
||||
curveTo(3f, 3.9f, 3.9f, 3f, 5f, 3f)
|
||||
horizontalLineTo(9f)
|
||||
}
|
||||
path(
|
||||
stroke = SolidColor(Color.White),
|
||||
strokeLineWidth = 1.5f,
|
||||
strokeLineCap = StrokeCap.Round,
|
||||
strokeLineJoin = StrokeJoin.Round
|
||||
) {
|
||||
moveTo(15f, 3f)
|
||||
horizontalLineTo(19f)
|
||||
curveTo(20.1f, 3f, 21f, 3.9f, 21f, 5f)
|
||||
verticalLineTo(9f)
|
||||
}
|
||||
path(
|
||||
stroke = SolidColor(Color.White),
|
||||
strokeLineWidth = 1.5f,
|
||||
strokeLineCap = StrokeCap.Round,
|
||||
strokeLineJoin = StrokeJoin.Round
|
||||
) {
|
||||
moveTo(21f, 15f)
|
||||
verticalLineTo(19f)
|
||||
curveTo(21f, 20.1f, 20.1f, 21f, 19f, 21f)
|
||||
horizontalLineTo(15f)
|
||||
}
|
||||
path(
|
||||
stroke = SolidColor(Color.White),
|
||||
strokeLineWidth = 1.5f,
|
||||
strokeLineCap = StrokeCap.Round,
|
||||
strokeLineJoin = StrokeJoin.Round
|
||||
) {
|
||||
moveTo(9f, 21f)
|
||||
horizontalLineTo(5f)
|
||||
curveTo(3.9f, 21f, 3f, 20.1f, 3f, 19f)
|
||||
verticalLineTo(15f)
|
||||
}
|
||||
}.build()
|
||||
|
||||
return _IconsaxFullscreen!!
|
||||
}
|
||||
|
||||
@Suppress("ObjectPropertyName")
|
||||
private var _IconsaxFullscreen: ImageVector? = null
|
||||
@ -0,0 +1,90 @@
|
||||
/*
|
||||
* 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.resources.iconsax
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.StrokeJoin
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.graphics.vector.path
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
val Iconsax.IconsaxFullscreenExit: ImageVector
|
||||
get() {
|
||||
if (_IconsaxFullscreenExit != null) {
|
||||
return _IconsaxFullscreenExit!!
|
||||
}
|
||||
_IconsaxFullscreenExit = ImageVector.Builder(
|
||||
name = "IconsaxFullscreenExit",
|
||||
defaultWidth = 24.dp,
|
||||
defaultHeight = 24.dp,
|
||||
viewportWidth = 24f,
|
||||
viewportHeight = 24f
|
||||
).apply {
|
||||
path(
|
||||
stroke = SolidColor(Color.White),
|
||||
strokeLineWidth = 1.5f,
|
||||
strokeLineCap = StrokeCap.Round,
|
||||
strokeLineJoin = StrokeJoin.Round
|
||||
) {
|
||||
moveTo(9f, 3f)
|
||||
verticalLineTo(7f)
|
||||
curveTo(9f, 8.1f, 8.1f, 9f, 7f, 9f)
|
||||
horizontalLineTo(3f)
|
||||
}
|
||||
path(
|
||||
stroke = SolidColor(Color.White),
|
||||
strokeLineWidth = 1.5f,
|
||||
strokeLineCap = StrokeCap.Round,
|
||||
strokeLineJoin = StrokeJoin.Round
|
||||
) {
|
||||
moveTo(21f, 9f)
|
||||
horizontalLineTo(17f)
|
||||
curveTo(15.9f, 9f, 15f, 8.1f, 15f, 7f)
|
||||
verticalLineTo(3f)
|
||||
}
|
||||
path(
|
||||
stroke = SolidColor(Color.White),
|
||||
strokeLineWidth = 1.5f,
|
||||
strokeLineCap = StrokeCap.Round,
|
||||
strokeLineJoin = StrokeJoin.Round
|
||||
) {
|
||||
moveTo(15f, 21f)
|
||||
verticalLineTo(17f)
|
||||
curveTo(15f, 15.9f, 15.9f, 15f, 17f, 15f)
|
||||
horizontalLineTo(21f)
|
||||
}
|
||||
path(
|
||||
stroke = SolidColor(Color.White),
|
||||
strokeLineWidth = 1.5f,
|
||||
strokeLineCap = StrokeCap.Round,
|
||||
strokeLineJoin = StrokeJoin.Round
|
||||
) {
|
||||
moveTo(3f, 15f)
|
||||
horizontalLineTo(7f)
|
||||
curveTo(8.1f, 15f, 9f, 15.9f, 9f, 17f)
|
||||
verticalLineTo(21f)
|
||||
}
|
||||
}.build()
|
||||
|
||||
return _IconsaxFullscreenExit!!
|
||||
}
|
||||
|
||||
@Suppress("ObjectPropertyName")
|
||||
private var _IconsaxFullscreenExit: ImageVector? = null
|
||||
@ -0,0 +1,74 @@
|
||||
/*
|
||||
* 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.core.ui.component
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.window.WindowPlacement
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.spotube.millennium.composables.MillenniumIcon
|
||||
import org.spotube.millennium.composables.MillenniumIconButton
|
||||
import org.spotube.millennium.composables.MillenniumIconButtonStyle
|
||||
import org.spotube.spotube.resources.iconsax.Iconsax
|
||||
import org.spotube.spotube.resources.iconsax.IconsaxFullscreen
|
||||
import org.spotube.spotube.resources.iconsax.IconsaxFullscreenExit
|
||||
import spotube.composeapp.generated.resources.Res
|
||||
import spotube.composeapp.generated.resources.app_window_enter_fullscreen
|
||||
import spotube.composeapp.generated.resources.app_window_exit_fullscreen
|
||||
|
||||
@Composable
|
||||
actual fun WindowFullscreenButton() {
|
||||
val windowState = LocalWindowState.current
|
||||
// Where to go back to on exit; only set while this button put the window in full screen
|
||||
var placementBeforeFullscreen by remember { mutableStateOf<WindowPlacement?>(null) }
|
||||
val isFullscreen = windowState.placement == WindowPlacement.Fullscreen
|
||||
|
||||
// Full screen belongs to the screen that turned it on, so leaving that screen leaves it too
|
||||
DisposableEffect(windowState) {
|
||||
onDispose {
|
||||
val previous = placementBeforeFullscreen
|
||||
if (previous != null && windowState.placement == WindowPlacement.Fullscreen) {
|
||||
windowState.placement = previous
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MillenniumIconButton(
|
||||
onClick = {
|
||||
if (isFullscreen) {
|
||||
windowState.placement = placementBeforeFullscreen ?: WindowPlacement.Floating
|
||||
placementBeforeFullscreen = null
|
||||
} else {
|
||||
placementBeforeFullscreen = windowState.placement
|
||||
windowState.placement = WindowPlacement.Fullscreen
|
||||
}
|
||||
},
|
||||
style = MillenniumIconButtonStyle.Quiet
|
||||
) {
|
||||
MillenniumIcon(
|
||||
imageVector = if (isFullscreen) Iconsax.IconsaxFullscreenExit else Iconsax.IconsaxFullscreen,
|
||||
contentDescription = stringResource(
|
||||
if (isFullscreen) Res.string.app_window_exit_fullscreen else Res.string.app_window_enter_fullscreen
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,25 @@
|
||||
/*
|
||||
* 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.core.ui.component
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
@Composable
|
||||
actual fun WindowFullscreenButton() {
|
||||
// No-op on mobile targets.
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user