diff --git a/app/src/main/kotlin/com/metrolist/music/ui/menu/AddToPlaylistDialog.kt b/app/src/main/kotlin/com/metrolist/music/ui/menu/AddToPlaylistDialog.kt index a2d5b36bbb..5756f58564 100644 --- a/app/src/main/kotlin/com/metrolist/music/ui/menu/AddToPlaylistDialog.kt +++ b/app/src/main/kotlin/com/metrolist/music/ui/menu/AddToPlaylistDialog.kt @@ -30,7 +30,6 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import com.metrolist.innertube.YouTube import com.metrolist.innertube.utils.parseCookieString import com.metrolist.music.LocalDatabase import com.metrolist.music.R @@ -74,20 +73,19 @@ import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.material3.FilterChip import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.material3.FilterChipDefaults -import com.metrolist.music.LocalSyncUtils @Composable fun AddToPlaylistDialog( isVisible: Boolean, allowSyncing: Boolean = true, initialTextFieldValue: String? = null, + multiSelectParams: String? = null, onGetSong: suspend (Playlist) -> List, // list of song ids. Songs should be inserted to database in this function. onGetSongIds: (suspend () -> List)? = null, onDismiss: () -> Unit, viewModel: PlaylistsViewModel = hiltViewModel() ) { val database = LocalDatabase.current - val syncUtils = LocalSyncUtils.current val coroutineScope = rememberCoroutineScope() val (sortType, onSortTypeChange) = rememberEnumPreference( AddToPlaylistSortTypeKey, @@ -122,20 +120,6 @@ fun AddToPlaylistDialog( mutableStateOf>(emptySet()) } - suspend fun addSongsAndSync(targetPlaylist: Playlist, ids: List) { - database.addSongsToPlaylist(targetPlaylist, ids.map { it to null }, prepend = true) - targetPlaylist.playlist.browseId?.let { plist -> - ids.forEach { songId -> - syncUtils.registerPendingAdd(plist, songId) - try { - YouTube.addToPlaylist(plist, songId) - } finally { - syncUtils.unregisterPendingAdd(plist, songId) - } - } - } - } - LaunchedEffect(isVisible, playlists.isEmpty()) { if (!isVisible || playlists.isEmpty()) return@LaunchedEffect if (songIds != null) return@LaunchedEffect @@ -291,26 +275,29 @@ fun AddToPlaylistDialog( animationSpec = spring(stiffness = Spring.StiffnessMediumLow), label = "playlistBg" ) - PlaylistListItem( - playlist = playlist, - modifier = Modifier + PlaylistListItem( + playlist = playlist, + modifier = Modifier .padding(horizontal = 8.dp, vertical = 2.dp) .clip(RoundedCornerShape(16.dp)) .background(rowBg) .clickable { selectedPlaylist = playlist coroutineScope.launch(Dispatchers.IO) { - if (songIds == null) { - songIds = onGetSong(playlist) - } else { - onGetSong(playlist) - } - duplicates = database.playlistDuplicates(playlist.id, songIds!!) + val resolvedSongIds = + if (songIds.isNullOrEmpty()) { + onGetSong(playlist).also { resolved -> + songIds = resolved + } + } else { + songIds!! + } + duplicates = database.playlistDuplicates(playlist.id, resolvedSongIds) if (duplicates.isNotEmpty()) { showDuplicateDialog = true } else { onDismiss() - addSongsAndSync(playlist, songIds!!) + viewModel.addSongsAndSync(playlist, resolvedSongIds, multiSelectParams) } } } @@ -336,12 +323,11 @@ fun AddToPlaylistDialog( onClick = { showDuplicateDialog = false onDismiss() - coroutineScope.launch(Dispatchers.IO) { - addSongsAndSync( - selectedPlaylist!!, - songIds!!.filter { !duplicates.contains(it) } - ) - } + viewModel.addSongsAndSync( + selectedPlaylist!!, + songIds!!.filter { !duplicates.contains(it) }, + multiSelectParams, + ) } ) { Text(stringResource(R.string.skip_duplicates)) @@ -351,9 +337,7 @@ fun AddToPlaylistDialog( onClick = { showDuplicateDialog = false onDismiss() - coroutineScope.launch(Dispatchers.IO) { - addSongsAndSync(selectedPlaylist!!, songIds!!) - } + viewModel.addSongsAndSync(selectedPlaylist!!, songIds!!, multiSelectParams) } ) { Text(stringResource(R.string.add_anyway)) diff --git a/app/src/main/kotlin/com/metrolist/music/ui/menu/AlbumMenu.kt b/app/src/main/kotlin/com/metrolist/music/ui/menu/AlbumMenu.kt index 1878223d84..092e742fac 100644 --- a/app/src/main/kotlin/com/metrolist/music/ui/menu/AlbumMenu.kt +++ b/app/src/main/kotlin/com/metrolist/music/ui/menu/AlbumMenu.kt @@ -174,14 +174,7 @@ fun AlbumMenu( AddToPlaylistDialog( isVisible = showChoosePlaylistDialog, - onGetSong = { playlist -> - coroutineScope.launch(Dispatchers.IO) { - playlist.playlist.browseId?.let { playlistId -> - album.album.playlistId?.let { addPlaylistId -> - YouTube.addPlaylistToPlaylist(playlistId, addPlaylistId) - } - } - } + onGetSong = { songs.map { it.id } }, onGetSongIds = { songs.map { it.id } }, diff --git a/app/src/main/kotlin/com/metrolist/music/ui/menu/PlayerMenu.kt b/app/src/main/kotlin/com/metrolist/music/ui/menu/PlayerMenu.kt index af3b04d08d..b00271508c 100644 --- a/app/src/main/kotlin/com/metrolist/music/ui/menu/PlayerMenu.kt +++ b/app/src/main/kotlin/com/metrolist/music/ui/menu/PlayerMenu.kt @@ -167,13 +167,10 @@ fun PlayerMenu( AddToPlaylistDialog( isVisible = showChoosePlaylistDialog, - onGetSong = { playlist -> + onGetSong = { database.withTransaction { insert(mediaMetadata) } - coroutineScope.launch(Dispatchers.IO) { - playlist.playlist.browseId?.let { YouTube.addToPlaylist(it, mediaMetadata.id) } - } listOf(mediaMetadata.id) }, onGetSongIds = { listOf(mediaMetadata.id) }, diff --git a/app/src/main/kotlin/com/metrolist/music/ui/menu/QueueMenu.kt b/app/src/main/kotlin/com/metrolist/music/ui/menu/QueueMenu.kt index 8c7941d353..d2571a4bd3 100644 --- a/app/src/main/kotlin/com/metrolist/music/ui/menu/QueueMenu.kt +++ b/app/src/main/kotlin/com/metrolist/music/ui/menu/QueueMenu.kt @@ -117,13 +117,10 @@ fun QueueMenu( AddToPlaylistDialog( isVisible = showChoosePlaylistDialog, - onGetSong = { playlist -> + onGetSong = { database.withTransaction { insert(mediaMetadata) } - coroutineScope.launch(Dispatchers.IO) { - playlist.playlist.browseId?.let { YouTube.addToPlaylist(it, mediaMetadata.id) } - } listOf(mediaMetadata.id) }, onGetSongIds = { listOf(mediaMetadata.id) }, diff --git a/app/src/main/kotlin/com/metrolist/music/ui/menu/SelectionSongsMenu.kt b/app/src/main/kotlin/com/metrolist/music/ui/menu/SelectionSongsMenu.kt index 31aedabbd4..766dbaf4b1 100644 --- a/app/src/main/kotlin/com/metrolist/music/ui/menu/SelectionSongsMenu.kt +++ b/app/src/main/kotlin/com/metrolist/music/ui/menu/SelectionSongsMenu.kt @@ -139,14 +139,7 @@ fun SelectionSongMenu( AddToPlaylistDialog( isVisible = showChoosePlaylistDialog, - onGetSong = { playlist -> - coroutineScope.launch(Dispatchers.IO) { - songSelection.forEach { song -> - playlist.playlist.browseId?.let { browseId -> - YouTube.addToPlaylist(browseId, song.id) - } - } - } + onGetSong = { songSelection.map { it.id } }, onGetSongIds = { songSelection.map { it.id } }, diff --git a/app/src/main/kotlin/com/metrolist/music/ui/menu/SongMenu.kt b/app/src/main/kotlin/com/metrolist/music/ui/menu/SongMenu.kt index 63f7fd4136..cdc43b7e85 100644 --- a/app/src/main/kotlin/com/metrolist/music/ui/menu/SongMenu.kt +++ b/app/src/main/kotlin/com/metrolist/music/ui/menu/SongMenu.kt @@ -237,11 +237,9 @@ fun SongMenu( AddToPlaylistDialog( isVisible = showChoosePlaylistDialog, - onGetSong = { playlist -> - coroutineScope.launch(Dispatchers.IO) { - playlist.playlist.browseId?.let { browseId -> - YouTube.addToPlaylist(browseId, song.id) - } + onGetSong = { + database.withTransaction { + insert(song.toMediaMetadata()) } listOf(song.id) }, diff --git a/app/src/main/kotlin/com/metrolist/music/ui/menu/YouTubeAlbumMenu.kt b/app/src/main/kotlin/com/metrolist/music/ui/menu/YouTubeAlbumMenu.kt index beb8db3719..5ec2e8b9b6 100644 --- a/app/src/main/kotlin/com/metrolist/music/ui/menu/YouTubeAlbumMenu.kt +++ b/app/src/main/kotlin/com/metrolist/music/ui/menu/YouTubeAlbumMenu.kt @@ -153,14 +153,7 @@ fun YouTubeAlbumMenu( AddToPlaylistDialog( isVisible = showChoosePlaylistDialog, - onGetSong = { playlist -> - coroutineScope.launch(Dispatchers.IO) { - playlist.playlist.browseId?.let { playlistId -> - album?.album?.playlistId?.let { addPlaylistId -> - YouTube.addPlaylistToPlaylist(playlistId, addPlaylistId) - } - } - } + onGetSong = { album?.songs?.map { it.id }.orEmpty() }, onGetSongIds = { album?.songs?.map { it.id }.orEmpty() }, diff --git a/app/src/main/kotlin/com/metrolist/music/ui/menu/YouTubePlaylistMenu.kt b/app/src/main/kotlin/com/metrolist/music/ui/menu/YouTubePlaylistMenu.kt index eb8b6bbf1f..3cc5831897 100644 --- a/app/src/main/kotlin/com/metrolist/music/ui/menu/YouTubePlaylistMenu.kt +++ b/app/src/main/kotlin/com/metrolist/music/ui/menu/YouTubePlaylistMenu.kt @@ -126,12 +126,12 @@ fun YouTubePlaylistMenu( AddToPlaylistDialog( isVisible = showChoosePlaylistDialog, - onGetSong = { targetPlaylist -> + onGetSong = { val allSongs = songs .ifEmpty { YouTube - .playlist(targetPlaylist.id) + .playlist(playlist.id) .completed() .getOrNull() ?.songs @@ -142,11 +142,6 @@ fun YouTubePlaylistMenu( database.withTransaction { allSongs.forEach(::insert) } - coroutineScope.launch(Dispatchers.IO) { - targetPlaylist.playlist.browseId?.let { playlistId -> - YouTube.addPlaylistToPlaylist(playlistId, targetPlaylist.id) - } - } allSongs.map { it.id } }, onGetSongIds = { diff --git a/app/src/main/kotlin/com/metrolist/music/ui/menu/YouTubeSelectionSongMenu.kt b/app/src/main/kotlin/com/metrolist/music/ui/menu/YouTubeSelectionSongMenu.kt index 2ebe03348e..329a51c9d9 100644 --- a/app/src/main/kotlin/com/metrolist/music/ui/menu/YouTubeSelectionSongMenu.kt +++ b/app/src/main/kotlin/com/metrolist/music/ui/menu/YouTubeSelectionSongMenu.kt @@ -26,7 +26,6 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue -import androidx.compose.runtime.toMutableStateList import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource @@ -122,52 +121,16 @@ fun YouTubeSelectionSongMenu( } } - AddToPlaylistDialogOnline( + val selectedSongs = songSelection.map { it.toMediaMetadata() } + + AddToPlaylistDialog( isVisible = showChoosePlaylistDialog, - songs = - remember { - songSelection - .map { song -> - // Convert SongItem to Song entity - val metadata = song.toMediaMetadata() - com.metrolist.music.db.entities.Song( - song = - com.metrolist.music.db.entities.SongEntity( - id = metadata.id, - title = metadata.title, - duration = metadata.duration, - thumbnailUrl = metadata.thumbnailUrl, - albumId = metadata.album?.id, - albumName = metadata.album?.title, - liked = metadata.liked, - totalPlayTime = 0, - inLibrary = metadata.inLibrary, - isLocal = false, - libraryAddToken = metadata.libraryAddToken, - libraryRemoveToken = metadata.libraryRemoveToken, - ), - artists = - metadata.artists.map { artist -> - com.metrolist.music.db.entities.ArtistEntity( - id = artist.id ?: "", - name = artist.name, - ) - }, - album = - metadata.album?.let { album -> - com.metrolist.music.db.entities.AlbumEntity( - id = album.id, - title = album.title, - thumbnailUrl = metadata.thumbnailUrl, // Use song's thumbnail as album thumbnail - songCount = 0, - duration = 0, - ) - }, - ) - }.toMutableStateList() - }, - onProgressStart = { }, - onPercentageChange = { }, + onGetSong = { + database.withTransaction { + selectedSongs.forEach(::insert) + } + selectedSongs.map { it.id } + }, onDismiss = { showChoosePlaylistDialog = false }, diff --git a/app/src/main/kotlin/com/metrolist/music/ui/menu/YouTubeSongMenu.kt b/app/src/main/kotlin/com/metrolist/music/ui/menu/YouTubeSongMenu.kt index a71d7adad7..57d0bc0227 100644 --- a/app/src/main/kotlin/com/metrolist/music/ui/menu/YouTubeSongMenu.kt +++ b/app/src/main/kotlin/com/metrolist/music/ui/menu/YouTubeSongMenu.kt @@ -123,15 +123,10 @@ fun YouTubeSongMenu( AddToPlaylistDialog( isVisible = showChoosePlaylistDialog, - onGetSong = { playlist -> + onGetSong = { database.withTransaction { insert(song.toMediaMetadata()) } - coroutineScope.launch(Dispatchers.IO) { - playlist.playlist.browseId?.let { browseId -> - YouTube.addToPlaylist(browseId, song.id) - } - } listOf(song.id) }, onGetSongIds = { listOf(song.id) }, diff --git a/app/src/main/kotlin/com/metrolist/music/ui/player/MiniPlayer.kt b/app/src/main/kotlin/com/metrolist/music/ui/player/MiniPlayer.kt index b594eb1b4b..bdd9018099 100644 --- a/app/src/main/kotlin/com/metrolist/music/ui/player/MiniPlayer.kt +++ b/app/src/main/kotlin/com/metrolist/music/ui/player/MiniPlayer.kt @@ -179,6 +179,7 @@ private fun NewMiniPlayer( onClick: () -> Unit = {}, ) { val playerConnection = LocalPlayerConnection.current ?: return + val database = LocalDatabase.current val menuState = LocalMenuState.current // Theme settings - these rarely change @@ -486,7 +487,12 @@ private fun NewMiniPlayer( menuState.show { AddToPlaylistDialog( isVisible = true, - onGetSong = { listOf(metadata.id) }, + onGetSong = { + database.withTransaction { + insert(metadata) + } + listOf(metadata.id) + }, onDismiss = menuState::dismiss, ) } diff --git a/app/src/main/kotlin/com/metrolist/music/viewmodels/PlaylistsViewModel.kt b/app/src/main/kotlin/com/metrolist/music/viewmodels/PlaylistsViewModel.kt index f682f95ad7..2bb0155a48 100644 --- a/app/src/main/kotlin/com/metrolist/music/viewmodels/PlaylistsViewModel.kt +++ b/app/src/main/kotlin/com/metrolist/music/viewmodels/PlaylistsViewModel.kt @@ -14,12 +14,16 @@ import com.metrolist.music.constants.AddToPlaylistSortDescendingKey import com.metrolist.music.constants.AddToPlaylistSortTypeKey import com.metrolist.music.constants.PlaylistSortType import com.metrolist.music.db.MusicDatabase +import com.metrolist.music.db.entities.Playlist import com.metrolist.music.extensions.toEnum +import com.metrolist.innertube.YouTube import com.metrolist.music.utils.SyncUtils import com.metrolist.music.utils.dataStore import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flatMapLatest @@ -32,7 +36,7 @@ class PlaylistsViewModel @Inject constructor( @ApplicationContext context: Context, - database: MusicDatabase, + private val database: MusicDatabase, private val syncUtils: SyncUtils, ) : ViewModel() { val allPlaylists = @@ -49,4 +53,43 @@ constructor( suspend fun sync() { syncUtils.syncSavedPlaylists() } + + fun addSongsAndSync( + targetPlaylist: Playlist, + ids: List, + multiSelectParams: String? = null, + ) { + viewModelScope.launch(Dispatchers.IO) { + val localIds = ids.distinct() + database.addSongsToPlaylist(targetPlaylist, localIds.map { it to null }, prepend = true) + val browseId = targetPlaylist.playlist.browseId ?: return@launch + val remoteIds = + if (localIds.size > 1 && multiSelectParams != null) { + YouTube.getMultiSelectCommand(localIds, multiSelectParams) + .getOrNull() + ?.multiSelectCommand + ?.addToPlaylistEndpoint + ?.videoIds + .orEmpty() + .ifEmpty { localIds } + } else { + localIds + } + + remoteIds.forEach { songId -> + syncUtils.registerPendingAdd(browseId, songId) + } + try { + if (remoteIds.size == 1) { + YouTube.addToPlaylist(browseId, remoteIds.first()) + } else { + YouTube.addToPlaylist(browseId, remoteIds) + } + } finally { + remoteIds.forEach { songId -> + syncUtils.unregisterPendingAdd(browseId, songId) + } + } + } + } } diff --git a/innertube/src/main/kotlin/com/metrolist/innertube/InnerTube.kt b/innertube/src/main/kotlin/com/metrolist/innertube/InnerTube.kt index 4555937702..22ae31f07f 100644 --- a/innertube/src/main/kotlin/com/metrolist/innertube/InnerTube.kt +++ b/innertube/src/main/kotlin/com/metrolist/innertube/InnerTube.kt @@ -508,6 +508,42 @@ class InnerTube { } } + suspend fun addToPlaylist( + client: YouTubeClient, + playlistId: String, + videoIds: List, + ) = withRetry { + httpClient.post("browse/edit_playlist") { + ytClient(client, setLogin = true) + setBody( + EditPlaylistBody( + context = client.toContext(locale, visitorData, dataSyncId), + playlistId = playlistId.removePrefix("VL"), + actions = videoIds.map { + Action.AddVideoAction(addedVideoId = it) + } + ) + ) + } + } + + suspend fun getMultiSelectCommand( + client: YouTubeClient, + selectedItems: List, + multiSelectParams: String? = null, + ) = withRetry { + httpClient.post("get_multi_select_command") { + ytClient(client, setLogin = true) + setBody( + GetMultiSelectCommandBody( + context = client.toContext(locale, visitorData, dataSyncId), + selectedItems = selectedItems, + multiSelectParams = multiSelectParams, + ) + ) + } + } + suspend fun addPlaylistToPlaylist( client: YouTubeClient, playlistId: String, diff --git a/innertube/src/main/kotlin/com/metrolist/innertube/YouTube.kt b/innertube/src/main/kotlin/com/metrolist/innertube/YouTube.kt index f0ab99b8b2..7dbbb34ec7 100644 --- a/innertube/src/main/kotlin/com/metrolist/innertube/YouTube.kt +++ b/innertube/src/main/kotlin/com/metrolist/innertube/YouTube.kt @@ -36,6 +36,7 @@ import com.metrolist.innertube.models.response.BrowseResponse import com.metrolist.innertube.models.response.CreatePlaylistResponse import com.metrolist.innertube.models.response.EditPlaylistResponse import com.metrolist.innertube.models.response.FeedbackResponse +import com.metrolist.innertube.models.response.GetMultiSelectCommandResponse import com.metrolist.innertube.models.response.GetQueueResponse import com.metrolist.innertube.models.response.GetSearchSuggestionsResponse import com.metrolist.innertube.models.response.GetTranscriptResponse @@ -2589,6 +2590,21 @@ object YouTube { innerTube.addToPlaylist(WEB_REMIX, playlistId, videoId) } + suspend fun addToPlaylist( + playlistId: String, + videoIds: List, + ) = runCatching { + innerTube.addToPlaylist(WEB_REMIX, playlistId, videoIds) + } + + suspend fun getMultiSelectCommand( + selectedItems: List, + multiSelectParams: String? = null, + ) = runCatching { + val response = innerTube.getMultiSelectCommand(WEB_REMIX, selectedItems, multiSelectParams).body() + response + } + suspend fun addPlaylistToPlaylist( playlistId: String, addPlaylistId: String, diff --git a/innertube/src/main/kotlin/com/metrolist/innertube/models/body/EditPlaylistBody.kt b/innertube/src/main/kotlin/com/metrolist/innertube/models/body/EditPlaylistBody.kt index de7491a48c..8b4d51ecde 100644 --- a/innertube/src/main/kotlin/com/metrolist/innertube/models/body/EditPlaylistBody.kt +++ b/innertube/src/main/kotlin/com/metrolist/innertube/models/body/EditPlaylistBody.kt @@ -15,7 +15,8 @@ sealed class Action { @Serializable data class AddVideoAction( val action: String = "ACTION_ADD_VIDEO", - val addedVideoId: String + val addedVideoId: String, + val dedupeOption: String = "DEDUPE_OPTION_CHECK" ) : Action() @Serializable diff --git a/innertube/src/main/kotlin/com/metrolist/innertube/models/body/GetMultiSelectCommandBody.kt b/innertube/src/main/kotlin/com/metrolist/innertube/models/body/GetMultiSelectCommandBody.kt new file mode 100644 index 0000000000..549de8a03d --- /dev/null +++ b/innertube/src/main/kotlin/com/metrolist/innertube/models/body/GetMultiSelectCommandBody.kt @@ -0,0 +1,11 @@ +package com.metrolist.innertube.models.body + +import com.metrolist.innertube.models.Context +import kotlinx.serialization.Serializable + +@Serializable +data class GetMultiSelectCommandBody( + val context: Context, + val selectedItems: List, + val multiSelectParams: String? = null, +) diff --git a/innertube/src/main/kotlin/com/metrolist/innertube/models/response/GetMultiSelectCommandResponse.kt b/innertube/src/main/kotlin/com/metrolist/innertube/models/response/GetMultiSelectCommandResponse.kt new file mode 100644 index 0000000000..cd67ebf963 --- /dev/null +++ b/innertube/src/main/kotlin/com/metrolist/innertube/models/response/GetMultiSelectCommandResponse.kt @@ -0,0 +1,18 @@ +package com.metrolist.innertube.models.response + +import kotlinx.serialization.Serializable + +@Serializable +data class GetMultiSelectCommandResponse( + val multiSelectCommand: MultiSelectCommand? = null, +) { + @Serializable + data class MultiSelectCommand( + val addToPlaylistEndpoint: AddToPlaylistEndpoint? = null, + ) { + @Serializable + data class AddToPlaylistEndpoint( + val videoIds: List = emptyList(), + ) + } +}