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 1de02176..dee8b027 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 @@ -15,6 +15,7 @@ import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient import com.github.damontecres.wholphin.ui.DefaultItemFields import com.github.damontecres.wholphin.ui.toServerString +import com.github.damontecres.wholphin.util.BlockingList import com.github.damontecres.wholphin.util.profile.Codec import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers @@ -75,6 +76,33 @@ class MusicService setQueue(items, false) } + /** + * Replace the queue with the given list and starting playing the song as startIndex as soon as its ready + * + * Fetches each item in a blocking way and adds to the queue + */ + suspend fun setQueue( + items: BlockingList, + startIndex: Int, + shuffled: Boolean, + ) = withContext(Dispatchers.IO) { + Timber.d("setQueue: %s items, startIndex=%s, shuffled=%s", items.size, startIndex, shuffled) + withContext(Dispatchers.Main) { + player.setMediaItems(emptyList()) + player.shuffleModeEnabled = shuffled + player.play() + } + (startIndex..items.lastIndex).forEach { + val item = items.getBlocking(it) + if (item != null && item.type == BaseItemKind.AUDIO) { + val mediaItem = convert(item) + withContext(Dispatchers.Main) { + player.addMediaItem(mediaItem) + } + } + } + } + suspend fun setQueue( items: List, shuffled: Boolean, @@ -136,6 +164,8 @@ data class MusicServiceState( companion object { val EMPTY = MusicServiceState(emptyList(), 0, false) } + + val currentItemId: UUID? get() = if (isPlaying) queue.getOrNull(currentIndex)?.id else null } /** 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 d5b3ce4d..9aca6b58 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 @@ -1,35 +1,30 @@ package com.github.damontecres.wholphin.ui.detail.music import android.content.Context -import androidx.compose.foundation.focusGroup import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.relocation.BringIntoViewRequester import androidx.compose.foundation.relocation.bringIntoViewRequester -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.AccountCircle -import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState 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.focus.FocusRequester -import androidx.compose.ui.focus.FocusState import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -52,9 +47,9 @@ 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.ExpandableFaButton -import com.github.damontecres.wholphin.ui.components.ExpandablePlayButton import com.github.damontecres.wholphin.ui.components.GenreText import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.OverviewText @@ -85,7 +80,6 @@ import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.request.GetItemsRequest import timber.log.Timber import java.util.UUID -import kotlin.time.Duration @HiltViewModel(assistedFactory = AlbumViewModel.Factory::class) class AlbumViewModel @@ -93,7 +87,7 @@ class AlbumViewModel constructor( private val api: ApiClient, @param:ApplicationContext private val context: Context, - private val navigationManager: NavigationManager, + val navigationManager: NavigationManager, val serverRepository: ServerRepository, val mediaReportService: MediaReportService, private val favoriteWatchManager: FavoriteWatchManager, @@ -111,6 +105,8 @@ class AlbumViewModel private val _state = MutableStateFlow(AlbumState.EMPTY) val state: StateFlow = _state + val currentMusic = musicService.state + init { viewModelScope.launchIO { try { @@ -169,16 +165,18 @@ class AlbumViewModel } } - fun setFavorite(favorite: Boolean) = - viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { - favoriteWatchManager.setFavorite(itemId, favorite) - val album = - api.userLibraryApi - .getItem(itemId = itemId) - .content - .let { BaseItem(it, false) } - _state.update { it.copy(album = album) } - } + fun setFavorite( + itemId: UUID, + favorite: Boolean, + ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { + favoriteWatchManager.setFavorite(itemId, favorite) + val album = + api.userLibraryApi + .getItem(itemId = itemId) + .content + .let { BaseItem(it, false) } + _state.update { it.copy(album = album) } + } fun play( shuffled: Boolean, @@ -187,12 +185,27 @@ class AlbumViewModel viewModelScope.launchIO { Timber.v("Playing album %s from %s", itemId, startIndex) val songs = state.value.songs as ApiRequestPager<*> - val songsToAdd = - (startIndex..songs.lastIndex) - .mapNotNull { idx -> - songs.getBlocking(idx) + musicService.setQueue(songs, startIndex, shuffled) + } + } + + fun addToQueue( + itemId: UUID, + index: Int, + ) { + viewModelScope.launchIO { + val songs = state.value.songs as ApiRequestPager<*> + if (itemId == this@AlbumViewModel.itemId) { + songs.indices.forEach { + songs.getBlocking(it)?.let { + musicService.addToQueue(it) } - musicService.setQueue(songsToAdd, shuffled) + } + } else { + songs.getBlocking(index)?.let { + musicService.addToQueue(it) + } + } } } @@ -225,8 +238,22 @@ fun AlbumDetailsPage( creationCallback = { it.create(itemId) }, ), ) { + val context = LocalContext.current val scope = rememberCoroutineScope() val state by viewModel.state.collectAsState() + val currentMusic by viewModel.currentMusic.collectAsState() + + 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) }, + onClickFavorite = { itemId, favorite -> viewModel.setFavorite(itemId, favorite) }, + onClickAddPlaylist = {}, + ) + } when (val loading = state.loading) { is LoadingState.Error -> { @@ -271,9 +298,25 @@ fun AlbumDetailsPage( MusicButtonActions( onClickPlay = { viewModel.play(it, 0) }, onClickInstantMix = viewModel::startInstantMix, - onClickFavorite = { viewModel.setFavorite(!album.favorite) }, + onClickFavorite = { + viewModel.setFavorite( + album.id, + !album.favorite, + ) + }, onClickMore = { - // TODO + moreDialog = + DialogParams( + fromLongClick = false, + title = album.name + " (${album.data.productionYear ?: ""})", + items = + buildMoreDialogForMusic( + context = context, + actions = moreDialogActions, + item = album, + index = 0, + ), + ) }, ) }, @@ -295,16 +338,41 @@ fun AlbumDetailsPage( SongListItem( song = song, onClick = { viewModel.play(false, index) }, - onClickAddToQueue = {}, - onClickAddToPlaylist = {}, + onLongClick = { + if (song != null) { + moreDialog = + DialogParams( + fromLongClick = true, + title = song.name ?: "", + items = + buildMoreDialogForMusic( + context = context, + actions = moreDialogActions, + item = song, + index = index, + ), + ) + } + }, modifier = Modifier.padding(horizontal = 16.dp), showArtist = false, + isPlaying = song != null && currentMusic.currentItemId == song.id, ) } } } } } + moreDialog?.let { params -> + DialogPopup( + showDialog = true, + title = params.title, + dialogItems = params.items, + onDismissRequest = { moreDialog = null }, + dismissOnClick = true, + waitToLoad = params.fromLongClick, + ) + } } @Composable @@ -388,57 +456,3 @@ fun AlbumHeader( } } } - -@Composable -fun AlbumButtons( - onClickPlay: (Boolean) -> Unit, - onClickAddToPlaylist: () -> Unit, - onClickGoToArtist: () -> Unit, - onClickMore: () -> Unit, - buttonOnFocusChanged: (FocusState) -> Unit, - modifier: Modifier = Modifier, -) { - val firstFocus = remember { FocusRequester() } - LazyRow( - horizontalArrangement = Arrangement.spacedBy(16.dp), - contentPadding = PaddingValues(8.dp), - modifier = - modifier - .focusGroup() - .focusRestorer(firstFocus), - ) { - item { - ExpandablePlayButton( - title = R.string.play, - resume = Duration.ZERO, - icon = Icons.Default.PlayArrow, - onClick = { onClickPlay.invoke(false) }, - modifier = - Modifier - .onFocusChanged(buttonOnFocusChanged) - .focusRequester(firstFocus), - ) - } - item { - ExpandableFaButton( - title = R.string.shuffle, - iconStringRes = R.string.fa_shuffle, - onClick = { onClickPlay.invoke(true) }, - modifier = - Modifier - .onFocusChanged(buttonOnFocusChanged), - ) - } - item { - ExpandablePlayButton( - title = R.string.go_to_artist, - resume = Duration.ZERO, - icon = Icons.Default.AccountCircle, - onClick = { onClickGoToArtist.invoke() }, - modifier = - Modifier - .onFocusChanged(buttonOnFocusChanged), - ) - } - } -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingPage.kt index ad9533fb..c3185310 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingPage.kt @@ -113,8 +113,7 @@ fun NowPlayingPage( onClick = { player.seekTo(index, 0L) }, - onClickAddToQueue = {}, - onClickAddToPlaylist = {}, + onLongClick = {}, modifier = Modifier, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/SongListDisplay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/SongListDisplay.kt index 515789af..8eba55c4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/SongListDisplay.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/SongListDisplay.kt @@ -1,22 +1,27 @@ package com.github.damontecres.wholphin.ui.detail.music +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import androidx.tv.material3.Button -import androidx.tv.material3.ButtonDefaults +import androidx.tv.material3.Icon import androidx.tv.material3.ListItem import androidx.tv.material3.ListItemDefaults import androidx.tv.material3.Text import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.ui.PreviewTvSpec +import com.github.damontecres.wholphin.ui.enableMarquee import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.letNotEmpty import com.github.damontecres.wholphin.ui.roundMinutes @@ -31,8 +36,7 @@ fun SongListDisplay( songs: List, showArtist: Boolean, onClick: (Int, BaseItem) -> Unit, - onClickAddToQueue: (Int, BaseItem) -> Unit, - onClickAddToPlaylist: (Int, BaseItem) -> Unit, + onLongClick: (Int, BaseItem) -> Unit, modifier: Modifier = Modifier, ) { LazyColumn( @@ -43,8 +47,7 @@ fun SongListDisplay( SongListItem( song = song, onClick = { song?.let { onClick.invoke(index, song) } }, - onClickAddToQueue = { song?.let { onClick.invoke(index, song) } }, - onClickAddToPlaylist = { song?.let { onClick.invoke(index, song) } }, + onLongClick = { song?.let { onLongClick.invoke(index, song) } }, showArtist = showArtist, modifier = Modifier, ) @@ -56,10 +59,10 @@ fun SongListDisplay( fun SongListItem( song: BaseItem?, onClick: () -> Unit, - onClickAddToQueue: () -> Unit, - onClickAddToPlaylist: () -> Unit, + onLongClick: () -> Unit, modifier: Modifier = Modifier, showArtist: Boolean = false, + isPlaying: Boolean = false, ) = SongListItem( title = song?.title, artist = if (showArtist) song?.data?.albumArtist else null, @@ -71,10 +74,10 @@ fun SongListItem( ?.ticks ?.roundMinutes, onClick = onClick, - onClickAddToQueue = onClickAddToQueue, - onClickAddToPlaylist = onClickAddToPlaylist, + onLongClick = onLongClick, modifier = modifier, showArtist = showArtist, + isPlaying = isPlaying, ) @Composable @@ -84,27 +87,42 @@ fun SongListItem( indexNumber: Int?, runtime: Duration?, onClick: () -> Unit, - onClickAddToQueue: () -> Unit, - onClickAddToPlaylist: () -> Unit, + onLongClick: () -> Unit, modifier: Modifier = Modifier, showArtist: Boolean = false, isPlaying: Boolean = false, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, ) { Row( verticalAlignment = Alignment.CenterVertically, modifier = modifier, ) { + val focused by interactionSource.collectIsFocusedAsState() ListItem( selected = isPlaying, onClick = onClick, + onLongClick = onLongClick, + interactionSource = interactionSource, leadingContent = { - Text( - text = indexNumber?.toString() ?: "", - ) + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = indexNumber?.toString() ?: "", + ) + if (isPlaying) { + Icon( + imageVector = Icons.Default.PlayArrow, + contentDescription = null, + ) + } + } }, headlineContent = { Text( text = title ?: "", + maxLines = 1, + modifier = Modifier.enableMarquee(focused), ) }, supportingContent = @@ -125,14 +143,6 @@ fun SongListItem( scale = ListItemDefaults.scale(1f, 1f, .95f), modifier = Modifier.weight(1f), ) - Button( - onClick = onClickAddToQueue, - shape = ButtonDefaults.shape(shape = RoundedCornerShape(8.dp)), - ) { - Text( - text = "Add to queue", - ) - } } } @@ -149,21 +159,20 @@ fun SongListItemPreview() { indexNumber = 1, runtime = 2.minutes + 30.seconds, onClick = {}, - onClickAddToQueue = { }, - onClickAddToPlaylist = {}, + onLongClick = { }, modifier = Modifier, showArtist = false, ) SongListItem( title = "Song title", artist = "Artists", - indexNumber = 1, + indexNumber = 2, runtime = 2.minutes + 30.seconds, onClick = {}, - onClickAddToQueue = { }, - onClickAddToPlaylist = {}, + onLongClick = { }, modifier = Modifier, showArtist = true, + isPlaying = true, ) } } 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 new file mode 100644 index 00000000..d409ed67 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/Utils.kt @@ -0,0 +1,95 @@ +package com.github.damontecres.wholphin.ui.detail.music + +import android.content.Context +import androidx.compose.material.icons.Icons +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 com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.ui.components.DialogItem +import com.github.damontecres.wholphin.ui.nav.Destination +import org.jellyfin.sdk.model.UUID +import org.jellyfin.sdk.model.api.BaseItemKind + +data class MusicMoreDialogActions( + val onNavigate: (Destination) -> Unit, + val onClickPlay: (Int, UUID) -> Unit, + val onClickAddToQueue: (Int, UUID) -> Unit, + val onClickFavorite: (UUID, Boolean) -> Unit, + val onClickAddPlaylist: (UUID) -> Unit, + val onClickGoToAlbum: (UUID) -> Unit = { + onNavigate.invoke(Destination.MediaItem(itemId = it, type = BaseItemKind.MUSIC_ALBUM)) + }, + val onClickGoToArtist: (UUID) -> Unit = { + onNavigate.invoke(Destination.MediaItem(itemId = it, type = BaseItemKind.MUSIC_ARTIST)) + }, +) + +fun buildMoreDialogForMusic( + context: Context, + actions: MusicMoreDialogActions, + item: BaseItem, + index: Int, +): List = + buildList { + add( + DialogItem( + context.getString(R.string.play), + Icons.Default.PlayArrow, + iconColor = Color.Green.copy(alpha = .8f), + ) { + actions.onClickPlay(index, item.id) + }, + ) + add( + DialogItem( + context.getString(R.string.add_to_queue), + Icons.Default.Add, + ) { + actions.onClickAddToQueue(index, item.id) + }, + ) + add( + DialogItem( + text = R.string.add_to_playlist, + iconStringRes = R.string.fa_list_ul, + ) { + actions.onClickAddPlaylist.invoke(item.id) + }, + ) + add( + DialogItem( + text = if (item.favorite) R.string.remove_favorite else R.string.add_favorite, + iconStringRes = R.string.fa_heart, + iconColor = if (item.favorite) Color.Red else Color.Unspecified, + ) { + actions.onClickFavorite.invoke(item.id, !item.favorite) + }, + ) + if (item.type == BaseItemKind.AUDIO && item.data.albumId != null) { + add( + DialogItem( + context.getString(R.string.go_to_album), + Icons.Default.ArrowForward, + ) { + actions.onClickGoToAlbum.invoke(item.data.albumId!!) + }, + ) + } + if ((item.type == BaseItemKind.AUDIO || item.type == BaseItemKind.MUSIC_ALBUM) && item.data.artistItems?.isNotEmpty() == true) { + add( + DialogItem( + context.getString(R.string.go_to_artist), + Icons.Default.ArrowForward, + ) { + actions.onClickGoToArtist.invoke( + item.data.artistItems!! + .first() + .id, + ) + }, + ) + } + } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 64bd8e9b..ef76342b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -498,6 +498,8 @@ Go to artist Now playing Instant mix + Go to album + Add to queue Disabled