mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-08-05 19:59:51 +00:00
feat: implement playlist creation and editing functionality with UI components
This commit is contained in:
parent
a38b9e48cc
commit
8692c344d6
@ -53,6 +53,7 @@ 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.IconsaxAddSquare
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxEdit
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart2
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxPauseCircle
|
||||
@ -80,6 +81,7 @@ fun CollectionDetails(
|
||||
isFollowing: Boolean = false,
|
||||
onFollowClick: () -> Unit = { },
|
||||
showFollowButton: Boolean = true,
|
||||
onEdit: (() -> Unit)? = null,
|
||||
) {
|
||||
|
||||
|
||||
@ -151,6 +153,15 @@ fun CollectionDetails(
|
||||
}
|
||||
}
|
||||
}
|
||||
if (onEdit != null) {
|
||||
ButtonGroupDivider()
|
||||
GroupIconButton(onClick = onEdit) {
|
||||
Icon(
|
||||
imageVector = Iconsax.IconsaxEdit,
|
||||
contentDescription = "Edit playlist",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val ownerInfo = @Composable {
|
||||
|
||||
@ -22,6 +22,7 @@ import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationResult
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUser
|
||||
import dev.krtirtho.spotube.modules.plugin.PluginManager
|
||||
import io.github.reactivecircus.cache4k.Cache
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@ -276,4 +277,69 @@ class LibraryRepository(
|
||||
savedArtistIds.value -= ids.toSet()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun currentUser(): MetadataUser? {
|
||||
return plugin?.let { plugin ->
|
||||
pluginManager.withScope {
|
||||
plugin.use {
|
||||
metadataUserAPI.getUser("")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun createPlaylist(
|
||||
name: String,
|
||||
description: String?,
|
||||
isPublic: Boolean,
|
||||
isCollaborating: Boolean,
|
||||
imageBase64: String,
|
||||
trackIds: List<String>,
|
||||
): MetadataPlaylist? {
|
||||
return plugin?.let { plugin ->
|
||||
pluginManager.withScope {
|
||||
plugin.use {
|
||||
val result = metadataPlaylistAPI.createPlaylist(
|
||||
name = name,
|
||||
description = description,
|
||||
isPublic = isPublic,
|
||||
isCollaborating = isCollaborating,
|
||||
imageBase64 = imageBase64,
|
||||
trackIds = trackIds,
|
||||
)
|
||||
playlistCache.invalidateAll()
|
||||
savedPlaylistIds.value += result.id
|
||||
result
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updatePlaylist(
|
||||
id: String,
|
||||
name: String?,
|
||||
description: String?,
|
||||
isPublic: Boolean?,
|
||||
isCollaborating: Boolean?,
|
||||
imageBase64: String?,
|
||||
trackIds: List<String>?,
|
||||
): MetadataPlaylist? {
|
||||
return plugin?.let { plugin ->
|
||||
pluginManager.withScope {
|
||||
plugin.use {
|
||||
val result = metadataPlaylistAPI.updatePlaylist(
|
||||
id = id,
|
||||
name = name,
|
||||
description = description,
|
||||
isPublic = isPublic,
|
||||
isCollaborating = isCollaborating,
|
||||
imageBase64 = imageBase64,
|
||||
trackIds = trackIds,
|
||||
)
|
||||
playlistCache.invalidateAll()
|
||||
result
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -22,20 +22,25 @@ import androidx.compose.foundation.layout.Arrangement
|
||||
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.height
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@ -49,6 +54,7 @@ import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard
|
||||
import dev.krtirtho.spotube.core.ui.misc.SkeletonTree
|
||||
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
|
||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
@ -62,6 +68,7 @@ fun LibraryPlaylistsScreen(
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val gridState = rememberLazyGridState()
|
||||
var showCreatePlaylist by remember { mutableStateOf(false) }
|
||||
|
||||
|
||||
LaunchedEffect(gridState) {
|
||||
@ -84,22 +91,38 @@ fun LibraryPlaylistsScreen(
|
||||
}
|
||||
|
||||
Column(modifier = modifier) {
|
||||
AnimatedVisibility(
|
||||
visible = searchMode,
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
TextField(
|
||||
value = (state as? LibraryPlaylistsState.Data)?.query ?: "",
|
||||
onValueChange = viewModel::onQueryChange,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = { Text("Search saved playlists...") },
|
||||
singleLine = true,
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Iconsax.IconsaxFilterSearch,
|
||||
contentDescription = "Search"
|
||||
)
|
||||
}
|
||||
)
|
||||
AnimatedVisibility(
|
||||
visible = searchMode,
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
TextField(
|
||||
value = (state as? LibraryPlaylistsState.Data)?.query ?: "",
|
||||
onValueChange = viewModel::onQueryChange,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = { Text("Search saved playlists...") },
|
||||
singleLine = true,
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Iconsax.IconsaxFilterSearch,
|
||||
contentDescription = "Search"
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
onClick = { showCreatePlaylist = true },
|
||||
modifier = Modifier.size(48.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Iconsax.IconsaxAddSquare,
|
||||
contentDescription = "Create Playlist",
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
when (state) {
|
||||
@ -187,5 +210,20 @@ fun LibraryPlaylistsScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PlaylistFormSheet(
|
||||
visible = showCreatePlaylist,
|
||||
onDismiss = { showCreatePlaylist = false },
|
||||
onSubmit = { data ->
|
||||
viewModel.createPlaylist(
|
||||
name = data.name,
|
||||
description = data.description.ifBlank { null },
|
||||
isPublic = data.isPublic,
|
||||
isCollaborating = data.isCollaborating,
|
||||
imageBase64 = data.imageBase64,
|
||||
trackIds = emptyList(),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -152,4 +152,31 @@ class LibraryPlaylistsViewModel(
|
||||
loadInitialData()
|
||||
}
|
||||
}
|
||||
|
||||
fun createPlaylist(
|
||||
name: String,
|
||||
description: String?,
|
||||
isPublic: Boolean,
|
||||
isCollaborating: Boolean,
|
||||
imageBase64: String,
|
||||
trackIds: List<String>,
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
repository.createPlaylist(
|
||||
name = name,
|
||||
description = description,
|
||||
isPublic = isPublic,
|
||||
isCollaborating = isCollaborating,
|
||||
imageBase64 = imageBase64,
|
||||
trackIds = trackIds,
|
||||
)
|
||||
}.onFailure { e ->
|
||||
logger.e(e) { "Failed to create playlist" }
|
||||
}.onSuccess {
|
||||
repository.invalidateCaches()
|
||||
loadInitialData()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,334 @@
|
||||
/*
|
||||
* 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.library.playlist
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
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.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
|
||||
import androidx.compose.runtime.Composable
|
||||
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.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import dev.krtirtho.spotube.core.ui.base.TextField
|
||||
import io.github.vinceglb.filekit.dialogs.FileKitType
|
||||
import io.github.vinceglb.filekit.dialogs.compose.rememberFilePickerLauncher
|
||||
import io.github.vinceglb.filekit.readBytes
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import okio.ByteString.Companion.toByteString
|
||||
|
||||
data class PlaylistFormData(
|
||||
val name: String = "",
|
||||
val description: String = "",
|
||||
val isPublic: Boolean = true,
|
||||
val isCollaborating: Boolean = false,
|
||||
val imageBase64: String = "",
|
||||
val imagePreviewUrl: String? = null,
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun PlaylistFormSheet(
|
||||
visible: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
onSubmit: (PlaylistFormData) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
initialData: PlaylistFormData? = null,
|
||||
isEditing: Boolean = false,
|
||||
breakpointDp: Float = 600f,
|
||||
) {
|
||||
if (!visible) return
|
||||
|
||||
val adaptiveInfo = currentWindowAdaptiveInfo()
|
||||
val isLargeScreen = adaptiveInfo.windowSizeClass.minWidthDp >= breakpointDp
|
||||
|
||||
var name by remember(initialData) { mutableStateOf(initialData?.name ?: "") }
|
||||
var description by remember(initialData) { mutableStateOf(initialData?.description ?: "") }
|
||||
var isPublic by remember(initialData) { mutableStateOf(initialData?.isPublic ?: true) }
|
||||
var isCollaborating by remember(initialData) { mutableStateOf(initialData?.isCollaborating ?: false) }
|
||||
var imageBase64 by remember(initialData) { mutableStateOf(initialData?.imageBase64 ?: "") }
|
||||
var imagePreviewUrl by remember(initialData) { mutableStateOf(initialData?.imagePreviewUrl) }
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
val imagePicker = rememberFilePickerLauncher(
|
||||
type = FileKitType.Image
|
||||
) { file ->
|
||||
if (file != null) {
|
||||
scope.launch {
|
||||
val bytes = file.readBytes()
|
||||
val base64 = withContext(Dispatchers.Default) {
|
||||
bytes.toByteString().base64()
|
||||
}
|
||||
imageBase64 = base64
|
||||
imagePreviewUrl = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val currentData = PlaylistFormData(
|
||||
name = name,
|
||||
description = description,
|
||||
isPublic = isPublic,
|
||||
isCollaborating = isCollaborating,
|
||||
imageBase64 = imageBase64,
|
||||
imagePreviewUrl = imagePreviewUrl,
|
||||
)
|
||||
|
||||
val handleImagePick = { imagePicker.launch() }
|
||||
|
||||
if (isLargeScreen) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
modifier = modifier,
|
||||
title = {
|
||||
Text(if (isEditing) "Edit Playlist" else "Create Playlist")
|
||||
},
|
||||
text = {
|
||||
PlaylistFormFields(
|
||||
name = name,
|
||||
onNameChange = { name = it },
|
||||
description = description,
|
||||
onDescriptionChange = { description = it },
|
||||
isPublic = isPublic,
|
||||
onIsPublicChange = { isPublic = it },
|
||||
isCollaborating = isCollaborating,
|
||||
onIsCollaboratingChange = { isCollaborating = it },
|
||||
imageBase64 = imageBase64,
|
||||
imagePreviewUrl = imagePreviewUrl,
|
||||
onPickImage = handleImagePick,
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
onSubmit(currentData)
|
||||
onDismiss()
|
||||
},
|
||||
enabled = name.isNotBlank(),
|
||||
) {
|
||||
Text(if (isEditing) "Save" else "Create")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Cancel")
|
||||
}
|
||||
},
|
||||
)
|
||||
} else {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
dragHandle = null,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = if (isEditing) "Edit Playlist" else "Create Playlist",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
modifier = Modifier.padding(bottom = 16.dp),
|
||||
)
|
||||
PlaylistFormFields(
|
||||
name = name,
|
||||
onNameChange = { name = it },
|
||||
description = description,
|
||||
onDescriptionChange = { description = it },
|
||||
isPublic = isPublic,
|
||||
onIsPublicChange = { isPublic = it },
|
||||
isCollaborating = isCollaborating,
|
||||
onIsCollaboratingChange = { isCollaborating = it },
|
||||
imageBase64 = imageBase64,
|
||||
imagePreviewUrl = imagePreviewUrl,
|
||||
onPickImage = handleImagePick,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
|
||||
) {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Cancel")
|
||||
}
|
||||
TextButton(
|
||||
onClick = {
|
||||
onSubmit(currentData)
|
||||
onDismiss()
|
||||
},
|
||||
enabled = name.isNotBlank(),
|
||||
) {
|
||||
Text(if (isEditing) "Save" else "Create")
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PlaylistFormFields(
|
||||
name: String,
|
||||
onNameChange: (String) -> Unit,
|
||||
description: String,
|
||||
onDescriptionChange: (String) -> Unit,
|
||||
isPublic: Boolean,
|
||||
onIsPublicChange: (Boolean) -> Unit,
|
||||
isCollaborating: Boolean,
|
||||
onIsCollaboratingChange: (Boolean) -> Unit,
|
||||
imageBase64: String,
|
||||
imagePreviewUrl: String?,
|
||||
onPickImage: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
TextField(
|
||||
value = name,
|
||||
onValueChange = onNameChange,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = { Text("Playlist name") },
|
||||
singleLine = true,
|
||||
label = { Text("Name") },
|
||||
)
|
||||
|
||||
TextField(
|
||||
value = description,
|
||||
onValueChange = onDescriptionChange,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = { Text("Description (optional)") },
|
||||
maxLines = 3,
|
||||
label = { Text("Description") },
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.clickable { onIsPublicChange(!isPublic) }
|
||||
.padding(horizontal = 4.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text("Public", style = MaterialTheme.typography.bodyLarge)
|
||||
Text(
|
||||
"Anyone can see this playlist",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Switch(checked = isPublic, onCheckedChange = onIsPublicChange)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.clickable { onIsCollaboratingChange(!isCollaborating) }
|
||||
.padding(horizontal = 4.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text("Collaborative", style = MaterialTheme.typography.bodyLarge)
|
||||
Text(
|
||||
"Others can add tracks to this playlist",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Switch(checked = isCollaborating, onCheckedChange = onIsCollaboratingChange)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text("Cover Image", style = MaterialTheme.typography.bodyLarge)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(64.dp)
|
||||
.clip(RoundedCornerShape(8.dp)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
when {
|
||||
imagePreviewUrl != null -> AsyncImage(
|
||||
model = imagePreviewUrl,
|
||||
contentDescription = "Playlist cover",
|
||||
modifier = Modifier.size(64.dp),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
imageBase64.isNotBlank() -> {
|
||||
Text(
|
||||
"✓",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
Text(
|
||||
"No image",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
TextButton(onClick = onPickImage) {
|
||||
Text(if (imageBase64.isNotBlank() || imagePreviewUrl != null) "Change Image" else "Pick Image")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -21,6 +21,9 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
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
|
||||
@ -40,6 +43,8 @@ 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.PlaylistFormData
|
||||
import dev.krtirtho.spotube.modules.library.playlist.PlaylistFormSheet
|
||||
import org.koin.compose.koinInject
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import org.koin.core.parameter.parametersOf
|
||||
@ -61,6 +66,8 @@ fun PlaylistScreen(playlistId: String) {
|
||||
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
|
||||
val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle()
|
||||
val savedPlaylistIds by viewModel.savedPlaylistIds.collectAsStateWithLifecycle()
|
||||
val currentUserId by viewModel.currentUserId.collectAsStateWithLifecycle()
|
||||
var showEditPlaylist by remember { mutableStateOf(false) }
|
||||
|
||||
fun handleTrackOptionsAction(track: MetadataTrack, action: TrackOptionsAction) {
|
||||
viewModel.handleTrackOptionsAction(track, action)
|
||||
@ -131,6 +138,8 @@ fun PlaylistScreen(playlistId: String) {
|
||||
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()
|
||||
|
||||
|
||||
@ -166,6 +175,7 @@ fun PlaylistScreen(playlistId: String) {
|
||||
isFollowing = savedPlaylistIds.contains(playlistId),
|
||||
onFollowClick = viewModel::toggleSavedPlaylist,
|
||||
showFollowButton = playlistId != "saved_tracks",
|
||||
onEdit = if (isOwner) { { showEditPlaylist = true } } else null,
|
||||
)
|
||||
},
|
||||
tracks = dataState.tracks,
|
||||
@ -193,5 +203,31 @@ fun PlaylistScreen(playlistId: String) {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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 },
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -94,6 +94,9 @@ class PlaylistViewModel(
|
||||
val savedPlaylistIds
|
||||
get() = libraryRepository.savedPlaylistIdsFlow
|
||||
|
||||
private val _currentUserId = MutableStateFlow<String?>(null)
|
||||
val currentUserId: StateFlow<String?> = _currentUserId.asStateFlow()
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
repository.pluginManager.selectedMetadataPlugin
|
||||
@ -102,10 +105,15 @@ class PlaylistViewModel(
|
||||
.distinctUntilChanged()
|
||||
.collect {
|
||||
loadInitialData()
|
||||
loadCurrentUser()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadCurrentUser() {
|
||||
_currentUserId.value = libraryRepository.currentUser()?.id
|
||||
}
|
||||
|
||||
private suspend fun loadInitialData() = runCatching {
|
||||
_state.value = PlaylistScreenState.Loading
|
||||
val playlistInfo = repository.getPlaylistInfo(playlistId)
|
||||
@ -179,6 +187,33 @@ class PlaylistViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
fun updatePlaylist(
|
||||
name: String?,
|
||||
description: String?,
|
||||
isPublic: Boolean?,
|
||||
isCollaborating: Boolean?,
|
||||
imageBase64: String?,
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
libraryRepository.updatePlaylist(
|
||||
id = playlistId,
|
||||
name = name,
|
||||
description = description,
|
||||
isPublic = isPublic,
|
||||
isCollaborating = isCollaborating,
|
||||
imageBase64 = imageBase64,
|
||||
trackIds = null,
|
||||
)
|
||||
}.onFailure { e ->
|
||||
logger.e(e) { "Failed to update playlist" }
|
||||
}.onSuccess {
|
||||
repository.invalidateCaches()
|
||||
loadInitialData()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun handleTrackOptionsAction(track: MetadataTrack, action: TrackOptionsAction) {
|
||||
viewModelScope.launch {
|
||||
when (action) {
|
||||
|
||||
@ -1,20 +1,3 @@
|
||||
/*
|
||||
* 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.resources.iconsax
|
||||
|
||||
object Iconsax
|
||||
|
||||
@ -0,0 +1,92 @@
|
||||
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.PathData
|
||||
import androidx.compose.ui.graphics.vector.group
|
||||
import androidx.compose.ui.graphics.vector.path
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
val Iconsax.IconsaxEdit: ImageVector
|
||||
get() {
|
||||
if (_IconsaxEdit != null) {
|
||||
return _IconsaxEdit!!
|
||||
}
|
||||
_IconsaxEdit = ImageVector.Builder(
|
||||
name = "IconsaxEdit",
|
||||
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(
|
||||
fill = SolidColor(Color.White),
|
||||
fillAlpha = 0.4f,
|
||||
strokeAlpha = 0.4f
|
||||
) {
|
||||
moveTo(15.48f, 3f)
|
||||
horizontalLineTo(7.52f)
|
||||
curveTo(4.07f, 3f, 2f, 5.06f, 2f, 8.52f)
|
||||
verticalLineTo(16.47f)
|
||||
curveTo(2f, 19.94f, 4.07f, 22f, 7.52f, 22f)
|
||||
horizontalLineTo(15.47f)
|
||||
curveTo(18.93f, 22f, 20.99f, 19.94f, 20.99f, 16.48f)
|
||||
verticalLineTo(8.52f)
|
||||
curveTo(21f, 5.06f, 18.93f, 3f, 15.48f, 3f)
|
||||
close()
|
||||
}
|
||||
path(fill = SolidColor(Color.White)) {
|
||||
moveTo(21.02f, 2.98f)
|
||||
curveTo(19.23f, 1.18f, 17.48f, 1.14f, 15.64f, 2.98f)
|
||||
lineTo(14.51f, 4.1f)
|
||||
curveTo(14.41f, 4.2f, 14.38f, 4.34f, 14.42f, 4.47f)
|
||||
curveTo(15.12f, 6.92f, 17.08f, 8.88f, 19.53f, 9.58f)
|
||||
curveTo(19.56f, 9.59f, 19.61f, 9.59f, 19.64f, 9.59f)
|
||||
curveTo(19.74f, 9.59f, 19.84f, 9.55f, 19.91f, 9.48f)
|
||||
lineTo(21.02f, 8.36f)
|
||||
curveTo(21.93f, 7.45f, 22.38f, 6.58f, 22.38f, 5.69f)
|
||||
curveTo(22.38f, 4.79f, 21.93f, 3.9f, 21.02f, 2.98f)
|
||||
close()
|
||||
}
|
||||
path(fill = SolidColor(Color.White)) {
|
||||
moveTo(17.86f, 10.42f)
|
||||
curveTo(17.59f, 10.29f, 17.33f, 10.16f, 17.09f, 10.01f)
|
||||
curveTo(16.89f, 9.89f, 16.69f, 9.76f, 16.5f, 9.62f)
|
||||
curveTo(16.34f, 9.52f, 16.16f, 9.37f, 15.98f, 9.22f)
|
||||
curveTo(15.96f, 9.21f, 15.9f, 9.16f, 15.82f, 9.08f)
|
||||
curveTo(15.51f, 8.83f, 15.18f, 8.49f, 14.87f, 8.12f)
|
||||
curveTo(14.85f, 8.1f, 14.79f, 8.04f, 14.74f, 7.95f)
|
||||
curveTo(14.64f, 7.84f, 14.49f, 7.65f, 14.36f, 7.44f)
|
||||
curveTo(14.25f, 7.3f, 14.12f, 7.1f, 14f, 6.89f)
|
||||
curveTo(13.85f, 6.64f, 13.72f, 6.39f, 13.6f, 6.13f)
|
||||
curveTo(13.47f, 5.85f, 13.37f, 5.59f, 13.28f, 5.34f)
|
||||
lineTo(7.9f, 10.72f)
|
||||
curveTo(7.55f, 11.07f, 7.21f, 11.73f, 7.14f, 12.22f)
|
||||
lineTo(6.71f, 15.2f)
|
||||
curveTo(6.62f, 15.83f, 6.79f, 16.42f, 7.18f, 16.81f)
|
||||
curveTo(7.51f, 17.14f, 7.96f, 17.31f, 8.46f, 17.31f)
|
||||
curveTo(8.57f, 17.31f, 8.68f, 17.3f, 8.79f, 17.29f)
|
||||
lineTo(11.76f, 16.87f)
|
||||
curveTo(12.25f, 16.8f, 12.91f, 16.47f, 13.26f, 16.11f)
|
||||
lineTo(18.64f, 10.73f)
|
||||
curveTo(18.39f, 10.65f, 18.14f, 10.54f, 17.86f, 10.42f)
|
||||
close()
|
||||
}
|
||||
}
|
||||
}.build()
|
||||
|
||||
return _IconsaxEdit!!
|
||||
}
|
||||
|
||||
@Suppress("ObjectPropertyName")
|
||||
private var _IconsaxEdit: ImageVector? = null
|
||||
@ -104,6 +104,8 @@ object FakeMetadataStore {
|
||||
|
||||
fun getUser(id: String): MetadataUser? = users[id]?.toModel()
|
||||
|
||||
fun getCurrentUser(): MetadataUser? = users[CURRENT_USER_ID]?.toModel()
|
||||
|
||||
fun getTrack(id: String): MetadataTrack = tracks[id]?.toModel()
|
||||
?: error("Track not found: $id")
|
||||
|
||||
|
||||
@ -23,4 +23,8 @@ class RealMetadataUserAPI : MetadataUserAPI {
|
||||
override suspend fun getUser(id: String): MetadataUser? {
|
||||
return FakeMetadataStore.getUser(id)
|
||||
}
|
||||
|
||||
override suspend fun me(): MetadataUser? {
|
||||
return FakeMetadataStore.getCurrentUser()
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user