Compare commits

...

3 Commits

Author SHA1 Message Date
Brian López Medina
42c2d0fa7e
Merge 9785c93b6a into b12e98a8c5 2026-10-30 22:09:56 -06:00
Brian López Medina
9785c93b6a 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>
2026-10-29 03:57:42 -06:00
Brian López Medina
09edc5a182 refactor(player): extract the large player's seek bar, transport and volume controls
Moves them into shared composables so another player can reuse the same
playback logic. The large player renders exactly as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 03:19:03 -06:00
11 changed files with 963 additions and 224 deletions

View File

@ -759,6 +759,8 @@
<string name="player_collapse_content_description">Collapse player</string>
<string name="player_expand_lyrics_content_description">Expand lyrics</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_alternative_source">Alternative Source</string>
<string name="player_alternative_source_content_description">Alternative source</string>
@ -964,6 +966,8 @@
<string name="app_window_maximize">Maximize</string>
<string name="app_window_restore">Restore</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 -->
<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

@ -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)
)
}
}
}

View File

@ -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) {

View File

@ -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<Boolean> = lyricsOverlayVisible.asStateFlow()
val isFullscreenPlayerVisible: StateFlow<Boolean> = 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
}
}

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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
)
}
}

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