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 1/2] 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 + ) + } +} From 9785c93b6ac31ddd916bde98c28e2044c054783f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Brian=20L=C3=B3pez=20Medina?= Date: Thu, 29 Oct 2026 03:57:42 -0600 Subject: [PATCH 2/2] feat(player): add a full screen now playing view on desktop Large screens only had the bottom player bar; the expanded player was reachable only from the compact layout. A new button next to the volume slider opens a view that covers the window, with the album art in the middle over a blurred copy of it, the track info, progress, playback controls, volume, and the queue and lyrics buttons. The view closes with its button or Esc. On desktop it can also put the window in OS full screen, and leaves it again when the view closes. Co-Authored-By: Claude Opus 5.5 --- .../composeResources/values/strings.xml | 4 + .../ui/component/WindowFullscreenButton.kt | 25 ++ .../modules/shell/AppFullscreenPlayer.kt | 287 ++++++++++++++++++ .../spotube/modules/shell/AppLargePlayer.kt | 24 +- .../spotube/spotube/modules/shell/AppShell.kt | 26 +- .../modules/shell/AppShellViewModel.kt | 10 + .../resources/iconsax/IconsaxFullscreen.kt | 90 ++++++ .../iconsax/IconsaxFullscreenExit.kt | 90 ++++++ .../component/WindowFullscreenButton.jvm.kt | 74 +++++ .../WindowFullscreenButton.mobile.kt | 25 ++ 10 files changed, 649 insertions(+), 6 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/WindowFullscreenButton.kt create mode 100644 composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppFullscreenPlayer.kt create mode 100644 composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxFullscreen.kt create mode 100644 composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxFullscreenExit.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/ui/component/WindowFullscreenButton.jvm.kt create mode 100644 composeApp/src/mobileMain/kotlin/org/spotube/spotube/core/ui/component/WindowFullscreenButton.mobile.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index a5481e2e..e4d4947a 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -758,6 +758,8 @@ Collapse player Expand lyrics Now Playing + Open full screen player + Close full screen player Lyrics Alternative Source Alternative source @@ -963,6 +965,8 @@ Maximize Restore Close + Enter full screen + Exit full screen Playback diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/WindowFullscreenButton.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/WindowFullscreenButton.kt new file mode 100644 index 00000000..e27305d6 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/WindowFullscreenButton.kt @@ -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 . + */ + +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() diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppFullscreenPlayer.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppFullscreenPlayer.kt new file mode 100644 index 00000000..58d734ec --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppFullscreenPlayer.kt @@ -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 . + */ + +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( + 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 + ) + } + } + } + } +} 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 b54417ae..8d10ae80 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 @@ -66,6 +66,7 @@ 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.SwapHorizontal2 @@ -84,6 +85,7 @@ import spotube.composeapp.generated.resources.player_sleep_timer import spotube.composeapp.generated.resources.player_track_details import spotube.composeapp.generated.resources.player_alternative_source_content_description import spotube.composeapp.generated.resources.player_download +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.queue_title @@ -96,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, @@ -104,6 +107,7 @@ fun AppLargePlayer( onAlternativeSource: () -> Unit = {}, onMoreOptions: () -> Unit = {}, onLyrics: () -> Unit = {}, + onFullscreen: () -> Unit = {}, audioPlayer: AudioPlayerInterface = koinInject(), audioPlayerQueue: AudioPlayerQueue = koinInject(), downloadsViewModel: DownloadsViewModel = koinViewModel(), @@ -295,11 +299,21 @@ fun AppLargePlayer( }) } } - PlayerVolumeControl( - playerUiState = playerUiState, - sliderModifier = Modifier.fillMaxWidth(0.62f), - audioPlayer = audioPlayer - ) + Row(verticalAlignment = Alignment.CenterVertically) { + PlayerVolumeControl( + playerUiState = playerUiState, + sliderModifier = Modifier.fillMaxWidth(0.62f), + audioPlayer = audioPlayer + ) + MillenniumIconButton( + style = MillenniumIconButtonStyle.Quiet, onClick = onFullscreen + ) { + MillenniumIcon( + Iconsax.IconsaxFullscreen, + contentDescription = stringResource(Res.string.player_fullscreen_open_content_description) + ) + } + } } } } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppShell.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppShell.kt index 484fe48b..20fba020 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppShell.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppShell.kt @@ -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) { diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppShellViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppShellViewModel.kt index 482acbbf..3fb96a69 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppShellViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppShellViewModel.kt @@ -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 = lyricsOverlayVisible.asStateFlow() + val isFullscreenPlayerVisible: StateFlow = 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 + } } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxFullscreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxFullscreen.kt new file mode 100644 index 00000000..30bf0df7 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxFullscreen.kt @@ -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 . + */ + +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 diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxFullscreenExit.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxFullscreenExit.kt new file mode 100644 index 00000000..f4143771 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/resources/iconsax/IconsaxFullscreenExit.kt @@ -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 . + */ + +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 diff --git a/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/ui/component/WindowFullscreenButton.jvm.kt b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/ui/component/WindowFullscreenButton.jvm.kt new file mode 100644 index 00000000..f56b0393 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/ui/component/WindowFullscreenButton.jvm.kt @@ -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 . + */ + +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(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 + ) + ) + } +} diff --git a/composeApp/src/mobileMain/kotlin/org/spotube/spotube/core/ui/component/WindowFullscreenButton.mobile.kt b/composeApp/src/mobileMain/kotlin/org/spotube/spotube/core/ui/component/WindowFullscreenButton.mobile.kt new file mode 100644 index 00000000..81693e1e --- /dev/null +++ b/composeApp/src/mobileMain/kotlin/org/spotube/spotube/core/ui/component/WindowFullscreenButton.mobile.kt @@ -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 . + */ + +package org.spotube.spotube.core.ui.component + +import androidx.compose.runtime.Composable + +@Composable +actual fun WindowFullscreenButton() { + // No-op on mobile targets. +}