diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt index feaa2aae..a0b7bb75 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt @@ -209,6 +209,12 @@ class MusicService player.moveMediaItem(index, if (direction == MoveDirection.UP) index - 1 else index + 1) updateQueueSize() } + + suspend fun playNext(song: BaseItem) { + val mediaItem = convert(song) + onMain { player.addMediaItem(state.value.currentIndex + 1, mediaItem) } + updateQueueSize() + } } @Stable diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/AlbumDetailsPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/AlbumDetailsPage.kt index 88b4c58a..c09c022e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/AlbumDetailsPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/AlbumDetailsPage.kt @@ -17,6 +17,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -49,20 +50,23 @@ import com.github.damontecres.wholphin.services.MediaReportService import com.github.damontecres.wholphin.services.MusicService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.UserPreferencesService -import com.github.damontecres.wholphin.ui.DefaultItemFields import com.github.damontecres.wholphin.ui.components.DialogParams import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.GenreText import com.github.damontecres.wholphin.ui.components.LoadingPage +import com.github.damontecres.wholphin.ui.components.Optional import com.github.damontecres.wholphin.ui.components.OverviewText import com.github.damontecres.wholphin.ui.components.QuickDetails +import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel +import com.github.damontecres.wholphin.ui.detail.PlaylistDialog +import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState +import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.letNotEmpty import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.ExceptionHandler -import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.LoadingState import dagger.assisted.Assisted import dagger.assisted.AssistedFactory @@ -80,8 +84,7 @@ import org.jellyfin.sdk.api.client.extensions.itemsApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.ImageType -import org.jellyfin.sdk.model.api.ItemSortBy -import org.jellyfin.sdk.model.api.request.GetItemsRequest +import org.jellyfin.sdk.model.api.MediaType import timber.log.Timber import java.util.UUID @@ -121,22 +124,7 @@ class AlbumViewModel .content .let { BaseItem(it, false) } } - val songsDeferred = - async { - val request = - GetItemsRequest( - parentId = itemId, - includeItemTypes = listOf(BaseItemKind.AUDIO), - fields = DefaultItemFields, - sortBy = - listOf( - ItemSortBy.PARENT_INDEX_NUMBER, - ItemSortBy.INDEX_NUMBER, - ItemSortBy.SORT_NAME, - ), - ) - ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope).init() - } + val songsDeferred = async { getPagerForAlbum(api, itemId) } val album = itemDeferred.await() val songs = songsDeferred.await() val imageUrl = imageUrlService.getItemImageUrl(album, ImageType.PRIMARY) @@ -194,18 +182,22 @@ class AlbumViewModel } } + fun playNext(song: BaseItem) { + viewModelScope.launchDefault { + musicService.playNext(song) + } + } + fun addToQueue( - itemId: UUID, + item: BaseItem, index: Int, ) { viewModelScope.launchIO { val songs = state.value.songs as ApiRequestPager<*> - if (itemId == this@AlbumViewModel.itemId) { + if (item.id == this@AlbumViewModel.itemId) { musicService.addAllToQueue(songs, 0) - } else { - songs.getBlocking(index)?.let { - musicService.addToQueue(it) - } + } else if (item.type == BaseItemKind.AUDIO) { + musicService.addToQueue(item) } } } @@ -238,21 +230,28 @@ fun AlbumDetailsPage( hiltViewModel( creationCallback = { it.create(itemId) }, ), + playlistViewModel: AddPlaylistViewModel = hiltViewModel(), ) { val context = LocalContext.current val scope = rememberCoroutineScope() val state by viewModel.state.collectAsState() val currentMusic by viewModel.currentMusic.collectAsState() + var showPlaylistDialog by remember { mutableStateOf>(Optional.absent()) } + val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) var moreDialog by remember { mutableStateOf(null) } val moreDialogActions = remember { MusicMoreDialogActions( onNavigate = { viewModel.navigationManager.navigateTo(it) }, onClickPlay = { index, _ -> viewModel.play(false, index) }, - onClickAddToQueue = { index, itemId -> viewModel.addToQueue(itemId, index) }, + onClickPlayNext = { _, song -> viewModel.playNext(song) }, + onClickAddToQueue = { index, item -> viewModel.addToQueue(item, index) }, onClickFavorite = { itemId, favorite -> viewModel.setFavorite(itemId, favorite) }, - onClickAddPlaylist = {}, + onClickAddPlaylist = { itemId -> + playlistViewModel.loadPlaylists(MediaType.AUDIO) + showPlaylistDialog.makePresent(itemId) + }, ) } @@ -377,6 +376,23 @@ fun AlbumDetailsPage( waitToLoad = params.fromLongClick, ) } + showPlaylistDialog.compose { itemId -> + PlaylistDialog( + title = stringResource(R.string.add_to_playlist), + state = playlistState, + onDismissRequest = { showPlaylistDialog.makeAbsent() }, + onClick = { + playlistViewModel.addToPlaylist(it.id, itemId) + showPlaylistDialog.makeAbsent() + }, + createEnabled = true, + onCreatePlaylist = { + playlistViewModel.createPlaylistAndAddItem(it, itemId) + showPlaylistDialog.makeAbsent() + }, + elevation = 3.dp, + ) + } } @Composable diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/ArtistDetailsPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/ArtistDetailsPage.kt index bd67bbcd..601d1f0d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/ArtistDetailsPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/ArtistDetailsPage.kt @@ -18,6 +18,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -59,14 +60,19 @@ import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.GenreText import com.github.damontecres.wholphin.ui.components.LoadingPage +import com.github.damontecres.wholphin.ui.components.Optional import com.github.damontecres.wholphin.ui.components.OverviewText import com.github.damontecres.wholphin.ui.components.QuickDetails +import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel +import com.github.damontecres.wholphin.ui.detail.PlaylistDialog +import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.letNotEmpty import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.toBaseItems import com.github.damontecres.wholphin.util.ApiRequestPager +import com.github.damontecres.wholphin.util.BlockingList import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.LoadingState @@ -86,6 +92,7 @@ import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.ItemSortBy +import org.jellyfin.sdk.model.api.MediaType import org.jellyfin.sdk.model.api.SortOrder import org.jellyfin.sdk.model.api.request.GetItemsRequest import timber.log.Timber @@ -205,7 +212,7 @@ class ArtistViewModel _state.update { it.copy(artist = artist) } } - fun play(shuffled: Boolean) { + fun playArtist(shuffled: Boolean) { viewModelScope.launchIO { Timber.v("Playing artist %s", itemId) val request = @@ -247,9 +254,58 @@ class ArtistViewModel } } - fun playSong(song: BaseItem) { + fun play(item: BaseItem) { + viewModelScope.launchIO { + Timber.v("Playing %s %s", item.type, item.id) + when (item.type) { + BaseItemKind.AUDIO -> { + musicService.setQueue(listOf(item), false) + } + + BaseItemKind.MUSIC_ALBUM -> { + val pager = getPagerForAlbum(api, item.id) + musicService.setQueue(pager, 0, false) + } + + BaseItemKind.MUSIC_ARTIST -> { + val pager = getPagerForArtist(api, item.id) + musicService.setQueue(pager, 0, false) + } + + else -> {} + } + } + } + + fun playNext(song: BaseItem) { viewModelScope.launchDefault { - musicService.setQueue(listOf(song), false) + musicService.playNext(song) + } + } + + fun addToQueue( + item: BaseItem, + index: Int, + ) { + viewModelScope.launchIO { + Timber.v("addToQueue %s %s", item.type, item.id) + when (item.type) { + BaseItemKind.AUDIO -> { + musicService.addAllToQueue(BlockingList.of(listOf(item)), 0) + } + + BaseItemKind.MUSIC_ALBUM -> { + val pager = getPagerForAlbum(api, itemId) + musicService.addAllToQueue(pager, 0) + } + + BaseItemKind.MUSIC_ARTIST -> { + val pager = getPagerForArtist(api, item.id) + musicService.addAllToQueue(pager, 0) + } + + else -> {} + } } } } @@ -275,21 +331,28 @@ fun ArtistDetailsPage( hiltViewModel( creationCallback = { it.create(itemId) }, ), + playlistViewModel: AddPlaylistViewModel = hiltViewModel(), ) { val context = LocalContext.current val scope = rememberCoroutineScope() val state by viewModel.state.collectAsState() val currentMusic by viewModel.currentMusic.collectAsState() + var showPlaylistDialog by remember { mutableStateOf>(Optional.absent()) } + val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) var moreDialog by remember { mutableStateOf(null) } val moreDialogActions = remember { MusicMoreDialogActions( onNavigate = { viewModel.navigationManager.navigateTo(it) }, - onClickPlay = { index, song -> TODO() }, - onClickAddToQueue = { index, itemId -> viewModel.addToQueue(itemId, index) }, + onClickPlay = { index, item -> viewModel.play(item) }, + onClickPlayNext = { _, item -> viewModel.playNext(item) }, + onClickAddToQueue = { index, item -> viewModel.addToQueue(item, index) }, onClickFavorite = { itemId, favorite -> viewModel.setFavorite(itemId, favorite) }, - onClickAddPlaylist = {}, + onClickAddPlaylist = { itemId -> + playlistViewModel.loadPlaylists(MediaType.AUDIO) + showPlaylistDialog.makePresent(itemId) + }, ) } @@ -340,7 +403,7 @@ fun ArtistDetailsPage( actions = remember { MusicButtonActions( - onClickPlay = { viewModel.play(it) }, + onClickPlay = { viewModel.playArtist(it) }, onClickInstantMix = viewModel::startInstantMix, onClickFavorite = { viewModel.setFavorite( @@ -385,7 +448,7 @@ fun ArtistDetailsPage( itemsIndexed(state.topSongs) { index, song -> SongListItem( song = song, - onClick = { song?.let { viewModel.playSong(it) } }, + onClick = { song?.let { viewModel.play(it) } }, onLongClick = { if (song != null) { moreDialog = @@ -444,6 +507,23 @@ fun ArtistDetailsPage( waitToLoad = params.fromLongClick, ) } + showPlaylistDialog.compose { itemId -> + PlaylistDialog( + title = stringResource(R.string.add_to_playlist), + state = playlistState, + onDismissRequest = { showPlaylistDialog.makeAbsent() }, + onClick = { + playlistViewModel.addToPlaylist(it.id, itemId) + showPlaylistDialog.makeAbsent() + }, + createEnabled = true, + onCreatePlaylist = { + playlistViewModel.createPlaylistAndAddItem(it, itemId) + showPlaylistDialog.makeAbsent() + }, + elevation = 3.dp, + ) + } } @Composable diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/Utils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/Utils.kt index d409ed67..1f8377b7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/Utils.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/Utils.kt @@ -6,17 +6,26 @@ import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.ArrowForward import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.ui.graphics.Color +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.ui.DefaultItemFields import com.github.damontecres.wholphin.ui.components.DialogItem import com.github.damontecres.wholphin.ui.nav.Destination -import org.jellyfin.sdk.model.UUID +import com.github.damontecres.wholphin.util.ApiRequestPager +import com.github.damontecres.wholphin.util.GetItemsRequestHandler +import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.ItemSortBy +import org.jellyfin.sdk.model.api.request.GetItemsRequest +import java.util.UUID data class MusicMoreDialogActions( val onNavigate: (Destination) -> Unit, - val onClickPlay: (Int, UUID) -> Unit, - val onClickAddToQueue: (Int, UUID) -> Unit, + val onClickPlay: (Int, BaseItem) -> Unit, + val onClickPlayNext: (Int, BaseItem) -> Unit, + val onClickAddToQueue: (Int, BaseItem) -> Unit, val onClickFavorite: (UUID, Boolean) -> Unit, val onClickAddPlaylist: (UUID) -> Unit, val onClickGoToAlbum: (UUID) -> Unit = { @@ -40,7 +49,16 @@ fun buildMoreDialogForMusic( Icons.Default.PlayArrow, iconColor = Color.Green.copy(alpha = .8f), ) { - actions.onClickPlay(index, item.id) + actions.onClickPlay(index, item) + }, + ) + add( + DialogItem( + context.getString(R.string.play_next), + Icons.Default.PlayArrow, + iconColor = Color.Green.copy(alpha = .8f), + ) { + actions.onClickPlayNext(index, item) }, ) add( @@ -48,7 +66,7 @@ fun buildMoreDialogForMusic( context.getString(R.string.add_to_queue), Icons.Default.Add, ) { - actions.onClickAddToQueue(index, item.id) + actions.onClickAddToQueue(index, item) }, ) add( @@ -93,3 +111,43 @@ fun buildMoreDialogForMusic( ) } } + +suspend fun ViewModel.getPagerForAlbum( + api: ApiClient, + albumId: UUID, +): ApiRequestPager { + val request = + GetItemsRequest( + parentId = albumId, + includeItemTypes = listOf(BaseItemKind.AUDIO), + fields = DefaultItemFields, + sortBy = + listOf( + ItemSortBy.PARENT_INDEX_NUMBER, + ItemSortBy.INDEX_NUMBER, + ItemSortBy.SORT_NAME, + ), + ) + return ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope).init() +} + +suspend fun ViewModel.getPagerForArtist( + api: ApiClient, + artistId: UUID, +): ApiRequestPager { + val request = + GetItemsRequest( + parentId = artistId, + recursive = true, + includeItemTypes = listOf(BaseItemKind.AUDIO), + fields = DefaultItemFields, + // TODO better sort + sortBy = + listOf( + ItemSortBy.PARENT_INDEX_NUMBER, + ItemSortBy.INDEX_NUMBER, + ItemSortBy.SORT_NAME, + ), + ) + return ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope).init() +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1014f59c..831d168c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -727,5 +727,6 @@ Album artist Album Popular songs + Play next