feat: implement playlist creation and editing functionality with UI components

This commit is contained in:
Kingkor Roy Tirtho 2026-07-10 17:24:32 +06:00
parent a38b9e48cc
commit 8692c344d6
11 changed files with 660 additions and 32 deletions

View File

@ -53,6 +53,7 @@ import dev.krtirtho.spotube.core.ui.misc.TextWithShimmer
import dev.krtirtho.spotube.core.ui.misc.shimmerApply import dev.krtirtho.spotube.core.ui.misc.shimmerApply
import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare 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.IconsaxHeart
import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart2 import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart2
import dev.krtirtho.spotube.resources.iconsax.IconsaxPauseCircle import dev.krtirtho.spotube.resources.iconsax.IconsaxPauseCircle
@ -80,6 +81,7 @@ fun CollectionDetails(
isFollowing: Boolean = false, isFollowing: Boolean = false,
onFollowClick: () -> Unit = { }, onFollowClick: () -> Unit = { },
showFollowButton: Boolean = true, 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 { val ownerInfo = @Composable {

View File

@ -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.PaginationResult
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy 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.playlist.MetadataPlaylist
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUser
import dev.krtirtho.spotube.modules.plugin.PluginManager import dev.krtirtho.spotube.modules.plugin.PluginManager
import io.github.reactivecircus.cache4k.Cache import io.github.reactivecircus.cache4k.Cache
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -276,4 +277,69 @@ class LibraryRepository(
savedArtistIds.value -= ids.toSet() 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
}
}
}
}
} }

View File

@ -22,20 +22,25 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height 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.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.core.ui.misc.SkeletonTree
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare
import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
@ -62,6 +68,7 @@ fun LibraryPlaylistsScreen(
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val gridState = rememberLazyGridState() val gridState = rememberLazyGridState()
var showCreatePlaylist by remember { mutableStateOf(false) }
LaunchedEffect(gridState) { LaunchedEffect(gridState) {
@ -84,22 +91,38 @@ fun LibraryPlaylistsScreen(
} }
Column(modifier = modifier) { Column(modifier = modifier) {
AnimatedVisibility( Row(
visible = searchMode, modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) { ) {
TextField( AnimatedVisibility(
value = (state as? LibraryPlaylistsState.Data)?.query ?: "", visible = searchMode,
onValueChange = viewModel::onQueryChange, modifier = Modifier.weight(1f),
modifier = Modifier.fillMaxWidth(), ) {
placeholder = { Text("Search saved playlists...") }, TextField(
singleLine = true, value = (state as? LibraryPlaylistsState.Data)?.query ?: "",
leadingIcon = { onValueChange = viewModel::onQueryChange,
Icon( modifier = Modifier.fillMaxWidth(),
imageVector = Iconsax.IconsaxFilterSearch, placeholder = { Text("Search saved playlists...") },
contentDescription = "Search" 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)) Spacer(modifier = Modifier.height(12.dp))
when (state) { 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(),
)
},
)
} }
} }

View File

@ -152,4 +152,31 @@ class LibraryPlaylistsViewModel(
loadInitialData() 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()
}
}
}
} }

View File

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

View File

@ -21,6 +21,9 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue 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.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack 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.core.ui.misc.SkeletonTree
import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel
import dev.krtirtho.spotube.modules.library.LibraryRepository 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.koinInject
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf import org.koin.core.parameter.parametersOf
@ -61,6 +66,8 @@ fun PlaylistScreen(playlistId: String) {
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle() val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle() val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle()
val savedPlaylistIds by viewModel.savedPlaylistIds.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) { fun handleTrackOptionsAction(track: MetadataTrack, action: TrackOptionsAction) {
viewModel.handleTrackOptionsAction(track, action) viewModel.handleTrackOptionsAction(track, action)
@ -131,6 +138,8 @@ fun PlaylistScreen(playlistId: String) {
val ownerName = owner?.displayName ?: owner?.username ?: "Unknown" val ownerName = owner?.displayName ?: owner?.username ?: "Unknown"
val ownerImageURL = owner?.thumbnails?.firstOrNull()?.url val ownerImageURL = owner?.thumbnails?.firstOrNull()?.url
val isOwner = currentUserId != null && playlist?.owner?.id == currentUserId
val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle() val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle()
@ -166,6 +175,7 @@ fun PlaylistScreen(playlistId: String) {
isFollowing = savedPlaylistIds.contains(playlistId), isFollowing = savedPlaylistIds.contains(playlistId),
onFollowClick = viewModel::toggleSavedPlaylist, onFollowClick = viewModel::toggleSavedPlaylist,
showFollowButton = playlistId != "saved_tracks", showFollowButton = playlistId != "saved_tracks",
onEdit = if (isOwner) { { showEditPlaylist = true } } else null,
) )
}, },
tracks = dataState.tracks, 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 },
)
},
)
} }
} }

View File

@ -94,6 +94,9 @@ class PlaylistViewModel(
val savedPlaylistIds val savedPlaylistIds
get() = libraryRepository.savedPlaylistIdsFlow get() = libraryRepository.savedPlaylistIdsFlow
private val _currentUserId = MutableStateFlow<String?>(null)
val currentUserId: StateFlow<String?> = _currentUserId.asStateFlow()
init { init {
viewModelScope.launch { viewModelScope.launch {
repository.pluginManager.selectedMetadataPlugin repository.pluginManager.selectedMetadataPlugin
@ -102,10 +105,15 @@ class PlaylistViewModel(
.distinctUntilChanged() .distinctUntilChanged()
.collect { .collect {
loadInitialData() loadInitialData()
loadCurrentUser()
} }
} }
} }
private suspend fun loadCurrentUser() {
_currentUserId.value = libraryRepository.currentUser()?.id
}
private suspend fun loadInitialData() = runCatching { private suspend fun loadInitialData() = runCatching {
_state.value = PlaylistScreenState.Loading _state.value = PlaylistScreenState.Loading
val playlistInfo = repository.getPlaylistInfo(playlistId) 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) { fun handleTrackOptionsAction(track: MetadataTrack, action: TrackOptionsAction) {
viewModelScope.launch { viewModelScope.launch {
when (action) { when (action) {

View File

@ -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 package dev.krtirtho.spotube.resources.iconsax
object Iconsax object Iconsax

View File

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

View File

@ -104,6 +104,8 @@ object FakeMetadataStore {
fun getUser(id: String): MetadataUser? = users[id]?.toModel() fun getUser(id: String): MetadataUser? = users[id]?.toModel()
fun getCurrentUser(): MetadataUser? = users[CURRENT_USER_ID]?.toModel()
fun getTrack(id: String): MetadataTrack = tracks[id]?.toModel() fun getTrack(id: String): MetadataTrack = tracks[id]?.toModel()
?: error("Track not found: $id") ?: error("Track not found: $id")

View File

@ -23,4 +23,8 @@ class RealMetadataUserAPI : MetadataUserAPI {
override suspend fun getUser(id: String): MetadataUser? { override suspend fun getUser(id: String): MetadataUser? {
return FakeMetadataStore.getUser(id) return FakeMetadataStore.getUser(id)
} }
override suspend fun me(): MetadataUser? {
return FakeMetadataStore.getCurrentUser()
}
} }