Implement various queue functions

This commit is contained in:
Damontecres 2026-03-01 10:16:10 -05:00
parent ea243975ad
commit 7a869d9f47
No known key found for this signature in database
5 changed files with 202 additions and 41 deletions

View file

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

View file

@ -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<AlbumViewModel, AlbumViewModel.Factory>(
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<UUID>>(Optional.absent()) }
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
var moreDialog by remember { mutableStateOf<DialogParams?>(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

View file

@ -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<ArtistViewModel, ArtistViewModel.Factory>(
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<UUID>>(Optional.absent()) }
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
var moreDialog by remember { mutableStateOf<DialogParams?>(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

View file

@ -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<GetItemsRequest> {
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<GetItemsRequest> {
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()
}

View file

@ -727,5 +727,6 @@
<string name="album_artist">Album artist</string>
<string name="album">Album</string>
<string name="popular_songs">Popular songs</string>
<string name="play_next">Play next</string>
</resources>