Compare commits

...

8 Commits

24 changed files with 1360 additions and 1040 deletions

View File

@ -29,7 +29,10 @@
- `plugin_interfaces` exports `zipline.core` and `semver` as API. `composeApp` depends on it for the plugin system.
- `plugin_interfaces` also has a JS target (`browser()`), used by the plugin system.
## Desktop JVM specifics
## UI component patterns
- **AdaptiveDropdownBottomSheet** (`commonMain/.../core/ui/component/AdaptiveDropdownBottomSheet.kt`): switches between `DropdownMenu` (large screen) and `ModalBottomSheet` (small screen) via `currentWindowAdaptiveInfo()`. Do NOT use expect/actual — all adaptive components that rely ONLY on Compose/Material3 APIs belong in commonMain.
- **AdaptiveDialogBottomSheet** (`commonMain/.../core/ui/component/AdaptiveDialogBottomSheet.kt`): switches between `ThemedDialog` (large screen) and `ModalBottomSheet` (small screen) via `currentWindowAdaptiveInfo()`. Same rule — keep in commonMain unless platform-specific APIs are required.
- Use `expect`/`actual` only when the component MUST use platform-specific APIs (e.g. `WindowState` for desktop window controls, native scrollbars). Pure Compose/Material3 adaptivity stays in commonMain.
- JavaFX is required; `--add-opens` flags in `compose.desktop.application.jvmArgs` must be preserved: `javafx.graphics/javafx.scene`, `javafx.graphics/com.sun.javafx.sg.prism`, `javafx.graphics/com.sun.javafx.scene`, `javafx.web/com.sun.webkit`, `javafx.media/com.sun.media.jfxmedia`, `javafx.media/com.sun.media.jfxmedia.events`.
- JavaFX dependencies are loaded from OpenJFX with platform classifiers (win/mac/linux) resolved at configuration time via `System.getProperty("os.name")`.

View File

@ -126,6 +126,7 @@
<string name="settings_plugins_plugin_content_description">%1$s plugin</string>
<string name="settings_plugins_clear_content_description">Clear</string>
<string name="plugin_configure_title">Configure</string>
<string name="plugin_screen_title">Plugin Manager</string>
<string name="plugin_error_enter_url">Please enter a URL</string>
<string name="plugin_error_url_scheme">URL must start with http:// or https://</string>
@ -145,6 +146,8 @@
<string name="plugin_action_remove">Remove plugin</string>
<string name="plugin_action_login">Login</string>
<string name="plugin_action_logout">Logout</string>
<string name="plugin_section_url_title">Download from URL</string>
<string name="plugin_section_file_title">Install from file</string>
<string name="plugin_permissions_author_version">• %1$s</string>
<string name="plugin_permissions_requested_title">Permissions requested</string>

View File

@ -0,0 +1,124 @@
/*
* 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 dev.krtirtho.spotube.core.ui.component
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.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import dev.krtirtho.spotube.core.ui.base.IconButton
import dev.krtirtho.spotube.core.ui.base.ThemedDialog
import dev.krtirtho.spotube.resources.iconsax.FluentDismiss
import dev.krtirtho.spotube.resources.iconsax.Iconsax
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AdaptiveDialogBottomSheet(
onDismiss: () -> Unit,
title: @Composable (() -> Unit)? = null,
modifier: Modifier = Modifier,
breakpointDp: Float = 600f,
actions: @Composable (() -> Unit)? = null,
content: @Composable () -> Unit,
) {
val adaptiveInfo = currentWindowAdaptiveInfo()
val isLargeScreen = adaptiveInfo.windowSizeClass.minWidthDp >= breakpointDp
if (isLargeScreen) {
ThemedDialog(
onDismissRequest = onDismiss,
title = if (title != null) {
{
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Box(Modifier.weight(1f)) { title() }
IconButton(onClick = onDismiss) {
Icon(
Iconsax.FluentDismiss,
contentDescription = "Close dialog",
)
}
}
}
} else {
null
},
actions = actions,
modifier = modifier,
) {
content()
}
} else {
ModalBottomSheet(
onDismissRequest = onDismiss,
dragHandle = null,
modifier = modifier,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.padding(top = 16.dp),
horizontalAlignment = Alignment.Start,
) {
title?.let {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Box(Modifier.weight(1f)) { it() }
IconButton(onClick = onDismiss) {
Icon(
Iconsax.FluentDismiss,
contentDescription = "Close dialog",
)
}
}
Spacer(Modifier.height(12.dp))
}
content()
actions?.let {
Spacer(Modifier.height(8.dp))
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f))
Spacer(Modifier.height(8.dp))
it()
}
}
Spacer(Modifier.height(24.dp))
}
}
}

View File

@ -32,7 +32,6 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@ -51,7 +50,6 @@ import dev.krtirtho.spotube.core.ui.base.OutlineButton
import dev.krtirtho.spotube.core.ui.base.PrimaryButton
import dev.krtirtho.spotube.core.ui.misc.TextWithShimmer
import dev.krtirtho.spotube.core.ui.misc.shimmerApply
import dev.krtirtho.spotube.resources.iconsax.ArrowLeft3
import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare
import dev.krtirtho.spotube.resources.iconsax.IconsaxEdit
@ -319,102 +317,6 @@ fun CollectionDetails(
}
}
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun CollapsedCollectionHeader(
title: String,
imageURL: String,
imageResource: DrawableResource? = null,
isPlaying: Boolean,
onPlay: () -> Unit,
modifier: Modifier = Modifier,
onBack: (() -> Unit)? = null,
sharedElementKey: String? = null,
) {
val sharedTransitionScope = LocalSharedTransitionScope.current
val animatedVisibilityScope = LocalAnimatedVisibilityScope.current
Card(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
if (onBack != null) {
IconButton(
onClick = onBack,
modifier = Modifier.size(36.dp),
) {
Icon(
imageVector = Iconsax.ArrowLeft3,
contentDescription = "Back",
modifier = Modifier.size(20.dp),
)
}
}
Box(
modifier = Modifier
.size(36.dp)
.clip(RoundedCornerShape(6.dp))
.sharedElementOrNone(
key = sharedElementKey,
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
)
.shimmerApply(),
contentAlignment = Alignment.Center,
) {
if (imageURL.isNotBlank()) {
AsyncImage(
model = imageURL,
contentDescription = title,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
)
} else if (imageResource != null) {
androidx.compose.foundation.Image(
painter = painterResource(imageResource),
contentDescription = title,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
)
} else {
Text(
text = title.take(1).ifBlank { "?" }.uppercase(),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
TextWithShimmer(
text = title,
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
IconButton(
onClick = onPlay,
modifier = Modifier.size(36.dp),
) {
Icon(
imageVector = if (isPlaying) Iconsax.IconsaxPauseCircle else Iconsax.IconsaxPlayCircle2,
contentDescription = if (isPlaying) "Pause" else "Play",
modifier = Modifier.size(24.dp),
)
}
}
}
}
@Composable
@androidx.compose.ui.tooling.preview.Preview
fun CollectionDetailsPreview() {

View File

@ -0,0 +1,257 @@
/*
* 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 dev.krtirtho.spotube.core.ui.component
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import dev.krtirtho.spotube.core.ui.misc.TextWithShimmer
import dev.krtirtho.spotube.core.ui.misc.shimmerApply
import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxPauseCircle
import dev.krtirtho.spotube.resources.iconsax.IconsaxPlayCircle2
import org.jetbrains.compose.resources.DrawableResource
import org.jetbrains.compose.resources.painterResource
@Composable
fun CollectionView(
title: String,
description: String,
imageURL: String,
imageResource: DrawableResource? = null,
ownerName: String,
ownerImageURL: String?,
onOwnerClick: () -> Unit,
onPlay: () -> Unit,
onShufflePlay: () -> Unit,
onAddToQueue: () -> Unit,
isPlaying: Boolean = false,
isFollowing: Boolean = false,
onFollowClick: () -> Unit = {},
showFollowButton: Boolean = true,
onEdit: (() -> Unit)? = null,
sharedElementKey: String? = null,
isLoading: Boolean = false,
error: String? = null,
onRetry: (() -> Unit)? = null,
tracks: List<MetadataTrack> = emptyList(),
hasMore: Boolean = false,
isLoadingNextPage: Boolean = false,
currentTrackId: String? = null,
isCurrentTrackPlaying: Boolean = false,
onTrackClick: (MetadataTrack) -> Unit = {},
onLoadNextPage: () -> Unit = {},
onTrackOptionsAction: (MetadataTrack, TrackOptionsAction) -> Unit = { _, _ -> },
onArtistClick: (MetadataArtist.Basic) -> Unit = {},
onAlbumClick: (MetadataAlbum.Detailed) -> Unit = {},
onArtistsOverflowClick: (MetadataTrack) -> Unit = {},
onBulkDownload: (List<MetadataTrack>) -> Unit = {},
onBulkAddToQueue: (List<MetadataTrack>) -> Unit = {},
onBulkPlayNext: (List<MetadataTrack>) -> Unit = {},
onBulkAddToPlaylist: (List<MetadataTrack>) -> Unit = {},
trackOptionsState: (MetadataTrack) -> TrackOptionsState = { TrackOptionsState() },
footerContent: (@Composable () -> Unit)? = null,
trailingContent: @Composable () -> Unit = {},
) {
val listState = rememberLazyListState()
val isCollapsed by remember {
derivedStateOf {
listState.firstVisibleItemIndex > 0 ||
listState.firstVisibleItemScrollOffset > 400
}
}
Scaffold(
topBar = {
ApplicationMainBar(
backButton = true,
title = if (isCollapsed) {
{
CollapsedCollectionTitle(
title = title,
imageURL = imageURL,
imageResource = imageResource,
)
}
} else {
{}
},
actions = if (isCollapsed) {
{
IconButton(onClick = onPlay) {
Icon(
imageVector = if (isPlaying) Iconsax.IconsaxPauseCircle else Iconsax.IconsaxPlayCircle2,
contentDescription = if (isPlaying) "Pause" else "Play",
)
}
}
} else {
{}
},
)
}
) { innerPadding ->
if (error != null && tracks.isEmpty() && !isLoading) {
ErrorDisplay(
errorMessage = error,
onRetry = onRetry ?: {},
modifier = Modifier.padding(innerPadding),
)
} else {
TrackList(
modifier = Modifier.padding(innerPadding),
state = listState,
headerContent = {
if (isLoading && tracks.isEmpty()) {
dev.krtirtho.spotube.core.ui.misc.SkeletonTree(true) {
CollectionDetails(
title = title.ifBlank { "Loading..." },
description = description,
imageURL = imageURL,
imageResource = imageResource,
ownerName = ownerName.ifBlank { "Unknown" },
ownerImageURL = ownerImageURL,
onOwnerClick = onOwnerClick,
onPlay = onPlay,
onShufflePlay = onShufflePlay,
onAddToQueue = onAddToQueue,
isPlaying = isPlaying,
isFollowing = isFollowing,
onFollowClick = onFollowClick,
showFollowButton = showFollowButton,
onEdit = onEdit,
sharedElementKey = sharedElementKey,
)
}
} else {
CollectionDetails(
title = title,
description = description,
imageURL = imageURL,
imageResource = imageResource,
ownerName = ownerName,
ownerImageURL = ownerImageURL,
onOwnerClick = onOwnerClick,
onPlay = onPlay,
onShufflePlay = onShufflePlay,
onAddToQueue = onAddToQueue,
isPlaying = isPlaying,
isFollowing = isFollowing,
onFollowClick = onFollowClick,
showFollowButton = showFollowButton,
onEdit = onEdit,
sharedElementKey = sharedElementKey,
)
}
},
footerContent = footerContent,
tracks = tracks,
error = error,
hasMore = hasMore,
isLoading = isLoading,
isLoadingNextPage = isLoadingNextPage,
currentTrackId = currentTrackId,
isCurrentTrackPlaying = isCurrentTrackPlaying,
onTrackClick = onTrackClick,
onLoadNextPage = onLoadNextPage,
onTrackOptionsAction = onTrackOptionsAction,
onArtistClick = onArtistClick,
onAlbumClick = onAlbumClick,
onArtistsOverflowClick = onArtistsOverflowClick,
onBulkDownload = onBulkDownload,
onBulkAddToQueue = onBulkAddToQueue,
onBulkPlayNext = onBulkPlayNext,
onBulkAddToPlaylist = onBulkAddToPlaylist,
trackOptionsState = trackOptionsState,
)
}
trailingContent()
}
}
@Composable
private fun CollapsedCollectionTitle(
title: String,
imageURL: String,
imageResource: DrawableResource? = null,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.size(36.dp)
.clip(RoundedCornerShape(6.dp))
.shimmerApply(),
contentAlignment = Alignment.Center,
) {
if (imageURL.isNotBlank()) {
AsyncImage(
model = imageURL,
contentDescription = title,
modifier = Modifier.size(36.dp),
contentScale = ContentScale.Crop,
)
} else if (imageResource != null) {
androidx.compose.foundation.Image(
painter = painterResource(imageResource),
contentDescription = title,
modifier = Modifier.size(36.dp),
contentScale = ContentScale.Crop,
)
} else {
Text(
text = title.take(1).ifBlank { "?" }.uppercase(),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
TextWithShimmer(
text = title,
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(start = 10.dp),
)
}
}

View File

@ -18,11 +18,8 @@
package dev.krtirtho.spotube.core.ui.component
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
@ -154,8 +151,6 @@ fun TrackList(
simplified: Boolean = false,
scrollable: Boolean = true,
state: LazyListState? = null,
collapsedHeader: (@Composable () -> Unit)? = null,
isCollapsedHeaderShown: Boolean = false,
) {
var filterQuery by rememberSaveable { mutableStateOf("") }
var sortBy by rememberSaveable { mutableStateOf(TrackSortOption.None) }
@ -491,17 +486,6 @@ fun TrackList(
}
}
if (collapsedHeader != null) {
AnimatedVisibility(
visible = isCollapsedHeaderShown,
enter = fadeIn() + slideInVertically { -it },
exit = fadeOut() + slideOutVertically { -it },
modifier = Modifier.align(Alignment.TopCenter).padding(horizontal = if (isCompact) 6.dp else 16.dp),
) {
collapsedHeader()
}
}
if (!useDropdownForOptions) {
selectedTrackForOptions?.let { track ->
TrackOptionsBottomSheet(

View File

@ -17,18 +17,13 @@
package dev.krtirtho.spotube.modules.album
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import dev.krtirtho.spotube.core.audioplayer.AudioPlayer
@ -38,14 +33,9 @@ import dev.krtirtho.spotube.core.audioplayer.QueueEntry
import dev.krtirtho.spotube.core.navigation.NavigationCommands
import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.core.share.ShareService
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
import dev.krtirtho.spotube.core.ui.component.CollapsedCollectionHeader
import dev.krtirtho.spotube.core.ui.component.CollectionDetails
import dev.krtirtho.spotube.core.ui.component.ErrorDisplay
import dev.krtirtho.spotube.core.ui.component.TrackList
import dev.krtirtho.spotube.core.ui.component.CollectionView
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
import dev.krtirtho.spotube.core.ui.component.TrackOptionsState
import dev.krtirtho.spotube.core.ui.misc.SkeletonTree
import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel
import dev.krtirtho.spotube.modules.library.LibraryRepository
import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker
@ -78,14 +68,6 @@ fun AlbumScreen(albumId: String) {
var tracksToAddToPlaylist by remember { mutableStateOf<List<MetadataTrack>>(emptyList()) }
var currentUserId by remember { mutableStateOf<String?>(null) }
val listState = rememberLazyListState()
val isCollapsedHeaderShown by remember {
derivedStateOf {
listState.firstVisibleItemIndex > 0 ||
listState.firstVisibleItemScrollOffset > 400
}
}
LaunchedEffect(Unit) {
currentUserId = libraryRepository.currentUser()?.id
}
@ -120,58 +102,8 @@ fun AlbumScreen(albumId: String) {
}
}
Scaffold(
topBar = { ApplicationMainBar(backButton = !isCollapsedHeaderShown) }
) { innerPadding ->
when (state) {
is AlbumScreenState.Loading -> {
TrackList(
modifier = Modifier.padding(innerPadding),
headerContent = {
SkeletonTree(true) {
CollectionDetails(
title = "Loading album...",
description = "",
imageURL = "",
ownerName = "Unknown artist",
ownerImageURL = null,
onOwnerClick = {},
onPlay = {},
onShufflePlay = {},
onAddToQueue = {},
isPlaying = false,
isFollowing = false,
onFollowClick = {},
)
}
},
tracks = emptyList(),
error = null,
hasMore = false,
isLoading = true,
isLoadingNextPage = false,
currentTrackId = null,
isCurrentTrackPlaying = false,
onTrackClick = {},
onLoadNextPage = {},
onArtistClick = { navigationCommands.navigateTo(Routes.Artist(it.id)) },
onAlbumClick = { navigationCommands.navigateTo(Routes.Album(it.id)) },
onTrackOptionsAction = { _, _ -> },
trackOptionsState = { TrackOptionsState() },
)
}
is AlbumScreenState.Error -> {
ErrorDisplay(
errorMessage = (state as AlbumScreenState.Error).message,
onRetry = { viewModel.refresh() },
modifier = Modifier.padding(innerPadding),
)
}
is AlbumScreenState.Data -> {
val dataState = state as AlbumScreenState.Data
val album = dataState.album
val dataState = state as? AlbumScreenState.Data
val album = dataState?.album
val ownerName =
album?.artists?.joinToString { it.name }.orEmpty().ifBlank { "Unknown artist" }
val ownerImageURL = album?.artists?.firstOrNull()?.thumbnails?.firstOrNull()?.url
@ -180,21 +112,10 @@ fun AlbumScreen(albumId: String) {
val isAlbumPlaying = currentCollectionEntry?.id == albumId &&
playerState == PlayerState.PLAYING
TrackList(
modifier = Modifier.padding(innerPadding),
state = listState,
collapsedHeader = {
CollapsedCollectionHeader(
title = album?.title ?: "Album",
imageURL = artworkUrl,
isPlaying = isAlbumPlaying,
onPlay = viewModel::playAlbum,
onBack = { navigationCommands.pop() },
)
},
isCollapsedHeaderShown = isCollapsedHeaderShown,
headerContent = {
CollectionDetails(
val errorMessage = (state as? AlbumScreenState.Error)?.message
val isLoading = state is AlbumScreenState.Loading && (dataState == null || dataState.tracks.isEmpty())
CollectionView(
title = album?.title ?: "Loading album...",
description = album?.description ?: "${album?.albumType?.name.orEmpty()}${album?.releaseDate.orEmpty()}",
imageURL = artworkUrl,
@ -202,9 +123,7 @@ fun AlbumScreen(albumId: String) {
ownerImageURL = ownerImageURL,
onOwnerClick = {
firstArtistId?.let {
navigationCommands.navigateTo(
Routes.Artist(it)
)
navigationCommands.navigateTo(Routes.Artist(it))
}
},
onPlay = viewModel::playAlbum,
@ -213,12 +132,11 @@ fun AlbumScreen(albumId: String) {
isPlaying = isAlbumPlaying,
isFollowing = savedAlbumIds.contains(albumId),
onFollowClick = viewModel::toggleSavedAlbum,
)
},
tracks = dataState.tracks,
error = null,
hasMore = dataState.nextPagination != null,
isLoading = state is AlbumScreenState.Loading && dataState.tracks.isEmpty(),
isLoading = isLoading,
error = errorMessage,
onRetry = { viewModel.refresh() },
tracks = dataState?.tracks ?: emptyList(),
hasMore = dataState?.nextPagination != null,
isLoadingNextPage = state is AlbumScreenState.Data.LoadingMore,
currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id,
isCurrentTrackPlaying = playerState == PlayerState.PLAYING,
@ -241,10 +159,7 @@ fun AlbumScreen(albumId: String) {
tracksToAddToPlaylist = tracks
showAddToPlaylistPicker = true
},
)
}
}
trailingContent = {
AddToPlaylistPicker(
visible = showAddToPlaylistPicker,
currentUserId = currentUserId,
@ -258,5 +173,6 @@ fun AlbumScreen(albumId: String) {
}
},
)
}
},
)
}

View File

@ -19,8 +19,6 @@ package dev.krtirtho.spotube.modules.library
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
@ -34,19 +32,17 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import dev.krtirtho.spotube.core.ui.base.ChipTab
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
import dev.krtirtho.spotube.modules.downloads.DownloadsScreen
import dev.krtirtho.spotube.modules.library.album.LibraryAlbumsScreen
import dev.krtirtho.spotube.modules.library.artist.LibraryArtistsScreen
import dev.krtirtho.spotube.modules.library.local_tracks.LibraryLocalTracksScreen
import dev.krtirtho.spotube.modules.library.playlist.LibraryPlaylistsScreen
import dev.krtirtho.spotube.modules.downloads.DownloadsScreen
import dev.krtirtho.spotube.modules.shell.AppShellViewModel
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch
import dev.krtirtho.spotube.resources.iconsax.IconsaxSearchBroken
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
@ -94,11 +90,12 @@ fun LibraryScreen(
) {
items(LibraryTab.entries.size) { index ->
val tab = LibraryTab.entries[index]
FilterChip(
label = { Text(tab.title) },
ChipTab(
selected = currentTab == tab,
onClick = { libraryState.onTabSelected(tab) },
)
) {
Text(tab.title)
}
}
}
Spacer(modifier = Modifier.height(12.dp))

View File

@ -21,12 +21,9 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Icon
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@ -44,20 +41,14 @@ import dev.krtirtho.spotube.core.navigation.NavigationCommands
import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.core.share.ShareService
import dev.krtirtho.spotube.core.ui.base.OutlineButton
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
import dev.krtirtho.spotube.core.ui.component.CollapsedCollectionHeader
import dev.krtirtho.spotube.core.ui.component.CollectionDetails
import dev.krtirtho.spotube.core.ui.component.ErrorDisplay
import dev.krtirtho.spotube.core.ui.component.TrackList
import dev.krtirtho.spotube.core.ui.component.CollectionView
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
import dev.krtirtho.spotube.core.ui.component.TrackOptionsState
import dev.krtirtho.spotube.core.ui.misc.SkeletonTree
import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel
import dev.krtirtho.spotube.modules.library.LibraryRepository
import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker
import dev.krtirtho.spotube.modules.library.playlist.PlaylistFormData
import dev.krtirtho.spotube.modules.library.playlist.PlaylistFormSheet
import dev.krtirtho.spotube.modules.playlist.AddTracksToPlaylistDialog
import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare
import kotlinx.coroutines.launch
@ -90,14 +81,6 @@ fun PlaylistScreen(playlistId: String) {
var showAddTracksDialog by remember { mutableStateOf(false) }
var tracksToAddToPlaylist by remember { mutableStateOf<List<MetadataTrack>>(emptyList()) }
val listState = rememberLazyListState()
val isCollapsedHeaderShown by remember {
derivedStateOf {
listState.firstVisibleItemIndex > 0 ||
listState.firstVisibleItemScrollOffset > 400
}
}
fun handleTrackOptionsAction(track: MetadataTrack, action: TrackOptionsAction) {
viewModel.handleTrackOptionsAction(track, action)
if (action is TrackOptionsAction.Share) {
@ -115,70 +98,18 @@ fun PlaylistScreen(playlistId: String) {
}
}
Scaffold(
topBar = { ApplicationMainBar(backButton = !isCollapsedHeaderShown) }
) { innerPadding ->
when (state) {
is PlaylistScreenState.Loading -> {
TrackList(
modifier = Modifier.padding(innerPadding),
headerContent = {
SkeletonTree(true){
CollectionDetails(
title = "Loading playlist...",
description = "",
imageURL = "",
ownerName = "Unknown",
ownerImageURL = null,
onOwnerClick = {},
onPlay = {},
onShufflePlay = {},
onAddToQueue = {},
isPlaying = false,
isFollowing = false,
onFollowClick = {},
)
}
},
tracks = emptyList(),
error = null,
hasMore = false,
isLoading = true,
isLoadingNextPage = false,
currentTrackId = null,
isCurrentTrackPlaying = false,
onTrackClick = {},
onLoadNextPage = {},
onArtistClick = { navigationCommands.navigateTo(Routes.Artist(it.id)) },
onAlbumClick = { navigationCommands.navigateTo(Routes.Album(it.id)) },
onTrackOptionsAction = { _, _ -> },
trackOptionsState = { TrackOptionsState() },
)
}
is PlaylistScreenState.Error -> {
ErrorDisplay(
errorMessage = (state as PlaylistScreenState.Error).message,
onRetry = { viewModel.refresh() },
modifier = Modifier.padding(innerPadding),
)
}
is PlaylistScreenState.Data -> {
val dataState = state as PlaylistScreenState.Data
val playlist = dataState.playlist
val dataState = state as? PlaylistScreenState.Data
val playlist = dataState?.playlist
val owner = playlist?.owner
val ownerName = owner?.displayName ?: owner?.username ?: "Unknown"
val ownerImageURL = owner?.thumbnails?.firstOrNull()?.url
val isOwner = currentUserId != null && playlist?.owner?.id == currentUserId
val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle()
val artworkUrl = playlist?.thumbnails?.firstOrNull()?.url.orEmpty()
val isPlaying = currentCollectionEntry?.id == playlistId &&
playerState == PlayerState.PLAYING
val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle()
fun getTrackOptionsState(track: MetadataTrack): TrackOptionsState {
val currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id
val queueTrackIds = queue.mapNotNull { entry ->
@ -192,38 +123,10 @@ fun PlaylistScreen(playlistId: String) {
)
}
TrackList(
modifier = Modifier.padding(innerPadding),
state = listState,
collapsedHeader = {
CollapsedCollectionHeader(
title = playlist?.title ?: "Playlist",
imageURL = artworkUrl,
isPlaying = isPlaying,
onPlay = viewModel::playPlaylist,
onBack = { navigationCommands.pop() },
)
},
isCollapsedHeaderShown = isCollapsedHeaderShown,
headerContent = {
CollectionDetails(
title = playlist?.title ?: "Loading playlist...",
description = playlist?.description.orEmpty(),
imageURL = artworkUrl,
ownerName = ownerName,
ownerImageURL = ownerImageURL,
onOwnerClick = {},
onPlay = viewModel::playPlaylist,
onShufflePlay = {},
onAddToQueue = viewModel::addPlaylistToQueue,
isPlaying = isPlaying,
isFollowing = savedPlaylistIds.contains(playlistId),
onFollowClick = viewModel::toggleSavedPlaylist,
showFollowButton = playlistId != "saved_tracks",
onEdit = if (isOwner) { { showEditPlaylist = true } } else null,
)
},
footerContent = if (isOwner) {
val errorMessage = (state as? PlaylistScreenState.Error)?.message
val isLoading = state is PlaylistScreenState.Loading && (dataState == null || dataState.tracks.isEmpty())
val footerContent: (@Composable () -> Unit)? = if (isOwner && dataState != null) {
{
Row(
modifier = Modifier
@ -240,11 +143,28 @@ fun PlaylistScreen(playlistId: String) {
}
}
}
} else null,
tracks = dataState.tracks,
error = null,
hasMore = dataState.nextPagination != null,
isLoading = state is PlaylistScreenState.Loading && dataState.tracks.isEmpty(),
} else null
CollectionView(
title = playlist?.title ?: "Loading playlist...",
description = playlist?.description.orEmpty(),
imageURL = artworkUrl,
ownerName = ownerName,
ownerImageURL = ownerImageURL,
onOwnerClick = {},
onPlay = viewModel::playPlaylist,
onShufflePlay = {},
onAddToQueue = viewModel::addPlaylistToQueue,
isPlaying = isPlaying,
isFollowing = savedPlaylistIds.contains(playlistId),
onFollowClick = viewModel::toggleSavedPlaylist,
showFollowButton = playlistId != "saved_tracks",
onEdit = if (isOwner) { { showEditPlaylist = true } } else null,
isLoading = isLoading,
error = errorMessage,
onRetry = { viewModel.refresh() },
tracks = dataState?.tracks ?: emptyList(),
hasMore = dataState?.nextPagination != null,
isLoadingNextPage = state is PlaylistScreenState.Data.LoadingMore,
currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id,
isCurrentTrackPlaying = playerState == PlayerState.PLAYING,
@ -267,17 +187,14 @@ fun PlaylistScreen(playlistId: String) {
tracksToAddToPlaylist = tracks
showAddToPlaylistPicker = true
},
)
}
}
val dataState = state as? PlaylistScreenState.Data
val playlist = (dataState as? PlaylistScreenState.Data.Loaded)?.playlist
footerContent = footerContent,
trailingContent = {
val loadedPlaylist = (dataState as? PlaylistScreenState.Data.Loaded)?.playlist
PlaylistFormSheet(
visible = showEditPlaylist,
onDismiss = { showEditPlaylist = false },
isEditing = true,
initialData = playlist?.let {
initialData = loadedPlaylist?.let {
PlaylistFormData(
name = it.title,
description = it.description.orEmpty(),
@ -319,5 +236,6 @@ fun PlaylistScreen(playlistId: String) {
viewModel.refresh()
},
)
}
},
)
}

View File

@ -22,10 +22,12 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
@ -51,55 +53,65 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.krtirtho.spotube.core.ui.base.Card
import dev.krtirtho.spotube.core.ui.base.PrimaryButton
import dev.krtirtho.spotube.PlatformType
import dev.krtirtho.spotube.core.ui.base.Card
import dev.krtirtho.spotube.core.ui.base.OutlineButton
import dev.krtirtho.spotube.core.ui.base.PrimaryButton
import dev.krtirtho.spotube.core.ui.base.SecondaryIconButton
import dev.krtirtho.spotube.core.ui.base.TextField
import dev.krtirtho.spotube.core.ui.component.AdaptiveDialogBottomSheet
import dev.krtirtho.spotube.core.ui.component.AdaptiveDropdownBottomSheet
import dev.krtirtho.spotube.core.ui.component.AdaptiveMenuItem
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
import dev.krtirtho.spotube.core.ui.component.HeaderDisplayMode
import dev.krtirtho.spotube.core.webview.WebViewController
import dev.krtirtho.spotube.getPlatform
import dev.krtirtho.spotube.modules.plugin.components.InstallSection
import dev.krtirtho.spotube.modules.plugin.components.PluginCard
import dev.krtirtho.spotube.modules.plugin.components.PluginPermissionDialog
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxAdd
import dev.krtirtho.spotube.resources.iconsax.IconsaxArrowDown4
import dev.krtirtho.spotube.resources.iconsax.IconsaxBox
import dev.krtirtho.spotube.resources.iconsax.IconsaxCheckSquare
import dev.krtirtho.spotube.resources.iconsax.IconsaxDocumentText
import dev.krtirtho.spotube.resources.iconsax.IconsaxEdit
import dev.krtirtho.spotube.resources.iconsax.IconsaxExportArrowBulk
import dev.krtirtho.spotube.resources.iconsax.IconsaxImportArrow2Bulk
import dev.krtirtho.spotube.resources.iconsax.IconsaxLink
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusic
import dev.krtirtho.spotube.resources.iconsax.IconsaxSound
import dev.krtirtho.spotube.resources.iconsax.IconsaxTextalignLeft
import io.github.vinceglb.filekit.dialogs.FileKitType
import io.github.vinceglb.filekit.dialogs.compose.rememberFilePickerLauncher
import io.github.vinceglb.filekit.readBytes
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.koinInject
import kotlinx.coroutines.flow.StateFlow
import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.plugin_action_download
import spotube.composeapp.generated.resources.plugin_action_install_from_file
import spotube.composeapp.generated.resources.plugin_configure_title
import spotube.composeapp.generated.resources.plugin_empty_subtitle
import spotube.composeapp.generated.resources.plugin_empty_title
import spotube.composeapp.generated.resources.plugin_error_download_failed
import spotube.composeapp.generated.resources.plugin_error_enter_url
import spotube.composeapp.generated.resources.plugin_error_url_scheme
import spotube.composeapp.generated.resources.plugin_install_section_title
import spotube.composeapp.generated.resources.plugin_installed_count
import spotube.composeapp.generated.resources.plugin_installed_plural
import spotube.composeapp.generated.resources.plugin_installed_singular
import spotube.composeapp.generated.resources.plugin_screen_title
import spotube.composeapp.generated.resources.settings_plugins_action_change
import spotube.composeapp.generated.resources.settings_plugins_action_select
import spotube.composeapp.generated.resources.plugin_section_file_title
import spotube.composeapp.generated.resources.plugin_section_url_title
import spotube.composeapp.generated.resources.plugin_url_placeholder
import spotube.composeapp.generated.resources.settings_plugins_ability_audio
import spotube.composeapp.generated.resources.settings_plugins_ability_lyrics
import spotube.composeapp.generated.resources.settings_plugins_ability_metadata
import spotube.composeapp.generated.resources.settings_plugins_ability_scrobble
import spotube.composeapp.generated.resources.settings_plugins_clear
import spotube.composeapp.generated.resources.settings_plugins_action_change
import spotube.composeapp.generated.resources.settings_plugins_action_select
import spotube.composeapp.generated.resources.settings_plugins_default_ability_title
import spotube.composeapp.generated.resources.settings_plugins_manage_title
import spotube.composeapp.generated.resources.settings_plugins_no_plugins
import spotube.composeapp.generated.resources.settings_plugins_no_selection
import spotube.composeapp.generated.resources.settings_plugins_plugin_content_description
@ -120,6 +132,7 @@ fun PluginScreen(
var urlInput by remember { mutableStateOf("") }
var urlError by remember { mutableStateOf<String?>(null) }
var isLoadingUrl by remember { mutableStateOf(false) }
var showInstallSheet by remember { mutableStateOf(false) }
val pleaseEnterUrl = stringResource(Res.string.plugin_error_enter_url)
val urlSchemeError = stringResource(Res.string.plugin_error_url_scheme)
@ -175,6 +188,100 @@ fun PluginScreen(
)
}
if (showInstallSheet) {
AdaptiveDialogBottomSheet(
onDismiss = { showInstallSheet = false },
title = {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
Iconsax.IconsaxImportArrow2Bulk,
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.primary
)
Text(
stringResource(Res.string.plugin_install_section_title),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold
)
}
},
) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
stringResource(Res.string.plugin_section_url_title),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.Top,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
TextField(
value = urlInput,
onValueChange = { urlInput = it; urlError = null },
modifier = Modifier.weight(1f),
placeholder = {
Text(
stringResource(Res.string.plugin_url_placeholder),
style = MaterialTheme.typography.bodySmall
)
},
leadingIcon = {
Icon(
Iconsax.IconsaxLink,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
},
isError = urlError != null,
singleLine = true,
)
SecondaryIconButton(
onClick = { submitUrl() },
enabled = !isLoadingUrl,
) {
if (isLoadingUrl) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary
)
} else {
Icon(
Iconsax.IconsaxImportArrow2Bulk,
contentDescription = stringResource(Res.string.plugin_action_download),
)
}
}
}
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f))
Text(
stringResource(Res.string.plugin_section_file_title),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
OutlineButton(
onClick = { launcher.launch() },
modifier = Modifier.fillMaxWidth(),
) {
Icon(
Iconsax.IconsaxExportArrowBulk,
contentDescription = stringResource(Res.string.plugin_action_install_from_file)
)
Spacer(Modifier.width(8.dp))
Text(stringResource(Res.string.plugin_action_install_from_file))
}
}
}
}
Scaffold(
topBar = {
ApplicationMainBar(title = { Text(stringResource(Res.string.plugin_screen_title)) })
@ -205,16 +312,28 @@ fun PluginScreen(
verticalArrangement = Arrangement.spacedBy(8.dp)
)
{
// ── Install section ───────────────────────────────────
// ── Configure header ──────────────────────────────
item {
InstallSection(
urlInput = urlInput,
onUrlChange = { urlInput = it; urlError = null },
urlError = urlError,
isLoadingUrl = isLoadingUrl,
onSubmitUrl = { submitUrl() },
onPickFile = { launcher.launch() }
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
stringResource(Res.string.plugin_configure_title),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold
)
PrimaryButton(onClick = { showInstallSheet = true }) {
Icon(
Iconsax.IconsaxAdd,
contentDescription = "Install a plugin",
)
Text(stringResource(Res.string.plugin_install_section_title))
}
}
}
// ── Default ability plugin selectors ─────────────────

View File

@ -1,151 +0,0 @@
/*
* 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 dev.krtirtho.spotube.modules.plugin.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import dev.krtirtho.spotube.core.ui.base.Card
import dev.krtirtho.spotube.core.ui.base.TextField
import dev.krtirtho.spotube.core.ui.base.OutlineButton
import dev.krtirtho.spotube.core.ui.base.PrimaryButton
import dev.krtirtho.spotube.core.ui.base.SecondaryIconButton
import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxExportArrowBulk
import dev.krtirtho.spotube.resources.iconsax.IconsaxImportArrow2Bulk
import dev.krtirtho.spotube.resources.iconsax.IconsaxLink
import org.jetbrains.compose.resources.stringResource
import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.plugin_action_download
import spotube.composeapp.generated.resources.plugin_action_install_from_file
import spotube.composeapp.generated.resources.plugin_install_section_title
import spotube.composeapp.generated.resources.plugin_url_placeholder
@Composable
internal fun InstallSection(
urlInput: String,
onUrlChange: (String) -> Unit,
urlError: String?,
isLoadingUrl: Boolean,
onSubmitUrl: () -> Unit,
onPickFile: () -> Unit,
) {
Card(
modifier = Modifier.fillMaxWidth(),
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
Iconsax.IconsaxImportArrow2Bulk,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.primary
)
Text(
stringResource(Res.string.plugin_install_section_title),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold
)
}
// URL row
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.Top,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
TextField(
value = urlInput,
onValueChange = onUrlChange,
modifier = Modifier.weight(1f),
placeholder = {
Text(
stringResource(Res.string.plugin_url_placeholder),
style = MaterialTheme.typography.bodySmall
)
},
leadingIcon = {
Icon(
Iconsax.IconsaxLink,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
},
isError = urlError != null,
// supportingText = urlError?.let { { Text(it) } },
// shape = RoundedCornerShape(10.dp),
singleLine = true,
)
SecondaryIconButton(
onClick = onSubmitUrl,
enabled = !isLoadingUrl,
) {
if (isLoadingUrl) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary
)
} else {
Icon(
Iconsax.IconsaxImportArrow2Bulk,
contentDescription = stringResource(Res.string.plugin_action_download),
)
}
}
}
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f))
// File picker
OutlineButton(
onClick = onPickFile,
modifier = Modifier.fillMaxWidth(),
) {
Icon(
Iconsax.IconsaxExportArrowBulk,
contentDescription = stringResource(Res.string.plugin_action_install_from_file)
)
Spacer(Modifier.width(8.dp))
Text(stringResource(Res.string.plugin_action_install_from_file))
}
}
}
}

View File

@ -17,18 +17,13 @@
package dev.krtirtho.spotube.modules.saved_tracks
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import dev.krtirtho.spotube.core.audioplayer.AudioPlayer
@ -39,19 +34,13 @@ import dev.krtirtho.spotube.core.audioplayer.QueueEntry
import dev.krtirtho.spotube.core.navigation.NavigationCommands
import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.core.share.ShareService
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
import dev.krtirtho.spotube.core.ui.component.CollapsedCollectionHeader
import dev.krtirtho.spotube.core.ui.component.CollectionDetails
import dev.krtirtho.spotube.core.ui.component.ErrorDisplay
import dev.krtirtho.spotube.core.ui.component.TrackList
import dev.krtirtho.spotube.core.ui.component.CollectionView
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
import dev.krtirtho.spotube.core.ui.component.TrackOptionsState
import dev.krtirtho.spotube.core.ui.misc.SkeletonTree
import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel
import dev.krtirtho.spotube.modules.library.LibraryRepository
import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.DrawableResource
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
@ -80,14 +69,6 @@ fun SavedTracksScreen() {
var tracksToAddToPlaylist by remember { mutableStateOf<List<MetadataTrack>>(emptyList()) }
var currentUserId by remember { mutableStateOf<String?>(null) }
val listState = rememberLazyListState()
val isCollapsedHeaderShown by remember {
derivedStateOf {
listState.firstVisibleItemIndex > 0 ||
listState.firstVisibleItemScrollOffset > 400
}
}
LaunchedEffect(Unit) {
currentUserId = libraryRepository.currentUser()?.id
}
@ -122,81 +103,16 @@ fun SavedTracksScreen() {
}
}
Scaffold(
topBar = { ApplicationMainBar(backButton = !isCollapsedHeaderShown) }
) { innerPadding ->
when (state) {
is SavedTracksScreenState.Loading -> {
TrackList(
modifier = Modifier.padding(innerPadding),
headerContent = {
SkeletonTree(true) {
CollectionDetails(
title = "Loading saved tracks...",
description = "",
imageURL = "",
imageResource = Res.drawable.liked_tracks,
ownerName = "You",
ownerImageURL = null,
onOwnerClick = {},
onPlay = {},
onShufflePlay = {},
onAddToQueue = {},
isPlaying = false,
isFollowing = false,
onFollowClick = {},
showFollowButton = false,
)
}
},
tracks = emptyList(),
error = null,
hasMore = false,
isLoading = true,
isLoadingNextPage = false,
currentTrackId = null,
isCurrentTrackPlaying = false,
onTrackClick = {},
onLoadNextPage = {},
onArtistClick = { navigationCommands.navigateTo(Routes.Artist(it.id)) },
onAlbumClick = { navigationCommands.navigateTo(Routes.Album(it.id)) },
onTrackOptionsAction = { _, _ -> },
trackOptionsState = { TrackOptionsState() },
)
}
is SavedTracksScreenState.Error -> {
ErrorDisplay(
errorMessage = (state as SavedTracksScreenState.Error).message,
onRetry = { viewModel.refresh() },
modifier = Modifier.padding(innerPadding),
)
}
is SavedTracksScreenState.Data -> {
val dataState = state as SavedTracksScreenState.Data
val dataState = state as? SavedTracksScreenState.Data
val isPlaying = currentCollectionEntry is QueueCollectionEntry.SavedTracks &&
playerState == PlayerState.PLAYING
TrackList(
modifier = Modifier.padding(innerPadding),
state = listState,
collapsedHeader = {
CollapsedCollectionHeader(
val errorMessage = (state as? SavedTracksScreenState.Error)?.message
val isLoading = state is SavedTracksScreenState.Loading && (dataState == null || dataState.tracks.isEmpty())
CollectionView(
title = "Saved Tracks",
imageURL = "",
imageResource = Res.drawable.liked_tracks,
isPlaying = isPlaying,
onPlay = viewModel::playSavedTracks,
onBack = { navigationCommands.pop() },
)
},
isCollapsedHeaderShown = isCollapsedHeaderShown,
headerContent = {
CollectionDetails(
title = "Saved Tracks",
description = "${dataState.totalCount} tracks",
description = dataState?.let { "${it.totalCount} tracks" } ?: "",
imageURL = "",
imageResource = Res.drawable.liked_tracks,
ownerName = "You",
@ -209,12 +125,11 @@ fun SavedTracksScreen() {
isFollowing = false,
onFollowClick = {},
showFollowButton = false,
)
},
tracks = dataState.tracks,
error = null,
hasMore = dataState.nextPagination != null,
isLoading = state is SavedTracksScreenState.Loading && dataState.tracks.isEmpty(),
isLoading = isLoading,
error = errorMessage,
onRetry = { viewModel.refresh() },
tracks = dataState?.tracks ?: emptyList(),
hasMore = dataState?.nextPagination != null,
isLoadingNextPage = state is SavedTracksScreenState.Data.LoadingMore,
currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id,
isCurrentTrackPlaying = playerState == PlayerState.PLAYING,
@ -237,10 +152,7 @@ fun SavedTracksScreen() {
tracksToAddToPlaylist = tracks
showAddToPlaylistPicker = true
},
)
}
}
trailingContent = {
AddToPlaylistPicker(
visible = showAddToPlaylistPicker,
currentUserId = currentUserId,
@ -254,5 +166,6 @@ fun SavedTracksScreen() {
}
},
)
}
},
)
}

View File

@ -18,18 +18,23 @@
package dev.krtirtho.spotube.modules.shell
import androidx.compose.foundation.background
import androidx.compose.foundation.border
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.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
@ -45,9 +50,14 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.blur.HazeColorEffect
import dev.chrisbanes.haze.blur.blurEffect
import dev.chrisbanes.haze.hazeEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.compose.AsyncImage
import dev.krtirtho.spotube.core.audioplayer.AudioPlayer
@ -100,6 +110,7 @@ import kotlin.time.Duration.Companion.milliseconds
@Composable
fun AppLargePlayer(
modifier: Modifier = Modifier,
hazeState: HazeState,
onQueue: () -> Unit = {},
onAlternativeSource: () -> Unit = {},
onMoreOptions: () -> Unit = {},
@ -171,11 +182,30 @@ fun AppLargePlayer(
}
}
val surfaceTint = MaterialTheme.colorScheme.surface.copy(alpha = 0.85f)
Surface(
modifier = modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.78f),
tonalElevation = 0.dp,
shadowElevation = 0.dp
modifier = modifier
.windowInsetsPadding(WindowInsets.navigationBars),
color = Color.Transparent,
shadowElevation = 0.dp,
) {
HorizontalDivider(
modifier = Modifier.fillMaxWidth(),
color = Color.Gray,
thickness = 1.dp,
)
Box(
modifier = Modifier
.fillMaxWidth()
.hazeEffect(state = hazeState) {
blurEffect {
blurRadius = 20.dp
colorEffects = listOf(
HazeColorEffect.tint(surfaceTint)
)
}
}
) {
Row(
modifier = Modifier
@ -380,5 +410,6 @@ fun AppLargePlayer(
}
}
}
}
}

View File

@ -19,6 +19,7 @@ package dev.krtirtho.spotube.modules.shell
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
@ -27,6 +28,7 @@ import androidx.compose.foundation.layout.BoxWithConstraintsScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBars
@ -36,7 +38,11 @@ import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.material3.BottomSheetScaffold
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SheetValue
import androidx.compose.material3.Text
import androidx.compose.material3.VerticalDivider
import androidx.compose.material3.rememberBottomSheetScaffoldState
import androidx.compose.material3.rememberStandardBottomSheetState
import androidx.compose.runtime.Composable
@ -52,8 +58,11 @@ import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.rememberHazeState
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import dev.krtirtho.spotube.core.navigation.NavigationCommands
@ -113,14 +122,32 @@ fun AppShell(
CompositionLocalProvider(LocalAppShellBottomInset provides bottomOverlayInset) {
if (useSidebar) {
Column(modifier = Modifier.fillMaxSize()) {
Box(modifier = Modifier.fillMaxSize().weight(1f)) {
val hazeState = rememberHazeState()
val bgHazeState = rememberHazeState()
Box(modifier = Modifier.fillMaxSize()) {
Box(
modifier = Modifier
.fillMaxSize()
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface)
.hazeSource(state = bgHazeState) // <-- Put it here!
)
Row(modifier = Modifier.fillMaxSize()) {
AppSidebar(
navigator = navigator, navigationState = navigationState
hazeState = bgHazeState,
navigator = navigator,
navigationState = navigationState
)
Box(modifier = Modifier.weight(1f)) {
VerticalDivider(
modifier = Modifier.fillMaxHeight(),
color = Color.Gray.copy(alpha = 0.2f),
thickness = 1.dp,
)
Box(modifier = Modifier.weight(1f).hazeSource(hazeState)) {
content()
}
}
@ -168,7 +195,10 @@ fun AppShell(
}
}
AppLargePlayer(
modifier = Modifier.fillMaxWidth(),
hazeState = hazeState,
modifier = Modifier
.fillMaxWidth()
.align(Alignment.BottomCenter),
onQueue = queueViewModel::toggleQueueVisibility,
onAlternativeSource = alternativeViewModel::toggleAlternativeVisibility,
onLyrics = { navigatorCommands.navigateTo(Routes.Lyrics) },

View File

@ -21,7 +21,6 @@ import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -35,7 +34,9 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@ -47,10 +48,13 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.blur.HazeColorEffect
import dev.chrisbanes.haze.blur.blurEffect
import dev.chrisbanes.haze.hazeEffect
import dev.krtirtho.spotube.core.navigation.NavigationState
import dev.krtirtho.spotube.core.navigation.Navigator
import dev.krtirtho.spotube.core.navigation.Routes
@ -59,6 +63,7 @@ import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme
import dev.krtirtho.spotube.core.ui.base.OutlineButton
import dev.krtirtho.spotube.core.ui.base.SecondaryButton
import dev.krtirtho.spotube.core.ui.base.copyPadding
import dev.krtirtho.spotube.core.ui.base.copyShape
import dev.krtirtho.spotube.modules.downloads.DownloadBadgeIndicator
import dev.krtirtho.spotube.modules.library.LibraryState
import dev.krtirtho.spotube.modules.library.LibraryTab
@ -73,6 +78,7 @@ import spotube.composeapp.generated.resources.cookie_regular
@Composable
fun AppSidebar(
hazeState: HazeState,
navigator: Navigator,
navigationState: NavigationState,
modifier: Modifier = Modifier,
@ -81,12 +87,20 @@ fun AppSidebar(
var expanded by rememberSaveable { mutableStateOf(true) }
val width by animateDpAsState(targetValue = if (expanded) 236.dp else 86.dp)
val currentLibraryTab by libraryState.currentTab.collectAsState()
val surfaceTint = MaterialTheme.colorScheme.surface.copy(alpha = 0.85f)
Column(
modifier = modifier
.fillMaxHeight()
.width(width)
.background(MaterialTheme.colorScheme.surfaceContainer),
.hazeEffect(hazeState) {
blurEffect {
blurRadius = 20.dp
colorEffects = listOf(
HazeColorEffect.tint(surfaceTint)
)
}
},
horizontalAlignment = Alignment.CenterHorizontally
) {
Row(
@ -174,12 +188,12 @@ fun SidebarItem(
showDownloadBadge: Boolean = false,
) {
val itemContent: @Composable RowScope.() -> Unit = {
Box(modifier = Modifier.size(24.dp)) {
Box(modifier = Modifier.size(20.dp)) {
Icon(
imageVector = activeIcon,
contentDescription = label,
tint = if (selected) {
MaterialTheme.colorScheme.onSecondaryContainer
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
@ -195,11 +209,15 @@ fun SidebarItem(
text = label,
maxLines = 1,
softWrap = false,
style = LocalTextStyle.current.copy(
fontSize = MaterialTheme.typography.labelLarge.fontSize,
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium,
color = if (selected) {
MaterialTheme.colorScheme.onSecondaryContainer
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
}
)
)
}
Spacer(modifier = if (expanded) Modifier.weight(1f) else Modifier)
@ -208,20 +226,24 @@ fun SidebarItem(
val buttonModifier = Modifier
.fillMaxWidth()
.padding(horizontal = 10.dp, vertical = 4.dp)
val contentPadding = PaddingValues(horizontal = 14.dp, vertical = 12.dp)
val contentPadding = PaddingValues(horizontal = 7.dp, vertical = 6.dp)
if (selected) {
SecondaryButton(
onClick = onClick,
modifier = buttonModifier,
theme = LocalBaseUITheme.current.buttons.secondary.copyPadding(contentPadding),
theme = LocalBaseUITheme.current.buttons.secondary
.copyPadding(contentPadding)
.copyShape(RoundedCornerShape(8.dp)),
content = itemContent,
)
} else {
OutlineButton(
onClick = onClick,
modifier = buttonModifier,
theme = LocalBaseUITheme.current.buttons.outline.copyPadding(contentPadding),
theme = LocalBaseUITheme.current.buttons.outline
.copyPadding(contentPadding)
.copyShape(RoundedCornerShape(8.dp)),
hoverOnly = true,
content = itemContent,
)

View File

@ -0,0 +1,51 @@
package dev.krtirtho.spotube.resources.iconsax
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.unit.dp
val Iconsax.FluentDismiss: ImageVector
get() {
if (_FluentDismiss != null) {
return _FluentDismiss!!
}
_FluentDismiss = ImageVector.Builder(
name = "FluentDismiss",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 24f,
viewportHeight = 24f
).apply {
path(fill = SolidColor(Color(0xFF212121))) {
moveToRelative(4.397f, 4.554f)
lineToRelative(0.073f, -0.084f)
arcToRelative(0.75f, 0.75f, 0f, isMoreThanHalf = false, isPositiveArc = true, 0.976f, -0.073f)
lineToRelative(0.084f, 0.073f)
lineTo(12f, 10.939f)
lineToRelative(6.47f, -6.47f)
arcToRelative(0.75f, 0.75f, 0f, isMoreThanHalf = true, isPositiveArc = true, 1.06f, 1.061f)
lineTo(13.061f, 12f)
lineToRelative(6.47f, 6.47f)
arcToRelative(0.75f, 0.75f, 0f, isMoreThanHalf = false, isPositiveArc = true, 0.072f, 0.976f)
lineToRelative(-0.073f, 0.084f)
arcToRelative(0.75f, 0.75f, 0f, isMoreThanHalf = false, isPositiveArc = true, -0.976f, 0.073f)
lineToRelative(-0.084f, -0.073f)
lineTo(12f, 13.061f)
lineToRelative(-6.47f, 6.47f)
arcToRelative(0.75f, 0.75f, 0f, isMoreThanHalf = false, isPositiveArc = true, -1.06f, -1.061f)
lineTo(10.939f, 12f)
lineToRelative(-6.47f, -6.47f)
arcToRelative(0.75f, 0.75f, 0f, isMoreThanHalf = false, isPositiveArc = true, -0.072f, -0.976f)
lineToRelative(0.073f, -0.084f)
lineToRelative(-0.073f, 0.084f)
close()
}
}.build()
return _FluentDismiss!!
}
@Suppress("ObjectPropertyName")
private var _FluentDismiss: ImageVector? = null

View File

@ -0,0 +1,49 @@
package dev.krtirtho.spotube.resources.iconsax
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.unit.dp
val Iconsax.FluentMaximize: ImageVector
get() {
if (_FluentMaximize != null) {
return _FluentMaximize!!
}
_FluentMaximize = ImageVector.Builder(
name = "FluentMaximize",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 24f,
viewportHeight = 24f
).apply {
path(fill = SolidColor(Color(0xFF212121))) {
moveTo(5.75f, 3f)
horizontalLineToRelative(12.5f)
arcTo(2.75f, 2.75f, 0f, isMoreThanHalf = false, isPositiveArc = true, 21f, 5.75f)
verticalLineToRelative(12.5f)
arcTo(2.75f, 2.75f, 0f, isMoreThanHalf = false, isPositiveArc = true, 18.25f, 21f)
lineTo(5.75f, 21f)
arcTo(2.75f, 2.75f, 0f, isMoreThanHalf = false, isPositiveArc = true, 3f, 18.25f)
lineTo(3f, 5.75f)
arcTo(2.75f, 2.75f, 0f, isMoreThanHalf = false, isPositiveArc = true, 5.75f, 3f)
close()
moveTo(5.75f, 4.5f)
curveToRelative(-0.69f, 0f, -1.25f, 0.56f, -1.25f, 1.25f)
verticalLineToRelative(12.5f)
curveToRelative(0f, 0.69f, 0.56f, 1.25f, 1.25f, 1.25f)
horizontalLineToRelative(12.5f)
curveToRelative(0.69f, 0f, 1.25f, -0.56f, 1.25f, -1.25f)
lineTo(19.5f, 5.75f)
curveToRelative(0f, -0.69f, -0.56f, -1.25f, -1.25f, -1.25f)
lineTo(5.75f, 4.5f)
close()
}
}.build()
return _FluentMaximize!!
}
@Suppress("ObjectPropertyName")
private var _FluentMaximize: ImageVector? = null

View File

@ -0,0 +1,35 @@
package dev.krtirtho.spotube.resources.iconsax
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.unit.dp
val Iconsax.FluentMinus: ImageVector
get() {
if (_FluentMinus != null) {
return _FluentMinus!!
}
_FluentMinus = ImageVector.Builder(
name = "FluentMinus",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 24f,
viewportHeight = 24f
).apply {
path(fill = SolidColor(Color(0xFF212121))) {
moveTo(3.755f, 12.5f)
horizontalLineToRelative(16.492f)
arcToRelative(0.75f, 0.75f, 0f, isMoreThanHalf = false, isPositiveArc = false, 0f, -1.5f)
horizontalLineTo(3.755f)
arcToRelative(0.75f, 0.75f, 0f, isMoreThanHalf = false, isPositiveArc = false, 0f, 1.5f)
close()
}
}.build()
return _FluentMinus!!
}
@Suppress("ObjectPropertyName")
private var _FluentMinus: ImageVector? = null

View File

@ -0,0 +1,63 @@
package dev.krtirtho.spotube.resources.iconsax
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.unit.dp
val Iconsax.FluentSquareMultiple: ImageVector
get() {
if (_FluentSquareMultiple != null) {
return _FluentSquareMultiple!!
}
_FluentSquareMultiple = ImageVector.Builder(
name = "FluentSquareMultiple",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 24f,
viewportHeight = 24f
).apply {
path(fill = SolidColor(Color(0xFF212121))) {
moveTo(7.518f, 5f)
horizontalLineTo(6.009f)
arcToRelative(3.25f, 3.25f, 0f, isMoreThanHalf = false, isPositiveArc = true, 3.24f, -3f)
horizontalLineToRelative(8.001f)
arcTo(4.75f, 4.75f, 0f, isMoreThanHalf = false, isPositiveArc = true, 22f, 6.75f)
verticalLineToRelative(8f)
arcToRelative(3.25f, 3.25f, 0f, isMoreThanHalf = false, isPositiveArc = true, -3f, 3.24f)
verticalLineToRelative(-1.508f)
arcToRelative(1.75f, 1.75f, 0f, isMoreThanHalf = false, isPositiveArc = false, 1.5f, -1.732f)
verticalLineToRelative(-8f)
arcToRelative(3.25f, 3.25f, 0f, isMoreThanHalf = false, isPositiveArc = false, -3.25f, -3.25f)
horizontalLineToRelative(-8f)
arcTo(1.75f, 1.75f, 0f, isMoreThanHalf = false, isPositiveArc = false, 7.518f, 5f)
close()
moveTo(5.25f, 6f)
arcTo(3.25f, 3.25f, 0f, isMoreThanHalf = false, isPositiveArc = false, 2f, 9.25f)
verticalLineToRelative(9.5f)
arcTo(3.25f, 3.25f, 0f, isMoreThanHalf = false, isPositiveArc = false, 5.25f, 22f)
horizontalLineToRelative(9.5f)
arcTo(3.25f, 3.25f, 0f, isMoreThanHalf = false, isPositiveArc = false, 18f, 18.75f)
verticalLineToRelative(-9.5f)
arcTo(3.25f, 3.25f, 0f, isMoreThanHalf = false, isPositiveArc = false, 14.75f, 6f)
horizontalLineToRelative(-9.5f)
close()
moveTo(3.5f, 9.25f)
curveToRelative(0f, -0.966f, 0.784f, -1.75f, 1.75f, -1.75f)
horizontalLineToRelative(9.5f)
curveToRelative(0.967f, 0f, 1.75f, 0.784f, 1.75f, 1.75f)
verticalLineToRelative(9.5f)
arcToRelative(1.75f, 1.75f, 0f, isMoreThanHalf = false, isPositiveArc = true, -1.75f, 1.75f)
horizontalLineToRelative(-9.5f)
arcToRelative(1.75f, 1.75f, 0f, isMoreThanHalf = false, isPositiveArc = true, -1.75f, -1.75f)
verticalLineToRelative(-9.5f)
close()
}
}.build()
return _FluentSquareMultiple!!
}
@Suppress("ObjectPropertyName")
private var _FluentSquareMultiple: ImageVector? = null

View File

@ -0,0 +1,61 @@
package dev.krtirtho.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.PathData
import androidx.compose.ui.graphics.vector.group
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.unit.dp
val Iconsax.IconsaxAdd: ImageVector
get() {
if (_IncosaxAdd != null) {
return _IncosaxAdd!!
}
_IncosaxAdd = ImageVector.Builder(
name = "IncosaxAdd",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 24f,
viewportHeight = 24f
).apply {
group(
clipPathData = PathData {
moveTo(0f, 0f)
horizontalLineToRelative(24f)
verticalLineToRelative(24f)
horizontalLineToRelative(-24f)
close()
}
) {
path(
fillAlpha = 0.4f,
stroke = SolidColor(Color.White),
strokeAlpha = 0.4f,
strokeLineWidth = 1.5f,
strokeLineCap = StrokeCap.Round,
strokeLineJoin = StrokeJoin.Round
) {
moveTo(6f, 12f)
horizontalLineTo(18f)
}
path(
stroke = SolidColor(Color.White),
strokeLineWidth = 1.5f,
strokeLineCap = StrokeCap.Round,
strokeLineJoin = StrokeJoin.Round
) {
moveTo(12f, 18f)
verticalLineTo(6f)
}
}
}.build()
return _IncosaxAdd!!
}
@Suppress("ObjectPropertyName")
private var _IncosaxAdd: ImageVector? = null

View File

@ -306,8 +306,6 @@ actual class AudioPlayer actual constructor(context: Any) : KoinComponent {
lock.withLock {
if (disposed || currentPlaylist.isEmpty() || shuffleEnabled == enabled) return
val wasPlaying = _playerState.value == PlayerState.PLAYING
val wasPaused = _playerState.value == PlayerState.PAUSED
val currentItem = _currentMediaItem.value
val rebuilt = if (enabled) {
@ -331,20 +329,12 @@ actual class AudioPlayer actual constructor(context: Any) : KoinComponent {
val nextIndex = when {
currentItem == null -> if (currentPlaylist.isEmpty()) -1 else 0
enabled -> 0
else -> currentPlaylist.indexOfFirst { it.url == currentItem.url }
.takeIf { it >= 0 } ?: 0
}
currentIndex = nextIndex
_currentMediaItem.tryEmit(currentPlaylist.getOrNull(nextIndex))
if (nextIndex >= 0 && (wasPlaying || wasPaused)) {
mediaListPlayer.controls().play(nextIndex)
if (wasPaused) {
mediaListPlayer.controls().setPause(true)
}
}
}
}

View File

@ -22,8 +22,6 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.window.WindowDraggableArea
@ -36,13 +34,12 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.ApplicationScope
import androidx.compose.ui.window.WindowPlacement
import compose.icons.FeatherIcons
import compose.icons.feathericons.Maximize2
import compose.icons.feathericons.Minimize2
import compose.icons.feathericons.Minus
import compose.icons.feathericons.X
import dev.krtirtho.spotube.resources.iconsax.FluentDismiss
import dev.krtirtho.spotube.resources.iconsax.FluentMaximize
import dev.krtirtho.spotube.resources.iconsax.FluentMinus
import dev.krtirtho.spotube.resources.iconsax.FluentSquareMultiple
import dev.krtirtho.spotube.resources.iconsax.Iconsax
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@ -79,7 +76,7 @@ actual fun ApplicationMainBar(
@Composable
private fun WindowButtons(modifier: Modifier = Modifier) {
val window = LocalWindowScope.current.window
val windowState = LocalWindowState.current
val applicationScope = LocalApplicationScope.current
Row(
@ -88,27 +85,27 @@ private fun WindowButtons(modifier: Modifier = Modifier) {
) {
IconButton(
onClick = {
window.isMinimized = true
windowState.isMinimized = true
}) {
Icon(
FeatherIcons.Minus, "Minimize", modifier = Modifier.size(14.dp)
Iconsax.FluentMinus, "Minimize", modifier = Modifier.size(14.dp)
)
}
IconButton(
onClick = {
window.placement = if (window.placement == WindowPlacement.Maximized) {
windowState.placement = if (windowState.placement == WindowPlacement.Maximized) {
WindowPlacement.Floating
} else {
WindowPlacement.Maximized
}
}) {
if (window.placement == WindowPlacement.Floating) {
if (windowState.placement == WindowPlacement.Floating) {
Icon(
FeatherIcons.Maximize2, "Maximize", modifier = Modifier.size(14.dp)
Iconsax.FluentMaximize, "Maximize", modifier = Modifier.size(14.dp)
)
} else {
Icon(
FeatherIcons.Minimize2, "Restore", modifier = Modifier.size(14.dp)
Iconsax.FluentSquareMultiple, "Restore", modifier = Modifier.size(14.dp)
)
}
}
@ -117,7 +114,7 @@ private fun WindowButtons(modifier: Modifier = Modifier) {
applicationScope.exitApplication()
}) {
Icon(
FeatherIcons.X, "Close", modifier = Modifier.size(14.dp)
Iconsax.FluentDismiss, "Close", modifier = Modifier.size(14.dp)
)
}
}

View File

@ -19,8 +19,12 @@ package dev.krtirtho.spotube.core.ui.component
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.window.FrameWindowScope
import androidx.compose.ui.window.WindowState
// LocalWindow is a LocalComposition that provides access to the current Window instance in the composition hierarchy.
val LocalWindowScope = compositionLocalOf<FrameWindowScope> {
error("No Window found in LocalWindowScope")
}
val LocalWindowState = compositionLocalOf<WindowState> {
error("No WindowState found in LocalWindowState")
}

View File

@ -29,6 +29,7 @@ import dev.krtirtho.spotube.core.newpipe.NewPipeDownloader
import dev.krtirtho.spotube.core.paths.Paths
import dev.krtirtho.spotube.core.ui.component.LocalApplicationScope
import dev.krtirtho.spotube.core.ui.component.LocalWindowScope
import dev.krtirtho.spotube.core.ui.component.LocalWindowState
import io.github.vinceglb.filekit.FileKit
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@ -66,6 +67,7 @@ fun main() {
CompositionLocalProvider(
LocalApplicationScope provides this@application,
LocalWindowScope provides this@Window,
LocalWindowState provides windowState
) {
App()
}