mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-08-05 19:59:51 +00:00
feat: add 'Add to Playlist' functionality with UI components and dialogs
This commit is contained in:
parent
8692c344d6
commit
49e969cc0f
@ -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(
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 }
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -222,6 +222,7 @@ class AlbumViewModel(
|
||||
is TrackOptionsAction.Download -> {}
|
||||
is TrackOptionsAction.ToggleBlacklist -> {}
|
||||
is TrackOptionsAction.Share -> {}
|
||||
is TrackOptionsAction.AddToPlaylist -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 }
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -269,6 +269,7 @@ class ArtistViewModel(
|
||||
is TrackOptionsAction.Download -> {}
|
||||
is TrackOptionsAction.ToggleBlacklist -> {}
|
||||
is TrackOptionsAction.Share -> {}
|
||||
is TrackOptionsAction.AddToPlaylist -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -259,6 +259,7 @@ class PlaylistViewModel(
|
||||
is TrackOptionsAction.Download -> {}
|
||||
is TrackOptionsAction.ToggleBlacklist -> {}
|
||||
is TrackOptionsAction.Share -> {}
|
||||
is TrackOptionsAction.AddToPlaylist -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 }
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -197,6 +197,7 @@ class SavedTracksViewModel(
|
||||
is TrackOptionsAction.Download -> {}
|
||||
is TrackOptionsAction.ToggleBlacklist -> {}
|
||||
is TrackOptionsAction.Share -> {}
|
||||
is TrackOptionsAction.AddToPlaylist -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user