diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionDetails.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionDetails.kt index c75c7cbd..0cbb24e4 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionDetails.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionDetails.kt @@ -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 { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryRepository.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryRepository.kt index 78f0de0b..d314ecf5 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryRepository.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryRepository.kt @@ -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, + ): 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?, + ): 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 + } + } + } + } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/LibraryPlaylistsScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/LibraryPlaylistsScreen.kt index 57743baf..a6621c14 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/LibraryPlaylistsScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/LibraryPlaylistsScreen.kt @@ -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(), + ) + }, + ) } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/LibraryPlaylistsViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/LibraryPlaylistsViewModel.kt index 645479a1..97f580bc 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/LibraryPlaylistsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/LibraryPlaylistsViewModel.kt @@ -152,4 +152,31 @@ class LibraryPlaylistsViewModel( loadInitialData() } } + + fun createPlaylist( + name: String, + description: String?, + isPublic: Boolean, + isCollaborating: Boolean, + imageBase64: String, + trackIds: List, + ) { + 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() + } + } + } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/PlaylistFormSheet.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/PlaylistFormSheet.kt new file mode 100644 index 00000000..4c55ad94 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/PlaylistFormSheet.kt @@ -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 . + */ + +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") + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistScreen.kt index b960d7e2..1686e37e 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistScreen.kt @@ -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 }, + ) + }, + ) } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistViewModel.kt index f406982a..5fd0379f 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistViewModel.kt @@ -94,6 +94,9 @@ class PlaylistViewModel( val savedPlaylistIds get() = libraryRepository.savedPlaylistIdsFlow + private val _currentUserId = MutableStateFlow(null) + val currentUserId: StateFlow = _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) { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/Iconsax.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/Iconsax.kt index 338fda64..e2a68daa 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/Iconsax.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/Iconsax.kt @@ -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 . - */ - package dev.krtirtho.spotube.resources.iconsax object Iconsax diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxEdit.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxEdit.kt new file mode 100644 index 00000000..51227cce --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxEdit.kt @@ -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 diff --git a/js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/metadata/FakeMetadataStore.kt b/js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/metadata/FakeMetadataStore.kt index 84d7a2b2..e3e488d4 100644 --- a/js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/metadata/FakeMetadataStore.kt +++ b/js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/metadata/FakeMetadataStore.kt @@ -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") diff --git a/js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/metadata/RealMetadataUserAPI.kt b/js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/metadata/RealMetadataUserAPI.kt index cc86648d..9fa844cf 100644 --- a/js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/metadata/RealMetadataUserAPI.kt +++ b/js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/metadata/RealMetadataUserAPI.kt @@ -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() + } } \ No newline at end of file