mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-11-04 10:13:59 +00:00
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:
parent
09edc5a182
commit
9785c93b6a
@ -758,6 +758,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>
|
||||
@ -963,6 +965,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>
|
||||
|
||||
@ -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()
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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(
|
||||
})
|
||||
}
|
||||
}
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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.
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user