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. +}