diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 2fbae5e0..ffe01199 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -759,6 +759,8 @@ Collapse player Expand lyrics Now Playing + Open full screen player + Close full screen player Lyrics Alternative Source Alternative source @@ -964,6 +966,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 3490f2fa..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 @@ -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) - ) } } } 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 b8f3d388..23ed7be8 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 @@ -104,6 +109,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 { @@ -177,7 +183,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/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 + ) + } +} 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. +}