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 <noreply@anthropic.com>
This commit is contained in:
Brian López Medina 2026-10-29 03:57:42 -06:00
parent 09edc5a182
commit 9785c93b6a
10 changed files with 649 additions and 6 deletions

View File

@ -758,6 +758,8 @@
<string name="player_collapse_content_description">Collapse player</string> <string name="player_collapse_content_description">Collapse player</string>
<string name="player_expand_lyrics_content_description">Expand lyrics</string> <string name="player_expand_lyrics_content_description">Expand lyrics</string>
<string name="player_now_playing">Now Playing</string> <string name="player_now_playing">Now Playing</string>
<string name="player_fullscreen_open_content_description">Open full screen player</string>
<string name="player_fullscreen_close_content_description">Close full screen player</string>
<string name="player_lyrics">Lyrics</string> <string name="player_lyrics">Lyrics</string>
<string name="player_alternative_source">Alternative Source</string> <string name="player_alternative_source">Alternative Source</string>
<string name="player_alternative_source_content_description">Alternative source</string> <string name="player_alternative_source_content_description">Alternative source</string>
@ -963,6 +965,8 @@
<string name="app_window_maximize">Maximize</string> <string name="app_window_maximize">Maximize</string>
<string name="app_window_restore">Restore</string> <string name="app_window_restore">Restore</string>
<string name="app_window_close">Close</string> <string name="app_window_close">Close</string>
<string name="app_window_enter_fullscreen">Enter full screen</string>
<string name="app_window_exit_fullscreen">Exit full screen</string>
<!-- Android playback notification --> <!-- Android playback notification -->
<string name="notification_playback_channel_name">Playback</string> <string name="notification_playback_channel_name">Playback</string>

View File

@ -0,0 +1,25 @@
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.spotube.spotube.core.ui.component
import androidx.compose.runtime.Composable
// Toggles the OS full screen mode of the app window. Renders nothing where the app doesn't own
// a window (mobile).
@Composable
expect fun WindowFullscreenButton()

View File

@ -0,0 +1,287 @@
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.spotube.spotube.modules.shell
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.BlurredEdgeTreatment
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.composeunstyled.Text
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
import org.spotube.millennium.composables.MillenniumIcon
import org.spotube.millennium.composables.MillenniumIconButton
import org.spotube.millennium.composables.MillenniumIconButtonStyle
import org.spotube.spotube.core.audioplayer.AudioPlayerInterface
import org.spotube.spotube.core.audioplayer.AudioPlayerQueue
import org.spotube.spotube.core.navigation.NavigationCommands
import org.spotube.spotube.core.navigation.Routes
import org.spotube.spotube.core.ui.component.ApplicationMainBar
import org.spotube.spotube.core.ui.component.WindowFullscreenButton
import org.spotube.spotube.core.ui.theming.AppTheme
import org.spotube.spotube.modules.saved_tracks.SAVED_TRACKS_COLLECTION_ID
import org.spotube.spotube.modules.saved_tracks.SavedTracksViewModel
import org.spotube.spotube.resources.iconsax.Iconsax
import org.spotube.spotube.resources.iconsax.IconsaxArrowDown4
import org.spotube.spotube.resources.iconsax.IconsaxMusic
import org.spotube.spotube.resources.iconsax.IconsaxMusicFilter
import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.player_fullscreen_close_content_description
import spotube.composeapp.generated.resources.player_lyrics
import spotube.composeapp.generated.resources.queue_title
// The "now playing" view on large screens, opened from the large player. It covers the whole
// window with the album art in the middle over a blurred copy of it, like the expanded player
// does on small screens.
//
// The top bar has no title, so it stays clear of the macOS window buttons, and on the right it
// has the window full screen toggle and the button to close the view (Esc too)
// Below the album art the song title and artists are shown with the like button on the right
// Then the progress bar, and below it the queue and lyrics buttons on the left, the playback
// controls in the center and the volume slider on the right
@Composable
fun AppFullscreenPlayer(
onClose: () -> Unit,
modifier: Modifier = Modifier,
onQueue: () -> Unit = {},
onLyrics: () -> Unit = {},
audioPlayer: AudioPlayerInterface = koinInject(),
audioPlayerQueue: AudioPlayerQueue = koinInject(),
savedTracksViewModel: SavedTracksViewModel = koinViewModel<SavedTracksViewModel>(
key = SAVED_TRACKS_COLLECTION_ID,
parameters = { parametersOf() }
)
) {
val playerUiState = rememberPlayerUiState(audioPlayer, audioPlayerQueue)
val navigationCommands: NavigationCommands = koinInject()
val focusRequester = remember { FocusRequester() }
val background = AppTheme.colors.background
// Takes the keyboard focus so Esc reaches this view
LaunchedEffect(focusRequester) {
focusRequester.requestFocus()
}
Box(
modifier = modifier
.fillMaxSize()
.clipToBounds()
.background(background)
// Keeps clicks from reaching the screen underneath
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = {})
.onPreviewKeyEvent { event ->
if (event.type == KeyEventType.KeyDown && event.key == Key.Escape) {
onClose()
true
} else {
false
}
}
.focusRequester(focusRequester)
.focusable()
) {
AsyncImage(
model = playerUiState.coverModel,
contentDescription = null,
modifier = Modifier
.fillMaxSize()
.blur(96.dp, BlurredEdgeTreatment.Rectangle),
contentScale = ContentScale.Crop
)
// Tones the art down towards the theme background, so the text stays readable in light
// and dark themes alike
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
listOf(background.copy(alpha = 0.45f), background.copy(alpha = 0.9f))
)
)
)
Column(
modifier = Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.safeDrawing),
horizontalAlignment = Alignment.CenterHorizontally
) {
ApplicationMainBar(
actions = {
WindowFullscreenButton()
MillenniumIconButton(
onClick = onClose,
style = MillenniumIconButtonStyle.Quiet
) {
MillenniumIcon(
Iconsax.IconsaxArrowDown4,
contentDescription = stringResource(Res.string.player_fullscreen_close_content_description)
)
}
},
backButton = false,
transparent = true
)
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.padding(horizontal = 48.dp, vertical = 16.dp),
contentAlignment = Alignment.Center
) {
Box(
modifier = Modifier
.widthIn(max = 640.dp)
.aspectRatio(1f, matchHeightConstraintsFirst = true)
.shadow(elevation = 24.dp, shape = RoundedCornerShape(16.dp))
.clip(RoundedCornerShape(16.dp))
.background(AppTheme.colors.surfaceContainerHighest)
) {
AsyncImage(
model = playerUiState.coverModel,
contentDescription = playerUiState.title,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
}
}
Column(
modifier = Modifier
.widthIn(max = 720.dp)
.fillMaxWidth()
.padding(start = 32.dp, end = 32.dp, bottom = 32.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = playerUiState.title,
style = AppTheme.typography.headlineSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
PlayerArtistsText(
artists = playerUiState.artistList,
onArtistClick = { artistId ->
navigationCommands.navigateTo(Routes.Artist(artistId))
// The page opens beneath this view, so get out of the way
onClose()
},
style = AppTheme.typography.bodyLarge,
color = AppTheme.colors.onSurfaceVariant
)
}
PlayerHeartButton(
audioPlayerQueue = audioPlayerQueue,
savedTracksViewModel = savedTracksViewModel
)
}
PlayerSeekBar(
playerUiState = playerUiState,
modifier = Modifier
.fillMaxWidth()
.padding(top = 20.dp),
audioPlayer = audioPlayer
)
Box(
modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp)
) {
Row(
modifier = Modifier.align(Alignment.CenterStart),
verticalAlignment = Alignment.CenterVertically
) {
MillenniumIconButton(
onClick = onQueue,
style = MillenniumIconButtonStyle.Quiet
) {
MillenniumIcon(Iconsax.IconsaxMusicFilter, contentDescription = stringResource(Res.string.queue_title))
}
MillenniumIconButton(
onClick = onLyrics,
style = MillenniumIconButtonStyle.Quiet
) {
MillenniumIcon(Iconsax.IconsaxMusic, contentDescription = stringResource(Res.string.player_lyrics))
}
}
PlayerTransportControls(
playerUiState = playerUiState,
modifier = Modifier.align(Alignment.Center),
horizontalArrangement = Arrangement.spacedBy(16.dp),
playIconSize = 30.dp,
audioPlayer = audioPlayer
)
PlayerVolumeControl(
playerUiState = playerUiState,
modifier = Modifier.align(Alignment.CenterEnd),
sliderModifier = Modifier.width(120.dp),
audioPlayer = audioPlayer
)
}
}
}
}
}

View File

@ -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.Iconsax
import org.spotube.spotube.resources.iconsax.Iconsax3DotsMore import org.spotube.spotube.resources.iconsax.Iconsax3DotsMore
import org.spotube.spotube.resources.iconsax.IconsaxDirectboxReceive 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.IconsaxMusic
import org.spotube.spotube.resources.iconsax.IconsaxMusicFilter import org.spotube.spotube.resources.iconsax.IconsaxMusicFilter
import org.spotube.spotube.resources.iconsax.SwapHorizontal2 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_track_details
import spotube.composeapp.generated.resources.player_alternative_source_content_description import spotube.composeapp.generated.resources.player_alternative_source_content_description
import spotube.composeapp.generated.resources.player_download 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_lyrics
import spotube.composeapp.generated.resources.player_more_options_content_description import spotube.composeapp.generated.resources.player_more_options_content_description
import spotube.composeapp.generated.resources.queue_title 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 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 // 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 // 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 @Composable
fun AppLargePlayer( fun AppLargePlayer(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@ -104,6 +107,7 @@ fun AppLargePlayer(
onAlternativeSource: () -> Unit = {}, onAlternativeSource: () -> Unit = {},
onMoreOptions: () -> Unit = {}, onMoreOptions: () -> Unit = {},
onLyrics: () -> Unit = {}, onLyrics: () -> Unit = {},
onFullscreen: () -> Unit = {},
audioPlayer: AudioPlayerInterface = koinInject(), audioPlayer: AudioPlayerInterface = koinInject(),
audioPlayerQueue: AudioPlayerQueue = koinInject(), audioPlayerQueue: AudioPlayerQueue = koinInject(),
downloadsViewModel: DownloadsViewModel = koinViewModel(), downloadsViewModel: DownloadsViewModel = koinViewModel(),
@ -295,11 +299,21 @@ fun AppLargePlayer(
}) })
} }
} }
Row(verticalAlignment = Alignment.CenterVertically) {
PlayerVolumeControl( PlayerVolumeControl(
playerUiState = playerUiState, playerUiState = playerUiState,
sliderModifier = Modifier.fillMaxWidth(0.62f), sliderModifier = Modifier.fillMaxWidth(0.62f),
audioPlayer = audioPlayer audioPlayer = audioPlayer
) )
MillenniumIconButton(
style = MillenniumIconButtonStyle.Quiet, onClick = onFullscreen
) {
MillenniumIcon(
Iconsax.IconsaxFullscreen,
contentDescription = stringResource(Res.string.player_fullscreen_open_content_description)
)
}
}
} }
} }
} }

View File

@ -17,7 +17,12 @@
package org.spotube.spotube.modules.shell package org.spotube.spotube.modules.shell
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.ExperimentalSharedTransitionApi 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.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@ -98,6 +103,7 @@ fun AppShell(
val isQueueVisible by queueViewModel.isQueueVisible.collectAsState() val isQueueVisible by queueViewModel.isQueueVisible.collectAsState()
val isAlternativeVisible by alternativeViewModel.isAlternativeVisible.collectAsState() val isAlternativeVisible by alternativeViewModel.isAlternativeVisible.collectAsState()
val isLyricsOverlayVisible by viewModel.isLyricsOverlayVisible.collectAsState() val isLyricsOverlayVisible by viewModel.isLyricsOverlayVisible.collectAsState()
val isFullscreenPlayerVisible by viewModel.isFullscreenPlayerVisible.collectAsState()
LaunchedEffect(navigatorCommands, navigator) { LaunchedEffect(navigatorCommands, navigator) {
launch { launch {
@ -171,7 +177,25 @@ fun AppShell(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
onQueue = queueViewModel::toggleQueueVisibility, onQueue = queueViewModel::toggleQueueVisibility,
onAlternativeSource = alternativeViewModel::toggleAlternativeVisibility, 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) { if (isQueueVisible) {

View File

@ -54,6 +54,7 @@ class AppShellViewModel : ViewModel() {
val expandedPlayerVisibilityThreshold = 0.001f val expandedPlayerVisibilityThreshold = 0.001f
private val lyricsOverlayVisible = MutableStateFlow(false) private val lyricsOverlayVisible = MutableStateFlow(false)
private val fullscreenPlayerVisible = MutableStateFlow(false)
private val compactChrome = MutableStateFlow(CompactChromeUiState()) private val compactChrome = MutableStateFlow(CompactChromeUiState())
val compactChromeUiState = compactChrome.asStateFlow() val compactChromeUiState = compactChrome.asStateFlow()
@ -62,6 +63,7 @@ class AppShellViewModel : ViewModel() {
} }
val isLyricsOverlayVisible: StateFlow<Boolean> = lyricsOverlayVisible.asStateFlow() val isLyricsOverlayVisible: StateFlow<Boolean> = lyricsOverlayVisible.asStateFlow()
val isFullscreenPlayerVisible: StateFlow<Boolean> = fullscreenPlayerVisible.asStateFlow()
@Composable @Composable
fun useSidebar(maxWidth: Dp? = null): Boolean { fun useSidebar(maxWidth: Dp? = null): Boolean {
@ -113,4 +115,12 @@ class AppShellViewModel : ViewModel() {
fun toggleLyricsOverlay() { fun toggleLyricsOverlay() {
lyricsOverlayVisible.update { !it } lyricsOverlayVisible.update { !it }
} }
fun showFullscreenPlayer() {
fullscreenPlayerVisible.value = true
}
fun hideFullscreenPlayer() {
fullscreenPlayerVisible.value = false
}
} }

View File

@ -0,0 +1,90 @@
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.spotube.spotube.resources.iconsax
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.unit.dp
val Iconsax.IconsaxFullscreen: ImageVector
get() {
if (_IconsaxFullscreen != null) {
return _IconsaxFullscreen!!
}
_IconsaxFullscreen = ImageVector.Builder(
name = "IconsaxFullscreen",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 24f,
viewportHeight = 24f
).apply {
path(
stroke = SolidColor(Color.White),
strokeLineWidth = 1.5f,
strokeLineCap = StrokeCap.Round,
strokeLineJoin = StrokeJoin.Round
) {
moveTo(3f, 9f)
verticalLineTo(5f)
curveTo(3f, 3.9f, 3.9f, 3f, 5f, 3f)
horizontalLineTo(9f)
}
path(
stroke = SolidColor(Color.White),
strokeLineWidth = 1.5f,
strokeLineCap = StrokeCap.Round,
strokeLineJoin = StrokeJoin.Round
) {
moveTo(15f, 3f)
horizontalLineTo(19f)
curveTo(20.1f, 3f, 21f, 3.9f, 21f, 5f)
verticalLineTo(9f)
}
path(
stroke = SolidColor(Color.White),
strokeLineWidth = 1.5f,
strokeLineCap = StrokeCap.Round,
strokeLineJoin = StrokeJoin.Round
) {
moveTo(21f, 15f)
verticalLineTo(19f)
curveTo(21f, 20.1f, 20.1f, 21f, 19f, 21f)
horizontalLineTo(15f)
}
path(
stroke = SolidColor(Color.White),
strokeLineWidth = 1.5f,
strokeLineCap = StrokeCap.Round,
strokeLineJoin = StrokeJoin.Round
) {
moveTo(9f, 21f)
horizontalLineTo(5f)
curveTo(3.9f, 21f, 3f, 20.1f, 3f, 19f)
verticalLineTo(15f)
}
}.build()
return _IconsaxFullscreen!!
}
@Suppress("ObjectPropertyName")
private var _IconsaxFullscreen: ImageVector? = null

View File

@ -0,0 +1,90 @@
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.spotube.spotube.resources.iconsax
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.unit.dp
val Iconsax.IconsaxFullscreenExit: ImageVector
get() {
if (_IconsaxFullscreenExit != null) {
return _IconsaxFullscreenExit!!
}
_IconsaxFullscreenExit = ImageVector.Builder(
name = "IconsaxFullscreenExit",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 24f,
viewportHeight = 24f
).apply {
path(
stroke = SolidColor(Color.White),
strokeLineWidth = 1.5f,
strokeLineCap = StrokeCap.Round,
strokeLineJoin = StrokeJoin.Round
) {
moveTo(9f, 3f)
verticalLineTo(7f)
curveTo(9f, 8.1f, 8.1f, 9f, 7f, 9f)
horizontalLineTo(3f)
}
path(
stroke = SolidColor(Color.White),
strokeLineWidth = 1.5f,
strokeLineCap = StrokeCap.Round,
strokeLineJoin = StrokeJoin.Round
) {
moveTo(21f, 9f)
horizontalLineTo(17f)
curveTo(15.9f, 9f, 15f, 8.1f, 15f, 7f)
verticalLineTo(3f)
}
path(
stroke = SolidColor(Color.White),
strokeLineWidth = 1.5f,
strokeLineCap = StrokeCap.Round,
strokeLineJoin = StrokeJoin.Round
) {
moveTo(15f, 21f)
verticalLineTo(17f)
curveTo(15f, 15.9f, 15.9f, 15f, 17f, 15f)
horizontalLineTo(21f)
}
path(
stroke = SolidColor(Color.White),
strokeLineWidth = 1.5f,
strokeLineCap = StrokeCap.Round,
strokeLineJoin = StrokeJoin.Round
) {
moveTo(3f, 15f)
horizontalLineTo(7f)
curveTo(8.1f, 15f, 9f, 15.9f, 9f, 17f)
verticalLineTo(21f)
}
}.build()
return _IconsaxFullscreenExit!!
}
@Suppress("ObjectPropertyName")
private var _IconsaxFullscreenExit: ImageVector? = null

View File

@ -0,0 +1,74 @@
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.spotube.spotube.core.ui.component
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.window.WindowPlacement
import org.jetbrains.compose.resources.stringResource
import org.spotube.millennium.composables.MillenniumIcon
import org.spotube.millennium.composables.MillenniumIconButton
import org.spotube.millennium.composables.MillenniumIconButtonStyle
import org.spotube.spotube.resources.iconsax.Iconsax
import org.spotube.spotube.resources.iconsax.IconsaxFullscreen
import org.spotube.spotube.resources.iconsax.IconsaxFullscreenExit
import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.app_window_enter_fullscreen
import spotube.composeapp.generated.resources.app_window_exit_fullscreen
@Composable
actual fun WindowFullscreenButton() {
val windowState = LocalWindowState.current
// Where to go back to on exit; only set while this button put the window in full screen
var placementBeforeFullscreen by remember { mutableStateOf<WindowPlacement?>(null) }
val isFullscreen = windowState.placement == WindowPlacement.Fullscreen
// Full screen belongs to the screen that turned it on, so leaving that screen leaves it too
DisposableEffect(windowState) {
onDispose {
val previous = placementBeforeFullscreen
if (previous != null && windowState.placement == WindowPlacement.Fullscreen) {
windowState.placement = previous
}
}
}
MillenniumIconButton(
onClick = {
if (isFullscreen) {
windowState.placement = placementBeforeFullscreen ?: WindowPlacement.Floating
placementBeforeFullscreen = null
} else {
placementBeforeFullscreen = windowState.placement
windowState.placement = WindowPlacement.Fullscreen
}
},
style = MillenniumIconButtonStyle.Quiet
) {
MillenniumIcon(
imageVector = if (isFullscreen) Iconsax.IconsaxFullscreenExit else Iconsax.IconsaxFullscreen,
contentDescription = stringResource(
if (isFullscreen) Res.string.app_window_exit_fullscreen else Res.string.app_window_enter_fullscreen
)
)
}
}

View File

@ -0,0 +1,25 @@
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.spotube.spotube.core.ui.component
import androidx.compose.runtime.Composable
@Composable
actual fun WindowFullscreenButton() {
// No-op on mobile targets.
}