feat: add 'Add to Playlist' functionality with UI components and dialogs

This commit is contained in:
Kingkor Roy Tirtho 2026-07-10 19:06:05 +06:00
parent 8692c344d6
commit 49e969cc0f
15 changed files with 845 additions and 6 deletions

View File

@ -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<MetadataTrack>) -> Unit = {},
onBulkAddToQueue: (List<MetadataTrack>) -> Unit = {},
onBulkPlayNext: (List<MetadataTrack>) -> Unit = {},
onBulkAddToPlaylist: (List<MetadataTrack>) -> 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(

View File

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

View File

@ -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<AlbumViewModel>(
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<List<MetadataTrack>>(emptyList()) }
var currentUserId by remember { mutableStateOf<String?>(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 }
)
}
},
)
}
}

View File

@ -222,6 +222,7 @@ class AlbumViewModel(
is TrackOptionsAction.Download -> {}
is TrackOptionsAction.ToggleBlacklist -> {}
is TrackOptionsAction.Share -> {}
is TrackOptionsAction.AddToPlaylist -> {}
}
}
}

View File

@ -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<ArtistViewModel>(
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<List<MetadataTrack>>(emptyList()) }
var currentUserId by remember { mutableStateOf<String?>(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 }
)
}
},
)
}
}

View File

@ -269,6 +269,7 @@ class ArtistViewModel(
is TrackOptionsAction.Download -> {}
is TrackOptionsAction.ToggleBlacklist -> {}
is TrackOptionsAction.Share -> {}
is TrackOptionsAction.AddToPlaylist -> {}
}
}
}

View File

@ -342,4 +342,15 @@ class LibraryRepository(
}
}
}
suspend fun addTracksToPlaylist(playlistId: String, trackIds: List<String>) {
plugin?.let { plugin ->
pluginManager.withScope {
plugin.use {
metadataPlaylistAPI.addTracksToPlaylist(playlistId, trackIds)
}
}
playlistCache.invalidateAll()
}
}
}

View File

@ -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 <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.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<List<MetadataPlaylist>>(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<MetadataPlaylist>,
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,
)
}
}
}

View File

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

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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<List<MetadataTrack>>(emptyList()) }
var isSearching by remember { mutableStateOf(false) }
var hasSearched by remember { mutableStateOf(false) }
var addingTrackIds by remember { mutableStateOf<Set<String>>(emptySet()) }
var addedTrackIds by remember { mutableStateOf<Set<String>>(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")
}
}
}
}
}

View File

@ -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<PlaylistViewModel>(
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<List<MetadataTrack>>(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()
},
)
}
}

View File

@ -259,6 +259,7 @@ class PlaylistViewModel(
is TrackOptionsAction.Download -> {}
is TrackOptionsAction.ToggleBlacklist -> {}
is TrackOptionsAction.Share -> {}
is TrackOptionsAction.AddToPlaylist -> {}
}
}
}

View File

@ -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<SavedTracksViewModel>(
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<List<MetadataTrack>>(emptyList()) }
var currentUserId by remember { mutableStateOf<String?>(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 }
)
}
},
)
}
}

View File

@ -197,6 +197,7 @@ class SavedTracksViewModel(
is TrackOptionsAction.Download -> {}
is TrackOptionsAction.ToggleBlacklist -> {}
is TrackOptionsAction.Share -> {}
is TrackOptionsAction.AddToPlaylist -> {}
}
}
}

View File

@ -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<List<MetadataTrack>>(emptyList()) }
var currentUserId by remember { mutableStateOf<String?>(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<MetadataTrack>) -> Unit,
onBulkAddToQueue: (List<MetadataTrack>) -> Unit,
onBulkPlayNext: (List<MetadataTrack>) -> Unit,
onBulkAddToPlaylist: (List<MetadataTrack>) -> 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<MetadataTrack>) -> Unit,
onBulkAddToQueue: (List<MetadataTrack>) -> Unit,
onBulkPlayNext: (List<MetadataTrack>) -> Unit,
onBulkAddToPlaylist: (List<MetadataTrack>) -> 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,
)