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