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.IconsaxAddSquare
import dev.krtirtho.spotube.resources.iconsax.IconsaxDirectboxReceive import dev.krtirtho.spotube.resources.iconsax.IconsaxDirectboxReceive
import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch 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.IconsaxNext
import dev.krtirtho.spotube.resources.iconsax.IconsaxPause import dev.krtirtho.spotube.resources.iconsax.IconsaxPause
import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay
@ -140,6 +141,7 @@ fun TrackList(
onBulkDownload: (List<MetadataTrack>) -> Unit = {}, onBulkDownload: (List<MetadataTrack>) -> Unit = {},
onBulkAddToQueue: (List<MetadataTrack>) -> Unit = {}, onBulkAddToQueue: (List<MetadataTrack>) -> Unit = {},
onBulkPlayNext: (List<MetadataTrack>) -> Unit = {}, onBulkPlayNext: (List<MetadataTrack>) -> Unit = {},
onBulkAddToPlaylist: (List<MetadataTrack>) -> Unit = {},
currentTrackId: String? = null, currentTrackId: String? = null,
isCurrentTrackPlaying: Boolean = false, isCurrentTrackPlaying: Boolean = false,
trackOptionsState: (MetadataTrack) -> TrackOptionsState = { TrackOptionsState() }, trackOptionsState: (MetadataTrack) -> TrackOptionsState = { TrackOptionsState() },
@ -433,6 +435,11 @@ fun TrackList(
label = if (isAll) "Play All Next" else "Play $trackCount Next", label = if (isAll) "Play All Next" else "Play $trackCount Next",
onClick = { onBulkPlayNext(targetTracks) }, onClick = { onBulkPlayNext(targetTracks) },
), ),
AdaptiveMenuItem(
icon = Iconsax.IconsaxMusicPlaylist,
label = if (isAll) "Add All to Playlist" else "Add $trackCount to Playlist",
onClick = { onBulkAddToPlaylist(targetTracks) },
),
), ),
trigger = { onClick -> trigger = { onClick ->
GroupIconButton( 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.IconsaxHeart
import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart2 import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart2
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicCircle 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.IconsaxMusicSquareRemove
import dev.krtirtho.spotube.resources.iconsax.IconsaxNext import dev.krtirtho.spotube.resources.iconsax.IconsaxNext
import dev.krtirtho.spotube.resources.iconsax.IconsaxShare import dev.krtirtho.spotube.resources.iconsax.IconsaxShare
@ -59,6 +60,7 @@ sealed interface TrackOptionsAction {
data object AddToQueue : TrackOptionsAction data object AddToQueue : TrackOptionsAction
data object RemoveFromQueue : TrackOptionsAction data object RemoveFromQueue : TrackOptionsAction
data object ToggleFavorite : TrackOptionsAction data object ToggleFavorite : TrackOptionsAction
data object AddToPlaylist : TrackOptionsAction
data object Download : TrackOptionsAction data object Download : TrackOptionsAction
data object ToggleBlacklist : TrackOptionsAction data object ToggleBlacklist : TrackOptionsAction
data object Share : 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( add(
AdaptiveMenuItem( AdaptiveMenuItem(
icon = Iconsax.IconsaxDirectboxReceive, icon = Iconsax.IconsaxDirectboxReceive,

View File

@ -20,7 +20,12 @@ package dev.krtirtho.spotube.modules.album
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
@ -40,6 +45,8 @@ import dev.krtirtho.spotube.core.ui.component.TrackOptionsState
import dev.krtirtho.spotube.core.ui.misc.SkeletonTree import dev.krtirtho.spotube.core.ui.misc.SkeletonTree
import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel
import dev.krtirtho.spotube.modules.library.LibraryRepository import dev.krtirtho.spotube.modules.library.LibraryRepository
import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker
import kotlinx.coroutines.launch
import org.koin.compose.koinInject import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf import org.koin.core.parameter.parametersOf
@ -50,6 +57,8 @@ fun AlbumScreen(albumId: String) {
val audioPlayer: AudioPlayer = koinInject() val audioPlayer: AudioPlayer = koinInject()
val shareService: ShareService = koinInject() val shareService: ShareService = koinInject()
val downloadsViewModel: DownloadsViewModel = koinViewModel() val downloadsViewModel: DownloadsViewModel = koinViewModel()
val libraryRepository: LibraryRepository = koinInject()
val scope = rememberCoroutineScope()
val viewModel = koinViewModel<AlbumViewModel>( val viewModel = koinViewModel<AlbumViewModel>(
key = albumId, key = albumId,
parameters = { parametersOf(albumId) } parameters = { parametersOf(albumId) }
@ -62,6 +71,13 @@ fun AlbumScreen(albumId: String) {
val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle() val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle()
val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle() val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle()
val savedAlbumIds by viewModel.savedAlbumIds.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 { fun getTrackOptionsState(track: MetadataTrack): TrackOptionsState {
val currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id val currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id
@ -87,6 +103,10 @@ fun AlbumScreen(albumId: String) {
if (action is TrackOptionsAction.Download) { if (action is TrackOptionsAction.Download) {
downloadsViewModel.downloadTrack(track) downloadsViewModel.downloadTrack(track)
} }
if (action is TrackOptionsAction.AddToPlaylist) {
tracksToAddToPlaylist = listOf(track)
showAddToPlaylistPicker = true
}
} }
Scaffold( Scaffold(
@ -194,8 +214,26 @@ fun AlbumScreen(albumId: String) {
onBulkPlayNext = { tracks -> onBulkPlayNext = { tracks ->
viewModel.playTracksNext(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.Download -> {}
is TrackOptionsAction.ToggleBlacklist -> {} is TrackOptionsAction.ToggleBlacklist -> {}
is TrackOptionsAction.Share -> {} 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.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -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.TextWithShimmer
import dev.krtirtho.spotube.core.ui.misc.shimmerApply import dev.krtirtho.spotube.core.ui.misc.shimmerApply
import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel
import dev.krtirtho.spotube.modules.library.LibraryRepository
import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker
import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare
import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay
import dev.krtirtho.spotube.resources.iconsax.User import dev.krtirtho.spotube.resources.iconsax.User
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import org.koin.compose.koinInject import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf import org.koin.core.parameter.parametersOf
@ -101,6 +108,8 @@ fun ArtistScreen(artistId: String) {
val audioPlayer: AudioPlayer = koinInject() val audioPlayer: AudioPlayer = koinInject()
val shareService: ShareService = koinInject() val shareService: ShareService = koinInject()
val downloadsViewModel: DownloadsViewModel = koinViewModel() val downloadsViewModel: DownloadsViewModel = koinViewModel()
val libraryRepository: LibraryRepository = koinInject()
val scope = rememberCoroutineScope()
val viewModel = koinViewModel<ArtistViewModel>( val viewModel = koinViewModel<ArtistViewModel>(
key = artistId, key = artistId,
parameters = { parametersOf(artistId) } parameters = { parametersOf(artistId) }
@ -113,6 +122,13 @@ fun ArtistScreen(artistId: String) {
val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle() val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle()
val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle() val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle()
val savedArtistIds by viewModel.savedArtistIds.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 { fun getTrackOptionsState(track: MetadataTrack): TrackOptionsState {
val currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id val currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id
@ -138,6 +154,10 @@ fun ArtistScreen(artistId: String) {
if (action is TrackOptionsAction.Download) { if (action is TrackOptionsAction.Download) {
downloadsViewModel.downloadTrack(track) downloadsViewModel.downloadTrack(track)
} }
if (action is TrackOptionsAction.AddToPlaylist) {
tracksToAddToPlaylist = listOf(track)
showAddToPlaylistPicker = true
}
} }
Scaffold( Scaffold(
@ -213,6 +233,10 @@ fun ArtistScreen(artistId: String) {
onBulkDownload = { tracks -> downloadsViewModel.downloadTracks(tracks) }, onBulkDownload = { tracks -> downloadsViewModel.downloadTracks(tracks) },
onBulkAddToQueue = { tracks -> viewModel.addTracksToQueue(tracks) }, onBulkAddToQueue = { tracks -> viewModel.addTracksToQueue(tracks) },
onBulkPlayNext = { tracks -> viewModel.playTracksNext(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.Download -> {}
is TrackOptionsAction.ToggleBlacklist -> {} is TrackOptionsAction.ToggleBlacklist -> {}
is TrackOptionsAction.Share -> {} 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.ModalBottomSheet
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue 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.layout.ContentScale
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage 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 dev.krtirtho.spotube.core.ui.base.TextField
import io.github.vinceglb.filekit.dialogs.FileKitType import io.github.vinceglb.filekit.dialogs.FileKitType
import io.github.vinceglb.filekit.dialogs.compose.rememberFilePickerLauncher import io.github.vinceglb.filekit.dialogs.compose.rememberFilePickerLauncher
@ -141,7 +142,7 @@ fun PlaylistFormSheet(
) )
}, },
confirmButton = { confirmButton = {
TextButton( PrimaryButton(
onClick = { onClick = {
onSubmit(currentData) onSubmit(currentData)
onDismiss() onDismiss()
@ -152,7 +153,7 @@ fun PlaylistFormSheet(
} }
}, },
dismissButton = { dismissButton = {
TextButton(onClick = onDismiss) { OutlineButton(onClick = onDismiss) {
Text("Cancel") Text("Cancel")
} }
}, },
@ -190,10 +191,10 @@ fun PlaylistFormSheet(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
) { ) {
TextButton(onClick = onDismiss) { OutlineButton(onClick = onDismiss) {
Text("Cancel") Text("Cancel")
} }
TextButton( PrimaryButton(
onClick = { onClick = {
onSubmit(currentData) onSubmit(currentData)
onDismiss() 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") 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 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.foundation.layout.padding
import androidx.compose.material3.Icon
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import dev.krtirtho.spotube.core.audioplayer.AudioPlayer 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.NavigationCommands
import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.core.share.ShareService 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.ApplicationMainBar
import dev.krtirtho.spotube.core.ui.component.CollectionDetails import dev.krtirtho.spotube.core.ui.component.CollectionDetails
import dev.krtirtho.spotube.core.ui.component.ErrorDisplay 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.core.ui.misc.SkeletonTree
import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel
import dev.krtirtho.spotube.modules.library.LibraryRepository import dev.krtirtho.spotube.modules.library.LibraryRepository
import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker
import dev.krtirtho.spotube.modules.library.playlist.PlaylistFormData import dev.krtirtho.spotube.modules.library.playlist.PlaylistFormData
import dev.krtirtho.spotube.modules.library.playlist.PlaylistFormSheet 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.koinInject
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf import org.koin.core.parameter.parametersOf
@ -55,6 +68,8 @@ fun PlaylistScreen(playlistId: String) {
val audioPlayer: AudioPlayer = koinInject() val audioPlayer: AudioPlayer = koinInject()
val shareService: ShareService = koinInject() val shareService: ShareService = koinInject()
val downloadsViewModel: DownloadsViewModel = koinViewModel() val downloadsViewModel: DownloadsViewModel = koinViewModel()
val libraryRepository: LibraryRepository = koinInject()
val scope = rememberCoroutineScope()
val viewModel = koinViewModel<PlaylistViewModel>( val viewModel = koinViewModel<PlaylistViewModel>(
key = playlistId, key = playlistId,
parameters = { parametersOf(playlistId) } parameters = { parametersOf(playlistId) }
@ -68,6 +83,9 @@ fun PlaylistScreen(playlistId: String) {
val savedPlaylistIds by viewModel.savedPlaylistIds.collectAsStateWithLifecycle() val savedPlaylistIds by viewModel.savedPlaylistIds.collectAsStateWithLifecycle()
val currentUserId by viewModel.currentUserId.collectAsStateWithLifecycle() val currentUserId by viewModel.currentUserId.collectAsStateWithLifecycle()
var showEditPlaylist by remember { mutableStateOf(false) } 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) { fun handleTrackOptionsAction(track: MetadataTrack, action: TrackOptionsAction) {
viewModel.handleTrackOptionsAction(track, action) viewModel.handleTrackOptionsAction(track, action)
@ -80,6 +98,10 @@ fun PlaylistScreen(playlistId: String) {
if (action is TrackOptionsAction.Download) { if (action is TrackOptionsAction.Download) {
downloadsViewModel.downloadTrack(track) downloadsViewModel.downloadTrack(track)
} }
if (action is TrackOptionsAction.AddToPlaylist) {
tracksToAddToPlaylist = listOf(track)
showAddToPlaylistPicker = true
}
} }
Scaffold( Scaffold(
@ -178,6 +200,24 @@ fun PlaylistScreen(playlistId: String) {
onEdit = if (isOwner) { { showEditPlaylist = true } } else null, 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, tracks = dataState.tracks,
error = null, error = null,
hasMore = dataState.nextPagination != null, hasMore = dataState.nextPagination != null,
@ -200,6 +240,10 @@ fun PlaylistScreen(playlistId: String) {
onBulkPlayNext = { tracks -> onBulkPlayNext = { tracks ->
viewModel.playTracksNext(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.Download -> {}
is TrackOptionsAction.ToggleBlacklist -> {} is TrackOptionsAction.ToggleBlacklist -> {}
is TrackOptionsAction.Share -> {} 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.foundation.layout.padding
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
@ -40,6 +45,9 @@ import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
import dev.krtirtho.spotube.core.ui.component.TrackOptionsState import dev.krtirtho.spotube.core.ui.component.TrackOptionsState
import dev.krtirtho.spotube.core.ui.misc.SkeletonTree import dev.krtirtho.spotube.core.ui.misc.SkeletonTree
import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel
import dev.krtirtho.spotube.modules.library.LibraryRepository
import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.DrawableResource import org.jetbrains.compose.resources.DrawableResource
import org.koin.compose.koinInject import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
@ -53,6 +61,8 @@ fun SavedTracksScreen() {
val audioPlayer: AudioPlayer = koinInject() val audioPlayer: AudioPlayer = koinInject()
val shareService: ShareService = koinInject() val shareService: ShareService = koinInject()
val downloadsViewModel: DownloadsViewModel = koinViewModel() val downloadsViewModel: DownloadsViewModel = koinViewModel()
val libraryRepository: LibraryRepository = koinInject()
val scope = rememberCoroutineScope()
val viewModel = koinViewModel<SavedTracksViewModel>( val viewModel = koinViewModel<SavedTracksViewModel>(
key = SAVED_TRACKS_COLLECTION_ID, key = SAVED_TRACKS_COLLECTION_ID,
parameters = { parametersOf() } parameters = { parametersOf() }
@ -63,6 +73,13 @@ fun SavedTracksScreen() {
val currentQueueEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle() val currentQueueEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle()
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle() val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
val playerState by audioPlayer.playerStateFlow.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 { fun getTrackOptionsState(track: MetadataTrack): TrackOptionsState {
val currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id val currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id
@ -88,6 +105,10 @@ fun SavedTracksScreen() {
if (action is TrackOptionsAction.Download) { if (action is TrackOptionsAction.Download) {
downloadsViewModel.downloadTrack(track) downloadsViewModel.downloadTrack(track)
} }
if (action is TrackOptionsAction.AddToPlaylist) {
tracksToAddToPlaylist = listOf(track)
showAddToPlaylistPicker = true
}
} }
Scaffold( Scaffold(
@ -187,8 +208,26 @@ fun SavedTracksScreen() {
onBulkPlayNext = { tracks -> onBulkPlayNext = { tracks ->
viewModel.playTracksNext(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.Download -> {}
is TrackOptionsAction.ToggleBlacklist -> {} is TrackOptionsAction.ToggleBlacklist -> {}
is TrackOptionsAction.Share -> {} 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.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -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.component.cards.PlayableCard
import dev.krtirtho.spotube.core.ui.misc.SkeletonTree import dev.krtirtho.spotube.core.ui.misc.SkeletonTree
import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel
import dev.krtirtho.spotube.modules.library.LibraryRepository
import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxSearchBroken import dev.krtirtho.spotube.resources.iconsax.IconsaxSearchBroken
@ -115,10 +119,18 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
val audioPlayerQueue: AudioPlayerQueue = koinInject() val audioPlayerQueue: AudioPlayerQueue = koinInject()
val shareService: ShareService = koinInject() val shareService: ShareService = koinInject()
val downloadsViewModel: DownloadsViewModel = koinViewModel() val downloadsViewModel: DownloadsViewModel = koinViewModel()
val libraryRepository: LibraryRepository = koinInject()
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
val selectedType = state.selectedSearchType val selectedType = state.selectedSearchType
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle() 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 val focusManager = LocalFocusManager.current
@ -175,6 +187,10 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
shareService.share(uri, track.title) 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) }, onBulkDownload = { tracks -> downloadsViewModel.downloadTracks(tracks) },
onBulkAddToQueue = ::bulkAddToQueue, onBulkAddToQueue = ::bulkAddToQueue,
onBulkPlayNext = ::bulkPlayNext, onBulkPlayNext = ::bulkPlayNext,
onBulkAddToPlaylist = { tracks ->
tracksToAddToPlaylist = tracks
showAddToPlaylistPicker = true
},
modifier = Modifier modifier = Modifier
.fillMaxSize(), .fillMaxSize(),
) )
@ -311,6 +331,10 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
onBulkDownload = { tracks -> downloadsViewModel.downloadTracks(tracks) }, onBulkDownload = { tracks -> downloadsViewModel.downloadTracks(tracks) },
onBulkAddToQueue = ::bulkAddToQueue, onBulkAddToQueue = ::bulkAddToQueue,
onBulkPlayNext = ::bulkPlayNext, onBulkPlayNext = ::bulkPlayNext,
onBulkAddToPlaylist = { tracks ->
tracksToAddToPlaylist = tracks
showAddToPlaylistPicker = true
},
modifier = Modifier modifier = Modifier
.fillMaxSize(), .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, onBulkDownload: (List<MetadataTrack>) -> Unit,
onBulkAddToQueue: (List<MetadataTrack>) -> Unit, onBulkAddToQueue: (List<MetadataTrack>) -> Unit,
onBulkPlayNext: (List<MetadataTrack>) -> Unit, onBulkPlayNext: (List<MetadataTrack>) -> Unit,
onBulkAddToPlaylist: (List<MetadataTrack>) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
if (query.isBlank()) { if (query.isBlank()) {
@ -621,6 +660,7 @@ private fun SearchAllTab(
onBulkDownload = onBulkDownload, onBulkDownload = onBulkDownload,
onBulkAddToQueue = onBulkAddToQueue, onBulkAddToQueue = onBulkAddToQueue,
onBulkPlayNext = onBulkPlayNext, onBulkPlayNext = onBulkPlayNext,
onBulkAddToPlaylist = onBulkAddToPlaylist,
contentPadding = PaddingValues(top = 12.dp, bottom = 16.dp + bottomInset), contentPadding = PaddingValues(top = 12.dp, bottom = 16.dp + bottomInset),
headerContent = { headerContent = {
if (showTracks) { if (showTracks) {
@ -704,6 +744,7 @@ private fun SearchTracksTab(
onBulkDownload: (List<MetadataTrack>) -> Unit, onBulkDownload: (List<MetadataTrack>) -> Unit,
onBulkAddToQueue: (List<MetadataTrack>) -> Unit, onBulkAddToQueue: (List<MetadataTrack>) -> Unit,
onBulkPlayNext: (List<MetadataTrack>) -> Unit, onBulkPlayNext: (List<MetadataTrack>) -> Unit,
onBulkAddToPlaylist: (List<MetadataTrack>) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
if (query.isBlank()) { if (query.isBlank()) {
@ -729,6 +770,7 @@ private fun SearchTracksTab(
onBulkDownload = onBulkDownload, onBulkDownload = onBulkDownload,
onBulkAddToQueue = onBulkAddToQueue, onBulkAddToQueue = onBulkAddToQueue,
onBulkPlayNext = onBulkPlayNext, onBulkPlayNext = onBulkPlayNext,
onBulkAddToPlaylist = onBulkAddToPlaylist,
simplified = true, simplified = true,
modifier = modifier, modifier = modifier,
) )