mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-08-06 04:09:51 +00:00
Compare commits
8 Commits
a09a7c8976
...
189a0c658b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
189a0c658b | ||
|
|
9013b374c7 | ||
|
|
c10c9bb3fc | ||
|
|
b22ab3d43e | ||
|
|
c3852a1c32 | ||
|
|
7d1a6750c3 | ||
|
|
edc7cdd3fa | ||
|
|
12dea49c2c |
@ -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")`.
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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() {
|
||||
|
||||
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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(
|
||||
|
||||
@ -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,143 +102,77 @@ 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() },
|
||||
)
|
||||
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
|
||||
val firstArtistId = album?.artists?.firstOrNull()?.id
|
||||
val artworkUrl = album?.thumbnails?.firstOrNull()?.url.orEmpty()
|
||||
val isAlbumPlaying = currentCollectionEntry?.id == albumId &&
|
||||
playerState == PlayerState.PLAYING
|
||||
|
||||
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,
|
||||
ownerName = ownerName,
|
||||
ownerImageURL = ownerImageURL,
|
||||
onOwnerClick = {
|
||||
firstArtistId?.let {
|
||||
navigationCommands.navigateTo(Routes.Artist(it))
|
||||
}
|
||||
|
||||
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 ownerName =
|
||||
album?.artists?.joinToString { it.name }.orEmpty().ifBlank { "Unknown artist" }
|
||||
val ownerImageURL = album?.artists?.firstOrNull()?.thumbnails?.firstOrNull()?.url
|
||||
val firstArtistId = album?.artists?.firstOrNull()?.id
|
||||
val artworkUrl = album?.thumbnails?.firstOrNull()?.url.orEmpty()
|
||||
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() },
|
||||
},
|
||||
onPlay = viewModel::playAlbum,
|
||||
onShufflePlay = {},
|
||||
onAddToQueue = viewModel::addAlbumToQueue,
|
||||
isPlaying = isAlbumPlaying,
|
||||
isFollowing = savedAlbumIds.contains(albumId),
|
||||
onFollowClick = viewModel::toggleSavedAlbum,
|
||||
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,
|
||||
onTrackClick = viewModel::playAlbumFromTrack,
|
||||
onLoadNextPage = viewModel::loadNextTracksPage,
|
||||
onArtistClick = { navigationCommands.navigateTo(Routes.Artist(it.id)) },
|
||||
onAlbumClick = { navigationCommands.navigateTo(Routes.Album(it.id)) },
|
||||
onTrackOptionsAction = ::handleTrackOptionsAction,
|
||||
trackOptionsState = ::getTrackOptionsState,
|
||||
onBulkDownload = { tracks ->
|
||||
downloadsViewModel.downloadTracks(tracks)
|
||||
},
|
||||
onBulkAddToQueue = { tracks ->
|
||||
viewModel.addTracksToQueue(tracks)
|
||||
},
|
||||
onBulkPlayNext = { tracks ->
|
||||
viewModel.playTracksNext(tracks)
|
||||
},
|
||||
onBulkAddToPlaylist = { tracks ->
|
||||
tracksToAddToPlaylist = tracks
|
||||
showAddToPlaylistPicker = true
|
||||
},
|
||||
trailingContent = {
|
||||
AddToPlaylistPicker(
|
||||
visible = showAddToPlaylistPicker,
|
||||
currentUserId = currentUserId,
|
||||
onDismiss = { showAddToPlaylistPicker = false },
|
||||
onPlaylistSelected = { playlistId ->
|
||||
scope.launch {
|
||||
libraryRepository.addTracksToPlaylist(
|
||||
playlistId,
|
||||
tracksToAddToPlaylist.map { it.id }
|
||||
)
|
||||
},
|
||||
isCollapsedHeaderShown = isCollapsedHeaderShown,
|
||||
headerContent = {
|
||||
CollectionDetails(
|
||||
title = album?.title ?: "Loading album...",
|
||||
description = album?.description ?: "${album?.albumType?.name.orEmpty()} • ${album?.releaseDate.orEmpty()}",
|
||||
imageURL = artworkUrl,
|
||||
ownerName = ownerName,
|
||||
ownerImageURL = ownerImageURL,
|
||||
onOwnerClick = {
|
||||
firstArtistId?.let {
|
||||
navigationCommands.navigateTo(
|
||||
Routes.Artist(it)
|
||||
)
|
||||
}
|
||||
},
|
||||
onPlay = viewModel::playAlbum,
|
||||
onShufflePlay = {},
|
||||
onAddToQueue = viewModel::addAlbumToQueue,
|
||||
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(),
|
||||
isLoadingNextPage = state is AlbumScreenState.Data.LoadingMore,
|
||||
currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id,
|
||||
isCurrentTrackPlaying = playerState == PlayerState.PLAYING,
|
||||
onTrackClick = viewModel::playAlbumFromTrack,
|
||||
onLoadNextPage = viewModel::loadNextTracksPage,
|
||||
onArtistClick = { navigationCommands.navigateTo(Routes.Artist(it.id)) },
|
||||
onAlbumClick = { navigationCommands.navigateTo(Routes.Album(it.id)) },
|
||||
onTrackOptionsAction = ::handleTrackOptionsAction,
|
||||
trackOptionsState = ::getTrackOptionsState,
|
||||
onBulkDownload = { tracks ->
|
||||
downloadsViewModel.downloadTracks(tracks)
|
||||
},
|
||||
onBulkAddToQueue = { tracks ->
|
||||
viewModel.addTracksToQueue(tracks)
|
||||
},
|
||||
onBulkPlayNext = { tracks ->
|
||||
viewModel.playTracksNext(tracks)
|
||||
},
|
||||
onBulkAddToPlaylist = { tracks ->
|
||||
tracksToAddToPlaylist = tracks
|
||||
showAddToPlaylistPicker = true
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
AddToPlaylistPicker(
|
||||
visible = showAddToPlaylistPicker,
|
||||
currentUserId = currentUserId,
|
||||
onDismiss = { showAddToPlaylistPicker = false },
|
||||
onPlaylistSelected = { playlistId ->
|
||||
scope.launch {
|
||||
libraryRepository.addTracksToPlaylist(
|
||||
playlistId,
|
||||
tracksToAddToPlaylist.map { it.id }
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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,209 +98,144 @@ 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() },
|
||||
)
|
||||
}
|
||||
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 artworkUrl = playlist?.thumbnails?.firstOrNull()?.url.orEmpty()
|
||||
val isPlaying = currentCollectionEntry?.id == playlistId &&
|
||||
playerState == PlayerState.PLAYING
|
||||
|
||||
is PlaylistScreenState.Error -> {
|
||||
ErrorDisplay(
|
||||
errorMessage = (state as PlaylistScreenState.Error).message,
|
||||
onRetry = { viewModel.refresh() },
|
||||
modifier = Modifier.padding(innerPadding),
|
||||
)
|
||||
}
|
||||
val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle()
|
||||
|
||||
is PlaylistScreenState.Data -> {
|
||||
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
|
||||
|
||||
fun getTrackOptionsState(track: MetadataTrack): TrackOptionsState {
|
||||
val currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id
|
||||
val queueTrackIds = queue.mapNotNull { entry ->
|
||||
(entry as? QueueEntry.StreamingTrack)?.track?.id
|
||||
}.toSet()
|
||||
return TrackOptionsState(
|
||||
isInQueue = queueTrackIds.contains(track.id),
|
||||
isCurrentlyPlaying = track.id == currentTrackId,
|
||||
isFavorite = savedTrackIds.contains(track.id),
|
||||
isBlacklisted = false,
|
||||
)
|
||||
}
|
||||
|
||||
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) {
|
||||
{
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 16.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
OutlineButton(onClick = { showAddTracksDialog = true }) {
|
||||
Icon(
|
||||
imageVector = Iconsax.IconsaxAddSquare,
|
||||
contentDescription = null,
|
||||
)
|
||||
Text("Add Tracks", modifier = Modifier.padding(start = 8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else null,
|
||||
tracks = dataState.tracks,
|
||||
error = null,
|
||||
hasMore = dataState.nextPagination != null,
|
||||
isLoading = state is PlaylistScreenState.Loading && dataState.tracks.isEmpty(),
|
||||
isLoadingNextPage = state is PlaylistScreenState.Data.LoadingMore,
|
||||
currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id,
|
||||
isCurrentTrackPlaying = playerState == PlayerState.PLAYING,
|
||||
onTrackClick = viewModel::playPlaylistFromTrack,
|
||||
onLoadNextPage = viewModel::loadNextTracksPage,
|
||||
onArtistClick = { navigationCommands.navigateTo(Routes.Artist(it.id)) },
|
||||
onAlbumClick = { navigationCommands.navigateTo(Routes.Album(it.id)) },
|
||||
onTrackOptionsAction = ::handleTrackOptionsAction,
|
||||
trackOptionsState = ::getTrackOptionsState,
|
||||
onBulkDownload = { tracks ->
|
||||
downloadsViewModel.downloadTracks(tracks)
|
||||
},
|
||||
onBulkAddToQueue = { tracks ->
|
||||
viewModel.addTracksToQueue(tracks)
|
||||
},
|
||||
onBulkPlayNext = { tracks ->
|
||||
viewModel.playTracksNext(tracks)
|
||||
},
|
||||
onBulkAddToPlaylist = { tracks ->
|
||||
tracksToAddToPlaylist = tracks
|
||||
showAddToPlaylistPicker = true
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val dataState = state as? PlaylistScreenState.Data
|
||||
val playlist = (dataState as? PlaylistScreenState.Data.Loaded)?.playlist
|
||||
PlaylistFormSheet(
|
||||
visible = showEditPlaylist,
|
||||
onDismiss = { showEditPlaylist = false },
|
||||
isEditing = true,
|
||||
initialData = playlist?.let {
|
||||
PlaylistFormData(
|
||||
name = it.title,
|
||||
description = it.description.orEmpty(),
|
||||
isPublic = true,
|
||||
isCollaborating = false,
|
||||
imagePreviewUrl = it.thumbnails.firstOrNull()?.url,
|
||||
)
|
||||
},
|
||||
onSubmit = { data ->
|
||||
viewModel.updatePlaylist(
|
||||
name = data.name,
|
||||
description = data.description.ifBlank { null },
|
||||
isPublic = data.isPublic,
|
||||
isCollaborating = data.isCollaborating,
|
||||
imageBase64 = data.imageBase64.ifBlank { null },
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
AddToPlaylistPicker(
|
||||
visible = showAddToPlaylistPicker,
|
||||
currentUserId = currentUserId,
|
||||
onDismiss = { showAddToPlaylistPicker = false },
|
||||
onPlaylistSelected = { selectedPlaylistId ->
|
||||
scope.launch {
|
||||
libraryRepository.addTracksToPlaylist(
|
||||
selectedPlaylistId,
|
||||
tracksToAddToPlaylist.map { it.id }
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
AddTracksToPlaylistDialog(
|
||||
visible = showAddTracksDialog,
|
||||
playlistId = playlistId,
|
||||
onDismiss = {
|
||||
showAddTracksDialog = false
|
||||
viewModel.refresh()
|
||||
},
|
||||
fun getTrackOptionsState(track: MetadataTrack): TrackOptionsState {
|
||||
val currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id
|
||||
val queueTrackIds = queue.mapNotNull { entry ->
|
||||
(entry as? QueueEntry.StreamingTrack)?.track?.id
|
||||
}.toSet()
|
||||
return TrackOptionsState(
|
||||
isInQueue = queueTrackIds.contains(track.id),
|
||||
isCurrentlyPlaying = track.id == currentTrackId,
|
||||
isFavorite = savedTrackIds.contains(track.id),
|
||||
isBlacklisted = false,
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 16.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
OutlineButton(onClick = { showAddTracksDialog = true }) {
|
||||
Icon(
|
||||
imageVector = Iconsax.IconsaxAddSquare,
|
||||
contentDescription = null,
|
||||
)
|
||||
Text("Add Tracks", modifier = Modifier.padding(start = 8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
} 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,
|
||||
onTrackClick = viewModel::playPlaylistFromTrack,
|
||||
onLoadNextPage = viewModel::loadNextTracksPage,
|
||||
onArtistClick = { navigationCommands.navigateTo(Routes.Artist(it.id)) },
|
||||
onAlbumClick = { navigationCommands.navigateTo(Routes.Album(it.id)) },
|
||||
onTrackOptionsAction = ::handleTrackOptionsAction,
|
||||
trackOptionsState = ::getTrackOptionsState,
|
||||
onBulkDownload = { tracks ->
|
||||
downloadsViewModel.downloadTracks(tracks)
|
||||
},
|
||||
onBulkAddToQueue = { tracks ->
|
||||
viewModel.addTracksToQueue(tracks)
|
||||
},
|
||||
onBulkPlayNext = { tracks ->
|
||||
viewModel.playTracksNext(tracks)
|
||||
},
|
||||
onBulkAddToPlaylist = { tracks ->
|
||||
tracksToAddToPlaylist = tracks
|
||||
showAddToPlaylistPicker = true
|
||||
},
|
||||
footerContent = footerContent,
|
||||
trailingContent = {
|
||||
val loadedPlaylist = (dataState as? PlaylistScreenState.Data.Loaded)?.playlist
|
||||
PlaylistFormSheet(
|
||||
visible = showEditPlaylist,
|
||||
onDismiss = { showEditPlaylist = false },
|
||||
isEditing = true,
|
||||
initialData = loadedPlaylist?.let {
|
||||
PlaylistFormData(
|
||||
name = it.title,
|
||||
description = it.description.orEmpty(),
|
||||
isPublic = true,
|
||||
isCollaborating = false,
|
||||
imagePreviewUrl = it.thumbnails.firstOrNull()?.url,
|
||||
)
|
||||
},
|
||||
onSubmit = { data ->
|
||||
viewModel.updatePlaylist(
|
||||
name = data.name,
|
||||
description = data.description.ifBlank { null },
|
||||
isPublic = data.isPublic,
|
||||
isCollaborating = data.isCollaborating,
|
||||
imageBase64 = data.imageBase64.ifBlank { null },
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
AddToPlaylistPicker(
|
||||
visible = showAddToPlaylistPicker,
|
||||
currentUserId = currentUserId,
|
||||
onDismiss = { showAddToPlaylistPicker = false },
|
||||
onPlaylistSelected = { selectedPlaylistId ->
|
||||
scope.launch {
|
||||
libraryRepository.addTracksToPlaylist(
|
||||
selectedPlaylistId,
|
||||
tracksToAddToPlaylist.map { it.id }
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
AddTracksToPlaylistDialog(
|
||||
visible = showAddTracksDialog,
|
||||
playlistId = playlistId,
|
||||
onDismiss = {
|
||||
showAddTracksDialog = false
|
||||
viewModel.refresh()
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@ -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 ─────────────────
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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,137 +103,69 @@ 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() },
|
||||
)
|
||||
}
|
||||
val dataState = state as? SavedTracksScreenState.Data
|
||||
val isPlaying = currentCollectionEntry is QueueCollectionEntry.SavedTracks &&
|
||||
playerState == PlayerState.PLAYING
|
||||
|
||||
is SavedTracksScreenState.Error -> {
|
||||
ErrorDisplay(
|
||||
errorMessage = (state as SavedTracksScreenState.Error).message,
|
||||
onRetry = { viewModel.refresh() },
|
||||
modifier = Modifier.padding(innerPadding),
|
||||
)
|
||||
}
|
||||
val errorMessage = (state as? SavedTracksScreenState.Error)?.message
|
||||
val isLoading = state is SavedTracksScreenState.Loading && (dataState == null || dataState.tracks.isEmpty())
|
||||
|
||||
is 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(
|
||||
title = "Saved Tracks",
|
||||
imageURL = "",
|
||||
imageResource = Res.drawable.liked_tracks,
|
||||
isPlaying = isPlaying,
|
||||
onPlay = viewModel::playSavedTracks,
|
||||
onBack = { navigationCommands.pop() },
|
||||
CollectionView(
|
||||
title = "Saved Tracks",
|
||||
description = dataState?.let { "${it.totalCount} tracks" } ?: "",
|
||||
imageURL = "",
|
||||
imageResource = Res.drawable.liked_tracks,
|
||||
ownerName = "You",
|
||||
ownerImageURL = null,
|
||||
onOwnerClick = {},
|
||||
onPlay = viewModel::playSavedTracks,
|
||||
onShufflePlay = {},
|
||||
onAddToQueue = viewModel::addSavedTracksToQueue,
|
||||
isPlaying = isPlaying,
|
||||
isFollowing = false,
|
||||
onFollowClick = {},
|
||||
showFollowButton = false,
|
||||
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,
|
||||
onTrackClick = viewModel::playSavedTracksFromTrack,
|
||||
onLoadNextPage = viewModel::loadNextTracksPage,
|
||||
onArtistClick = { navigationCommands.navigateTo(Routes.Artist(it.id)) },
|
||||
onAlbumClick = { navigationCommands.navigateTo(Routes.Album(it.id)) },
|
||||
onTrackOptionsAction = ::handleTrackOptionsAction,
|
||||
trackOptionsState = ::getTrackOptionsState,
|
||||
onBulkDownload = { tracks ->
|
||||
downloadsViewModel.downloadTracks(tracks)
|
||||
},
|
||||
onBulkAddToQueue = { tracks ->
|
||||
viewModel.addTracksToQueue(tracks)
|
||||
},
|
||||
onBulkPlayNext = { tracks ->
|
||||
viewModel.playTracksNext(tracks)
|
||||
},
|
||||
onBulkAddToPlaylist = { tracks ->
|
||||
tracksToAddToPlaylist = tracks
|
||||
showAddToPlaylistPicker = true
|
||||
},
|
||||
trailingContent = {
|
||||
AddToPlaylistPicker(
|
||||
visible = showAddToPlaylistPicker,
|
||||
currentUserId = currentUserId,
|
||||
onDismiss = { showAddToPlaylistPicker = false },
|
||||
onPlaylistSelected = { playlistId ->
|
||||
scope.launch {
|
||||
libraryRepository.addTracksToPlaylist(
|
||||
playlistId,
|
||||
tracksToAddToPlaylist.map { it.id }
|
||||
)
|
||||
},
|
||||
isCollapsedHeaderShown = isCollapsedHeaderShown,
|
||||
headerContent = {
|
||||
CollectionDetails(
|
||||
title = "Saved Tracks",
|
||||
description = "${dataState.totalCount} tracks",
|
||||
imageURL = "",
|
||||
imageResource = Res.drawable.liked_tracks,
|
||||
ownerName = "You",
|
||||
ownerImageURL = null,
|
||||
onOwnerClick = {},
|
||||
onPlay = viewModel::playSavedTracks,
|
||||
onShufflePlay = {},
|
||||
onAddToQueue = viewModel::addSavedTracksToQueue,
|
||||
isPlaying = isPlaying,
|
||||
isFollowing = false,
|
||||
onFollowClick = {},
|
||||
showFollowButton = false,
|
||||
)
|
||||
},
|
||||
tracks = dataState.tracks,
|
||||
error = null,
|
||||
hasMore = dataState.nextPagination != null,
|
||||
isLoading = state is SavedTracksScreenState.Loading && dataState.tracks.isEmpty(),
|
||||
isLoadingNextPage = state is SavedTracksScreenState.Data.LoadingMore,
|
||||
currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id,
|
||||
isCurrentTrackPlaying = playerState == PlayerState.PLAYING,
|
||||
onTrackClick = viewModel::playSavedTracksFromTrack,
|
||||
onLoadNextPage = viewModel::loadNextTracksPage,
|
||||
onArtistClick = { navigationCommands.navigateTo(Routes.Artist(it.id)) },
|
||||
onAlbumClick = { navigationCommands.navigateTo(Routes.Album(it.id)) },
|
||||
onTrackOptionsAction = ::handleTrackOptionsAction,
|
||||
trackOptionsState = ::getTrackOptionsState,
|
||||
onBulkDownload = { tracks ->
|
||||
downloadsViewModel.downloadTracks(tracks)
|
||||
},
|
||||
onBulkAddToQueue = { tracks ->
|
||||
viewModel.addTracksToQueue(tracks)
|
||||
},
|
||||
onBulkPlayNext = { tracks ->
|
||||
viewModel.playTracksNext(tracks)
|
||||
},
|
||||
onBulkAddToPlaylist = { tracks ->
|
||||
tracksToAddToPlaylist = tracks
|
||||
showAddToPlaylistPicker = true
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
AddToPlaylistPicker(
|
||||
visible = showAddToPlaylistPicker,
|
||||
currentUserId = currentUserId,
|
||||
onDismiss = { showAddToPlaylistPicker = false },
|
||||
onPlaylistSelected = { playlistId ->
|
||||
scope.launch {
|
||||
libraryRepository.addTracksToPlaylist(
|
||||
playlistId,
|
||||
tracksToAddToPlaylist.map { it.id }
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@ -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,211 +182,231 @@ 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,
|
||||
) {
|
||||
Row(
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = Color.Gray,
|
||||
thickness = 1.dp,
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
.hazeEffect(state = hazeState) {
|
||||
blurEffect {
|
||||
blurRadius = 20.dp
|
||||
colorEffects = listOf(
|
||||
HazeColorEffect.tint(surfaceTint)
|
||||
)
|
||||
}
|
||||
}
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.weight(1f),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(52.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerHighest)
|
||||
) {
|
||||
AsyncImage(
|
||||
model = coverModel,
|
||||
contentDescription = playerUiState.title,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
}
|
||||
Column(modifier = Modifier.padding(start = 12.dp)) {
|
||||
Text(
|
||||
text = playerUiState.title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
text = playerUiState.artists,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
PlayerHeartButton(
|
||||
audioPlayerQueue = audioPlayerQueue,
|
||||
savedTracksViewModel = savedTracksViewModel,
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.weight(1.1f),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Slider(
|
||||
value = seekProgress,
|
||||
onValueChange = {
|
||||
seekProgress = it
|
||||
isSeeking = true
|
||||
},
|
||||
onValueChangeFinished = {
|
||||
isSeeking = false
|
||||
onSeekFinished()
|
||||
},
|
||||
enabled = playerUiState.seekDuration.inWholeMilliseconds > 0L,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = formatPlayerTime(playerUiState.position),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = formatPlayerTime(playerUiState.displayDuration),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
VariableIconButton(
|
||||
onClick = ::onShuffleToggle,
|
||||
variant = if (playerUiState.isShuffling) VariableIconButtonVariant.Outline else VariableIconButtonVariant.Ghost
|
||||
) {
|
||||
Icon(
|
||||
Iconsax.IconsaxShuffle,
|
||||
contentDescription = if (playerUiState.isShuffling) "Disable shuffle" else "Enable shuffle",
|
||||
tint = if (playerUiState.isShuffling) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
)
|
||||
}
|
||||
GhostIconButton(onClick = ::onSkipPrevious) {
|
||||
Icon(Iconsax.IconsaxPrevious, contentDescription = "Previous")
|
||||
}
|
||||
IconButton(
|
||||
onClick = ::onPlayPause,
|
||||
theme = invertedButtonStyle().copyShape(CircleShape),
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(50.dp),
|
||||
.size(52.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerHighest)
|
||||
) {
|
||||
Icon(
|
||||
if (playerUiState.isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay,
|
||||
contentDescription = if (playerUiState.isPlaying) "Pause" else "Play or pause",
|
||||
AsyncImage(
|
||||
model = coverModel,
|
||||
contentDescription = playerUiState.title,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
}
|
||||
GhostIconButton(onClick = ::onSkipNext) {
|
||||
Icon(Iconsax.IconsaxNext, contentDescription = "Next")
|
||||
}
|
||||
VariableIconButton(
|
||||
onClick = ::onLoopToggle,
|
||||
variant = if (playerUiState.loopState == LoopState.NONE) VariableIconButtonVariant.Ghost else VariableIconButtonVariant.Outline
|
||||
) {
|
||||
Icon(
|
||||
imageVector = when (playerUiState.loopState) {
|
||||
LoopState.NONE -> Iconsax.IconsaxRepeateMusic
|
||||
LoopState.ONE -> Iconsax.IconsaxRepeateOne
|
||||
LoopState.ALL -> Iconsax.IconsaxRepeatMusic
|
||||
},
|
||||
contentDescription = "Loop mode ${playerUiState.loopState.name}",
|
||||
tint = if (playerUiState.loopState == LoopState.NONE) {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
} else {
|
||||
MaterialTheme.colorScheme.primary
|
||||
}
|
||||
Column(modifier = Modifier.padding(start = 12.dp)) {
|
||||
Text(
|
||||
text = playerUiState.title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
text = playerUiState.artists,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
PlayerHeartButton(
|
||||
audioPlayerQueue = audioPlayerQueue,
|
||||
savedTracksViewModel = savedTracksViewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
horizontalAlignment = Alignment.End
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
GhostIconButton(onClick = onQueue) {
|
||||
Icon(Iconsax.IconsaxMusicFilter, contentDescription = "Queue")
|
||||
}
|
||||
GhostIconButton(onClick = {
|
||||
val track = (currentEntry as? QueueEntry.StreamingTrack)?.track
|
||||
if (track != null) {
|
||||
downloadsViewModel.downloadTrack(track)
|
||||
}
|
||||
}) {
|
||||
DownloadProgressIcon(
|
||||
track = (currentEntry as? QueueEntry.StreamingTrack)?.track,
|
||||
icon = Iconsax.IconsaxDirectboxReceive,
|
||||
contentDescription = "Download",
|
||||
Column(
|
||||
modifier = Modifier.weight(1.1f),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Slider(
|
||||
value = seekProgress,
|
||||
onValueChange = {
|
||||
seekProgress = it
|
||||
isSeeking = true
|
||||
},
|
||||
onValueChangeFinished = {
|
||||
isSeeking = false
|
||||
onSeekFinished()
|
||||
},
|
||||
enabled = playerUiState.seekDuration.inWholeMilliseconds > 0L,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = formatPlayerTime(playerUiState.position),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = formatPlayerTime(playerUiState.displayDuration),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
GhostIconButton(onClick = onAlternativeSource) {
|
||||
Icon(Iconsax.SwapHorizontal2, contentDescription = "Alternative source")
|
||||
}
|
||||
GhostIconButton(onClick = onLyrics) {
|
||||
Icon(Iconsax.IconsaxMusic, contentDescription = "Lyrics")
|
||||
}
|
||||
GhostIconButton(onClick = onMoreOptions) {
|
||||
Icon(Iconsax.Iconsax3DotsMore, contentDescription = "More options")
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
VariableIconButton(
|
||||
onClick = ::onShuffleToggle,
|
||||
variant = if (playerUiState.isShuffling) VariableIconButtonVariant.Outline else VariableIconButtonVariant.Ghost
|
||||
) {
|
||||
Icon(
|
||||
Iconsax.IconsaxShuffle,
|
||||
contentDescription = if (playerUiState.isShuffling) "Disable shuffle" else "Enable shuffle",
|
||||
tint = if (playerUiState.isShuffling) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
)
|
||||
}
|
||||
GhostIconButton(onClick = ::onSkipPrevious) {
|
||||
Icon(Iconsax.IconsaxPrevious, contentDescription = "Previous")
|
||||
}
|
||||
IconButton(
|
||||
onClick = ::onPlayPause,
|
||||
theme = invertedButtonStyle().copyShape(CircleShape),
|
||||
modifier = Modifier
|
||||
.size(50.dp),
|
||||
) {
|
||||
Icon(
|
||||
if (playerUiState.isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay,
|
||||
contentDescription = if (playerUiState.isPlaying) "Pause" else "Play or pause",
|
||||
)
|
||||
}
|
||||
GhostIconButton(onClick = ::onSkipNext) {
|
||||
Icon(Iconsax.IconsaxNext, contentDescription = "Next")
|
||||
}
|
||||
VariableIconButton(
|
||||
onClick = ::onLoopToggle,
|
||||
variant = if (playerUiState.loopState == LoopState.NONE) VariableIconButtonVariant.Ghost else VariableIconButtonVariant.Outline
|
||||
) {
|
||||
Icon(
|
||||
imageVector = when (playerUiState.loopState) {
|
||||
LoopState.NONE -> Iconsax.IconsaxRepeateMusic
|
||||
LoopState.ONE -> Iconsax.IconsaxRepeateOne
|
||||
LoopState.ALL -> Iconsax.IconsaxRepeatMusic
|
||||
},
|
||||
contentDescription = "Loop mode ${playerUiState.loopState.name}",
|
||||
tint = if (playerUiState.loopState == LoopState.NONE) {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
} else {
|
||||
MaterialTheme.colorScheme.primary
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
GhostIconButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
if (playerUiState.volume <= 0f) {
|
||||
audioPlayer.setVolume(lastNonZeroVolume)
|
||||
} else {
|
||||
lastNonZeroVolume = playerUiState.volume
|
||||
audioPlayer.setVolume(0f)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
horizontalAlignment = Alignment.End
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
GhostIconButton(onClick = onQueue) {
|
||||
Icon(Iconsax.IconsaxMusicFilter, contentDescription = "Queue")
|
||||
}
|
||||
GhostIconButton(onClick = {
|
||||
val track = (currentEntry as? QueueEntry.StreamingTrack)?.track
|
||||
if (track != null) {
|
||||
downloadsViewModel.downloadTrack(track)
|
||||
}
|
||||
}) {
|
||||
DownloadProgressIcon(
|
||||
track = (currentEntry as? QueueEntry.StreamingTrack)?.track,
|
||||
icon = Iconsax.IconsaxDirectboxReceive,
|
||||
contentDescription = "Download",
|
||||
)
|
||||
}
|
||||
GhostIconButton(onClick = onAlternativeSource) {
|
||||
Icon(Iconsax.SwapHorizontal2, contentDescription = "Alternative source")
|
||||
}
|
||||
GhostIconButton(onClick = onLyrics) {
|
||||
Icon(Iconsax.IconsaxMusic, contentDescription = "Lyrics")
|
||||
}
|
||||
GhostIconButton(onClick = onMoreOptions) {
|
||||
Icon(Iconsax.Iconsax3DotsMore, contentDescription = "More options")
|
||||
}
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
GhostIconButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
if (playerUiState.volume <= 0f) {
|
||||
audioPlayer.setVolume(lastNonZeroVolume)
|
||||
} else {
|
||||
lastNonZeroVolume = playerUiState.volume
|
||||
audioPlayer.setVolume(0f)
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
val volumeIcon = when {
|
||||
playerUiState.volume <= 0f -> Iconsax.IconsaxVolumeCross
|
||||
playerUiState.volume < 0.5f -> Iconsax.IconsaxVolumeLow
|
||||
else -> Iconsax.IconsaxVolumeHigh
|
||||
}
|
||||
Icon(
|
||||
imageVector = volumeIcon,
|
||||
contentDescription = if (playerUiState.volume <= 0f) "Unmute" else "Mute"
|
||||
)
|
||||
}
|
||||
) {
|
||||
val volumeIcon = when {
|
||||
playerUiState.volume <= 0f -> Iconsax.IconsaxVolumeCross
|
||||
playerUiState.volume < 0.5f -> Iconsax.IconsaxVolumeLow
|
||||
else -> Iconsax.IconsaxVolumeHigh
|
||||
}
|
||||
Icon(
|
||||
imageVector = volumeIcon,
|
||||
contentDescription = if (playerUiState.volume <= 0f) "Unmute" else "Mute"
|
||||
Slider(
|
||||
value = playerUiState.volume,
|
||||
onValueChange = {
|
||||
lastNonZeroVolume = it
|
||||
scope.launch {
|
||||
audioPlayer.setVolume(it)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(0.62f)
|
||||
)
|
||||
}
|
||||
Slider(
|
||||
value = playerUiState.volume,
|
||||
onValueChange = {
|
||||
lastNonZeroVolume = it
|
||||
scope.launch {
|
||||
audioPlayer.setVolume(it)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(0.62f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) },
|
||||
|
||||
@ -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,
|
||||
color = if (selected) {
|
||||
MaterialTheme.colorScheme.onSecondaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
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,
|
||||
)
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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")
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user