From 49e969cc0fbf9f28e1244495cbd37ebb626cf8a4 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 10 Jul 2026 19:06:05 +0600 Subject: [PATCH] feat: add 'Add to Playlist' functionality with UI components and dialogs --- .../spotube/core/ui/component/TrackList.kt | 7 + .../spotube/core/ui/component/TrackOptions.kt | 10 + .../spotube/modules/album/AlbumScreen.kt | 38 ++ .../spotube/modules/album/AlbumViewModel.kt | 1 + .../spotube/modules/artist/ArtistScreen.kt | 38 ++ .../spotube/modules/artist/ArtistViewModel.kt | 1 + .../modules/library/LibraryRepository.kt | 11 + .../library/playlist/AddToPlaylistPicker.kt | 257 ++++++++++++++ .../library/playlist/PlaylistFormSheet.kt | 13 +- .../playlist/AddTracksToPlaylistDialog.kt | 325 ++++++++++++++++++ .../modules/playlist/PlaylistScreen.kt | 67 ++++ .../modules/playlist/PlaylistViewModel.kt | 1 + .../modules/saved_tracks/SavedTracksScreen.kt | 39 +++ .../saved_tracks/SavedTracksViewModel.kt | 1 + .../spotube/modules/search/SearchScreen.kt | 42 +++ 15 files changed, 845 insertions(+), 6 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/AddToPlaylistPicker.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/AddTracksToPlaylistDialog.kt diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackList.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackList.kt index 7228998d..a0ff05dc 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackList.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackList.kt @@ -103,6 +103,7 @@ import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare import dev.krtirtho.spotube.resources.iconsax.IconsaxDirectboxReceive import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch +import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicPlaylist import dev.krtirtho.spotube.resources.iconsax.IconsaxNext import dev.krtirtho.spotube.resources.iconsax.IconsaxPause import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay @@ -140,6 +141,7 @@ fun TrackList( onBulkDownload: (List) -> Unit = {}, onBulkAddToQueue: (List) -> Unit = {}, onBulkPlayNext: (List) -> Unit = {}, + onBulkAddToPlaylist: (List) -> Unit = {}, currentTrackId: String? = null, isCurrentTrackPlaying: Boolean = false, trackOptionsState: (MetadataTrack) -> TrackOptionsState = { TrackOptionsState() }, @@ -433,6 +435,11 @@ fun TrackList( label = if (isAll) "Play All Next" else "Play $trackCount Next", onClick = { onBulkPlayNext(targetTracks) }, ), + AdaptiveMenuItem( + icon = Iconsax.IconsaxMusicPlaylist, + label = if (isAll) "Add All to Playlist" else "Add $trackCount to Playlist", + onClick = { onBulkAddToPlaylist(targetTracks) }, + ), ), trigger = { onClick -> GroupIconButton( diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackOptions.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackOptions.kt index ee10832f..82b40e24 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackOptions.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackOptions.kt @@ -49,6 +49,7 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxDirectboxReceive import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart2 import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicCircle +import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicPlaylist import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicSquareRemove import dev.krtirtho.spotube.resources.iconsax.IconsaxNext import dev.krtirtho.spotube.resources.iconsax.IconsaxShare @@ -59,6 +60,7 @@ sealed interface TrackOptionsAction { data object AddToQueue : TrackOptionsAction data object RemoveFromQueue : TrackOptionsAction data object ToggleFavorite : TrackOptionsAction + data object AddToPlaylist : TrackOptionsAction data object Download : TrackOptionsAction data object ToggleBlacklist : TrackOptionsAction data object Share : TrackOptionsAction @@ -274,6 +276,14 @@ private fun buildTrackMenuItems( ), ) + add( + AdaptiveMenuItem( + icon = Iconsax.IconsaxMusicPlaylist, + label = "Add to playlist", + onClick = { onAction(TrackOptionsAction.AddToPlaylist) }, + ), + ) + add( AdaptiveMenuItem( icon = Iconsax.IconsaxDirectboxReceive, diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumScreen.kt index 7f9e6cf8..a16c7b4f 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumScreen.kt @@ -20,7 +20,12 @@ package dev.krtirtho.spotube.modules.album import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold 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.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 @@ -40,6 +45,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.AddToPlaylistPicker +import kotlinx.coroutines.launch import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf @@ -50,6 +57,8 @@ fun AlbumScreen(albumId: String) { val audioPlayer: AudioPlayer = koinInject() val shareService: ShareService = koinInject() val downloadsViewModel: DownloadsViewModel = koinViewModel() + val libraryRepository: LibraryRepository = koinInject() + val scope = rememberCoroutineScope() val viewModel = koinViewModel( key = albumId, parameters = { parametersOf(albumId) } @@ -62,6 +71,13 @@ fun AlbumScreen(albumId: String) { val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle() val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle() val savedAlbumIds by viewModel.savedAlbumIds.collectAsStateWithLifecycle() + var showAddToPlaylistPicker by remember { mutableStateOf(false) } + var tracksToAddToPlaylist by remember { mutableStateOf>(emptyList()) } + var currentUserId by remember { mutableStateOf(null) } + + LaunchedEffect(Unit) { + currentUserId = libraryRepository.currentUser()?.id + } fun getTrackOptionsState(track: MetadataTrack): TrackOptionsState { val currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id @@ -87,6 +103,10 @@ fun AlbumScreen(albumId: String) { if (action is TrackOptionsAction.Download) { downloadsViewModel.downloadTrack(track) } + if (action is TrackOptionsAction.AddToPlaylist) { + tracksToAddToPlaylist = listOf(track) + showAddToPlaylistPicker = true + } } Scaffold( @@ -194,8 +214,26 @@ fun AlbumScreen(albumId: String) { 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 } + ) + } + }, + ) } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumViewModel.kt index a2236f93..2e93d290 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumViewModel.kt @@ -222,6 +222,7 @@ class AlbumViewModel( is TrackOptionsAction.Download -> {} is TrackOptionsAction.ToggleBlacklist -> {} is TrackOptionsAction.Share -> {} + is TrackOptionsAction.AddToPlaylist -> {} } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt index 83d7ca59..8384d03e 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt @@ -45,6 +45,10 @@ 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.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -84,12 +88,15 @@ import dev.krtirtho.spotube.core.ui.misc.SkeletonTree import dev.krtirtho.spotube.core.ui.misc.TextWithShimmer import dev.krtirtho.spotube.core.ui.misc.shimmerApply 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.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay import dev.krtirtho.spotube.resources.iconsax.User import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf @@ -101,6 +108,8 @@ fun ArtistScreen(artistId: String) { val audioPlayer: AudioPlayer = koinInject() val shareService: ShareService = koinInject() val downloadsViewModel: DownloadsViewModel = koinViewModel() + val libraryRepository: LibraryRepository = koinInject() + val scope = rememberCoroutineScope() val viewModel = koinViewModel( key = artistId, parameters = { parametersOf(artistId) } @@ -113,6 +122,13 @@ fun ArtistScreen(artistId: String) { val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle() val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle() val savedArtistIds by viewModel.savedArtistIds.collectAsStateWithLifecycle() + var showAddToPlaylistPicker by remember { mutableStateOf(false) } + var tracksToAddToPlaylist by remember { mutableStateOf>(emptyList()) } + var currentUserId by remember { mutableStateOf(null) } + + LaunchedEffect(Unit) { + currentUserId = libraryRepository.currentUser()?.id + } fun getTrackOptionsState(track: MetadataTrack): TrackOptionsState { val currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id @@ -138,6 +154,10 @@ fun ArtistScreen(artistId: String) { if (action is TrackOptionsAction.Download) { downloadsViewModel.downloadTrack(track) } + if (action is TrackOptionsAction.AddToPlaylist) { + tracksToAddToPlaylist = listOf(track) + showAddToPlaylistPicker = true + } } Scaffold( @@ -213,6 +233,10 @@ fun ArtistScreen(artistId: String) { onBulkDownload = { tracks -> downloadsViewModel.downloadTracks(tracks) }, onBulkAddToQueue = { tracks -> viewModel.addTracksToQueue(tracks) }, onBulkPlayNext = { tracks -> viewModel.playTracksNext(tracks) }, + onBulkAddToPlaylist = { tracks -> + tracksToAddToPlaylist = tracks + showAddToPlaylistPicker = true + }, ) } @@ -266,6 +290,20 @@ fun ArtistScreen(artistId: String) { } } } + + AddToPlaylistPicker( + visible = showAddToPlaylistPicker, + currentUserId = currentUserId, + onDismiss = { showAddToPlaylistPicker = false }, + onPlaylistSelected = { playlistId -> + scope.launch { + libraryRepository.addTracksToPlaylist( + playlistId, + tracksToAddToPlaylist.map { it.id } + ) + } + }, + ) } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistViewModel.kt index 2fa42a74..d838d5c6 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistViewModel.kt @@ -269,6 +269,7 @@ class ArtistViewModel( is TrackOptionsAction.Download -> {} is TrackOptionsAction.ToggleBlacklist -> {} is TrackOptionsAction.Share -> {} + is TrackOptionsAction.AddToPlaylist -> {} } } } 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 d314ecf5..dcf8a566 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 @@ -342,4 +342,15 @@ class LibraryRepository( } } } + + suspend fun addTracksToPlaylist(playlistId: String, trackIds: List) { + plugin?.let { plugin -> + pluginManager.withScope { + plugin.use { + metadataPlaylistAPI.addTracksToPlaylist(playlistId, trackIds) + } + } + playlistCache.invalidateAll() + } + } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/AddToPlaylistPicker.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/AddToPlaylistPicker.kt new file mode 100644 index 00000000..c504d0b8 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/AddToPlaylistPicker.kt @@ -0,0 +1,257 @@ +/* + * Copyright (C) 2026 Kingmor 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.fillMaxSize +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo +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.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.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist +import dev.krtirtho.spotube.core.ui.base.OutlineButton +import dev.krtirtho.spotube.core.ui.base.TextField +import dev.krtirtho.spotube.modules.library.LibraryRepository +import dev.krtirtho.spotube.resources.iconsax.Iconsax +import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch +import kotlinx.coroutines.launch +import org.koin.compose.koinInject + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AddToPlaylistPicker( + visible: Boolean, + currentUserId: String?, + onDismiss: () -> Unit, + onPlaylistSelected: (String) -> Unit, + modifier: Modifier = Modifier, + libraryRepository: LibraryRepository = koinInject(), + breakpointDp: Float = 600f, +) { + if (!visible) return + + val adaptiveInfo = currentWindowAdaptiveInfo() + val isLargeScreen = adaptiveInfo.windowSizeClass.minWidthDp >= breakpointDp + + var playlists by remember { mutableStateOf>(emptyList()) } + var isLoading by remember { mutableStateOf(true) } + var searchQuery by remember { mutableStateOf("") } + val scope = rememberCoroutineScope() + + LaunchedEffect(visible, currentUserId) { + if (visible) { + isLoading = true + scope.launch { + val result = libraryRepository.savedPlaylists() + playlists = result?.items?.filter { + currentUserId != null && it.owner?.id == currentUserId + } ?: emptyList() + isLoading = false + } + } + } + + val filteredPlaylists = remember(playlists, searchQuery) { + if (searchQuery.isBlank()) { + playlists + } else { + playlists.filter { + it.title.contains(searchQuery, ignoreCase = true) || + it.description?.contains(searchQuery, ignoreCase = true) == true + } + } + } + + val content: @Composable () -> Unit = { + Column(modifier = Modifier.fillMaxSize()) { + TextField( + value = searchQuery, + onValueChange = { searchQuery = it }, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 12.dp), + placeholder = { Text("Search your playlists...") }, + singleLine = true, + leadingIcon = { + Icon( + imageVector = Iconsax.IconsaxFilterSearch, + contentDescription = "Search", + ) + }, + ) + + PlaylistPickerContent( + playlists = filteredPlaylists, + isLoading = isLoading, + onPlaylistSelected = { playlistId -> + onPlaylistSelected(playlistId) + onDismiss() + }, + ) + } + } + + if (isLargeScreen) { + AlertDialog( + onDismissRequest = onDismiss, + modifier = modifier, + title = { + Text("Add to Your Playlist") + }, + text = { + Box(modifier = Modifier.fillMaxWidth().height(400.dp)) { + content() + } + }, + confirmButton = { + OutlineButton(onClick = onDismiss) { + Text("Cancel") + } + }, + ) + } else { + ModalBottomSheet( + onDismissRequest = onDismiss, + dragHandle = null, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + ) { + Text( + text = "Add to Your Playlist", + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.padding(bottom = 16.dp), + ) + content() + } + } + } +} + +@Composable +private fun PlaylistPickerContent( + playlists: List, + isLoading: Boolean, + onPlaylistSelected: (String) -> Unit, +) { + if (isLoading) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text("Loading your playlists...") + } + } else if (playlists.isEmpty()) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text("You don't own any playlists yet.\nCreate one to add tracks.") + } + } else { + LazyColumn( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + items(playlists, key = { it.id }) { playlist -> + PlaylistPickerRow( + playlist = playlist, + onClick = { onPlaylistSelected(playlist.id) }, + ) + } + } + } +} + +@Composable +private fun PlaylistPickerRow( + playlist: MetadataPlaylist, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .clickable(onClick = onClick) + .padding(horizontal = 8.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + val imageUrl = playlist.thumbnails.firstOrNull()?.url + if (imageUrl != null) { + AsyncImage( + model = imageUrl, + contentDescription = playlist.title, + modifier = Modifier + .size(48.dp) + .clip(RoundedCornerShape(8.dp)), + contentScale = ContentScale.Crop, + ) + } else { + Box( + modifier = Modifier + .size(48.dp) + .clip(RoundedCornerShape(8.dp)), + contentAlignment = Alignment.Center, + ) { + Text( + text = playlist.title.take(1).uppercase(), + style = MaterialTheme.typography.titleMedium, + ) + } + } + + Column(modifier = Modifier.weight(1f)) { + Text( + text = playlist.title, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = "${playlist.trackCount} tracks", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} 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 index 4c55ad94..6a3fb58a 100644 --- 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 @@ -36,7 +36,6 @@ 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 @@ -50,6 +49,8 @@ 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.OutlineButton +import dev.krtirtho.spotube.core.ui.base.PrimaryButton import dev.krtirtho.spotube.core.ui.base.TextField import io.github.vinceglb.filekit.dialogs.FileKitType import io.github.vinceglb.filekit.dialogs.compose.rememberFilePickerLauncher @@ -141,7 +142,7 @@ fun PlaylistFormSheet( ) }, confirmButton = { - TextButton( + PrimaryButton( onClick = { onSubmit(currentData) onDismiss() @@ -152,7 +153,7 @@ fun PlaylistFormSheet( } }, dismissButton = { - TextButton(onClick = onDismiss) { + OutlineButton(onClick = onDismiss) { Text("Cancel") } }, @@ -190,10 +191,10 @@ fun PlaylistFormSheet( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), ) { - TextButton(onClick = onDismiss) { + OutlineButton(onClick = onDismiss) { Text("Cancel") } - TextButton( + PrimaryButton( onClick = { onSubmit(currentData) onDismiss() @@ -325,7 +326,7 @@ private fun PlaylistFormFields( } } } - TextButton(onClick = onPickImage) { + PrimaryButton(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/AddTracksToPlaylistDialog.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/AddTracksToPlaylistDialog.kt new file mode 100644 index 00000000..1696ad32 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/AddTracksToPlaylistDialog.kt @@ -0,0 +1,325 @@ +/* + * Copyright (C) 2026 Kingmor 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.playlist + +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.fillMaxSize +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo +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.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.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import dev.krtirtho.spotube.core.ui.base.IconButton +import dev.krtirtho.spotube.core.ui.base.OutlineButton +import dev.krtirtho.spotube.core.ui.base.PrimaryButton +import dev.krtirtho.spotube.core.ui.base.TextField +import dev.krtirtho.spotube.modules.library.LibraryRepository +import dev.krtirtho.spotube.modules.search.SearchRepository +import dev.krtirtho.spotube.resources.iconsax.Iconsax +import dev.krtirtho.spotube.resources.iconsax.IconsaxCheckCircle +import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch +import dev.krtirtho.spotube.resources.iconsax.IconsaxSearch +import kotlinx.coroutines.launch +import org.koin.compose.koinInject + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AddTracksToPlaylistDialog( + visible: Boolean, + playlistId: String, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + searchRepository: SearchRepository = koinInject(), + libraryRepository: LibraryRepository = koinInject(), + breakpointDp: Float = 600f, +) { + if (!visible) return + + val adaptiveInfo = currentWindowAdaptiveInfo() + val isLargeScreen = adaptiveInfo.windowSizeClass.minWidthDp >= breakpointDp + + var query by remember { mutableStateOf("") } + var searchResults by remember { mutableStateOf>(emptyList()) } + var isSearching by remember { mutableStateOf(false) } + var hasSearched by remember { mutableStateOf(false) } + var addingTrackIds by remember { mutableStateOf>(emptySet()) } + var addedTrackIds by remember { mutableStateOf>(emptySet()) } + val scope = rememberCoroutineScope() + + LaunchedEffect(visible) { + if (!visible) { + query = "" + searchResults = emptyList() + hasSearched = false + addingTrackIds = emptySet() + addedTrackIds = emptySet() + } + } + + val handleSearch = { + if (query.isNotBlank() && !isSearching) { + isSearching = true + hasSearched = true + scope.launch { + val result = searchRepository.searchTracks(query) + searchResults = result.items.map { it.data } + isSearching = false + } + } + } + + val handleAddTrack = { track: MetadataTrack -> + if (!addingTrackIds.contains(track.id) && !addedTrackIds.contains(track.id)) { + addingTrackIds = addingTrackIds + track.id + scope.launch { + libraryRepository.addTracksToPlaylist(playlistId, listOf(track.id)) + addingTrackIds = addingTrackIds - track.id + addedTrackIds = addedTrackIds + track.id + } + } + } + + val content: @Composable () -> Unit = { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + TextField( + value = query, + onValueChange = { query = it }, + modifier = Modifier.weight(1f), + placeholder = { Text("Search tracks...") }, + singleLine = true, + leadingIcon = { + Icon( + imageVector = Iconsax.IconsaxFilterSearch, + contentDescription = "Search", + ) + }, + ) + IconButton(onClick = handleSearch) { + Icon( + imageVector = Iconsax.IconsaxSearch, + contentDescription = "Search", + ) + } + } + + when { + isSearching -> { + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } + hasSearched && searchResults.isEmpty() -> { + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + contentAlignment = Alignment.Center, + ) { + Text("No tracks found") + } + } + searchResults.isNotEmpty() -> { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + items(searchResults, key = { it.id }) { track -> + TrackSearchResultRow( + track = track, + isAdding = addingTrackIds.contains(track.id), + isAdded = addedTrackIds.contains(track.id), + onAdd = { handleAddTrack(track) }, + ) + } + } + } + else -> { + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + contentAlignment = Alignment.Center, + ) { + Text("Search for tracks to add") + } + } + } + } + } + + if (isLargeScreen) { + AlertDialog( + onDismissRequest = onDismiss, + modifier = modifier, + title = { + Text("Add Tracks to Playlist") + }, + text = { + Box(modifier = Modifier.fillMaxWidth().height(500.dp)) { + content() + } + }, + confirmButton = { + PrimaryButton(onClick = onDismiss) { + Text("Done") + } + }, + ) + } else { + ModalBottomSheet( + onDismissRequest = onDismiss, + dragHandle = null, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + .height(600.dp), + ) { + Text( + text = "Add Tracks to Playlist", + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.padding(bottom = 16.dp), + ) + content() + } + } + } +} + +@Composable +private fun TrackSearchResultRow( + track: MetadataTrack, + isAdding: Boolean, + isAdded: Boolean, + onAdd: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .padding(horizontal = 8.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + val imageUrl = track.album?.thumbnails?.firstOrNull()?.url + if (imageUrl != null) { + AsyncImage( + model = imageUrl, + contentDescription = track.title, + modifier = Modifier + .size(48.dp) + .clip(RoundedCornerShape(6.dp)), + contentScale = ContentScale.Crop, + ) + } else { + Box( + modifier = Modifier + .size(48.dp) + .clip(RoundedCornerShape(6.dp)), + contentAlignment = Alignment.Center, + ) { + Text( + text = track.title.take(1).uppercase(), + style = MaterialTheme.typography.titleMedium, + ) + } + } + + Column(modifier = Modifier.weight(1f)) { + Text( + text = track.title, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = track.artists.joinToString(", ") { it.name }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + when { + isAdded -> { + Icon( + imageVector = Iconsax.IconsaxCheckCircle, + contentDescription = "Added", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp), + ) + } + isAdding -> { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp, + ) + } + else -> { + OutlineButton(onClick = onAdd) { + Text("Add") + } + } + } + } +} 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 1686e37e..8cf52b9f 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 @@ -17,14 +17,21 @@ package dev.krtirtho.spotube.modules.playlist +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.material3.Icon import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text 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.Modifier +import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import dev.krtirtho.spotube.core.audioplayer.AudioPlayer @@ -34,6 +41,7 @@ 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.base.OutlineButton import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar import dev.krtirtho.spotube.core.ui.component.CollectionDetails import dev.krtirtho.spotube.core.ui.component.ErrorDisplay @@ -43,8 +51,13 @@ 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 import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf @@ -55,6 +68,8 @@ fun PlaylistScreen(playlistId: String) { val audioPlayer: AudioPlayer = koinInject() val shareService: ShareService = koinInject() val downloadsViewModel: DownloadsViewModel = koinViewModel() + val libraryRepository: LibraryRepository = koinInject() + val scope = rememberCoroutineScope() val viewModel = koinViewModel( key = playlistId, parameters = { parametersOf(playlistId) } @@ -68,6 +83,9 @@ fun PlaylistScreen(playlistId: String) { val savedPlaylistIds by viewModel.savedPlaylistIds.collectAsStateWithLifecycle() val currentUserId by viewModel.currentUserId.collectAsStateWithLifecycle() var showEditPlaylist by remember { mutableStateOf(false) } + var showAddToPlaylistPicker by remember { mutableStateOf(false) } + var showAddTracksDialog by remember { mutableStateOf(false) } + var tracksToAddToPlaylist by remember { mutableStateOf>(emptyList()) } fun handleTrackOptionsAction(track: MetadataTrack, action: TrackOptionsAction) { viewModel.handleTrackOptionsAction(track, action) @@ -80,6 +98,10 @@ fun PlaylistScreen(playlistId: String) { if (action is TrackOptionsAction.Download) { downloadsViewModel.downloadTrack(track) } + if (action is TrackOptionsAction.AddToPlaylist) { + tracksToAddToPlaylist = listOf(track) + showAddToPlaylistPicker = true + } } Scaffold( @@ -178,6 +200,24 @@ fun PlaylistScreen(playlistId: String) { 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, @@ -200,6 +240,10 @@ fun PlaylistScreen(playlistId: String) { onBulkPlayNext = { tracks -> viewModel.playTracksNext(tracks) }, + onBulkAddToPlaylist = { tracks -> + tracksToAddToPlaylist = tracks + showAddToPlaylistPicker = true + }, ) } } @@ -229,5 +273,28 @@ fun PlaylistScreen(playlistId: String) { ) }, ) + + 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() + }, + ) } } 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 5fd0379f..dcedfdff 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 @@ -259,6 +259,7 @@ class PlaylistViewModel( is TrackOptionsAction.Download -> {} is TrackOptionsAction.ToggleBlacklist -> {} is TrackOptionsAction.Share -> {} + is TrackOptionsAction.AddToPlaylist -> {} } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksScreen.kt index 3af1e7a7..168d5a61 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksScreen.kt @@ -20,7 +20,12 @@ package dev.krtirtho.spotube.modules.saved_tracks import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold 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.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 @@ -40,6 +45,9 @@ 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 @@ -53,6 +61,8 @@ fun SavedTracksScreen() { val audioPlayer: AudioPlayer = koinInject() val shareService: ShareService = koinInject() val downloadsViewModel: DownloadsViewModel = koinViewModel() + val libraryRepository: LibraryRepository = koinInject() + val scope = rememberCoroutineScope() val viewModel = koinViewModel( key = SAVED_TRACKS_COLLECTION_ID, parameters = { parametersOf() } @@ -63,6 +73,13 @@ fun SavedTracksScreen() { val currentQueueEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle() val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle() val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle() + var showAddToPlaylistPicker by remember { mutableStateOf(false) } + var tracksToAddToPlaylist by remember { mutableStateOf>(emptyList()) } + var currentUserId by remember { mutableStateOf(null) } + + LaunchedEffect(Unit) { + currentUserId = libraryRepository.currentUser()?.id + } fun getTrackOptionsState(track: MetadataTrack): TrackOptionsState { val currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id @@ -88,6 +105,10 @@ fun SavedTracksScreen() { if (action is TrackOptionsAction.Download) { downloadsViewModel.downloadTrack(track) } + if (action is TrackOptionsAction.AddToPlaylist) { + tracksToAddToPlaylist = listOf(track) + showAddToPlaylistPicker = true + } } Scaffold( @@ -187,8 +208,26 @@ fun SavedTracksScreen() { 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 } + ) + } + }, + ) } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksViewModel.kt index 1f3776a6..2dd7661c 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksViewModel.kt @@ -197,6 +197,7 @@ class SavedTracksViewModel( is TrackOptionsAction.Download -> {} is TrackOptionsAction.ToggleBlacklist -> {} is TrackOptionsAction.Share -> {} + is TrackOptionsAction.AddToPlaylist -> {} } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt index 751a26f6..d2bdd63c 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt @@ -57,8 +57,10 @@ 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.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -97,6 +99,8 @@ import dev.krtirtho.spotube.core.ui.component.UserCard import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard 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.shell.LocalAppShellBottomInset import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.IconsaxSearchBroken @@ -115,10 +119,18 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { val audioPlayerQueue: AudioPlayerQueue = koinInject() val shareService: ShareService = koinInject() val downloadsViewModel: DownloadsViewModel = koinViewModel() + val libraryRepository: LibraryRepository = koinInject() val state by viewModel.state.collectAsStateWithLifecycle() val selectedType = state.selectedSearchType val scope = rememberCoroutineScope() val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle() + var showAddToPlaylistPicker by remember { mutableStateOf(false) } + var tracksToAddToPlaylist by remember { mutableStateOf>(emptyList()) } + var currentUserId by remember { mutableStateOf(null) } + + LaunchedEffect(Unit) { + currentUserId = libraryRepository.currentUser()?.id + } val focusManager = LocalFocusManager.current @@ -175,6 +187,10 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { shareService.share(uri, track.title) } } + is TrackOptionsAction.AddToPlaylist -> { + tracksToAddToPlaylist = listOf(track) + showAddToPlaylistPicker = true + } } } } @@ -295,6 +311,10 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { onBulkDownload = { tracks -> downloadsViewModel.downloadTracks(tracks) }, onBulkAddToQueue = ::bulkAddToQueue, onBulkPlayNext = ::bulkPlayNext, + onBulkAddToPlaylist = { tracks -> + tracksToAddToPlaylist = tracks + showAddToPlaylistPicker = true + }, modifier = Modifier .fillMaxSize(), ) @@ -311,6 +331,10 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { onBulkDownload = { tracks -> downloadsViewModel.downloadTracks(tracks) }, onBulkAddToQueue = ::bulkAddToQueue, onBulkPlayNext = ::bulkPlayNext, + onBulkAddToPlaylist = { tracks -> + tracksToAddToPlaylist = tracks + showAddToPlaylistPicker = true + }, modifier = Modifier .fillMaxSize(), ) @@ -357,6 +381,20 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { } } } + + AddToPlaylistPicker( + visible = showAddToPlaylistPicker, + currentUserId = currentUserId, + onDismiss = { showAddToPlaylistPicker = false }, + onPlaylistSelected = { playlistId -> + scope.launch { + libraryRepository.addTracksToPlaylist( + playlistId, + tracksToAddToPlaylist.map { it.id } + ) + } + }, + ) } } @@ -568,6 +606,7 @@ private fun SearchAllTab( onBulkDownload: (List) -> Unit, onBulkAddToQueue: (List) -> Unit, onBulkPlayNext: (List) -> Unit, + onBulkAddToPlaylist: (List) -> Unit, modifier: Modifier = Modifier, ) { if (query.isBlank()) { @@ -621,6 +660,7 @@ private fun SearchAllTab( onBulkDownload = onBulkDownload, onBulkAddToQueue = onBulkAddToQueue, onBulkPlayNext = onBulkPlayNext, + onBulkAddToPlaylist = onBulkAddToPlaylist, contentPadding = PaddingValues(top = 12.dp, bottom = 16.dp + bottomInset), headerContent = { if (showTracks) { @@ -704,6 +744,7 @@ private fun SearchTracksTab( onBulkDownload: (List) -> Unit, onBulkAddToQueue: (List) -> Unit, onBulkPlayNext: (List) -> Unit, + onBulkAddToPlaylist: (List) -> Unit, modifier: Modifier = Modifier, ) { if (query.isBlank()) { @@ -729,6 +770,7 @@ private fun SearchTracksTab( onBulkDownload = onBulkDownload, onBulkAddToQueue = onBulkAddToQueue, onBulkPlayNext = onBulkPlayNext, + onBulkAddToPlaylist = onBulkAddToPlaylist, simplified = true, modifier = modifier, )