diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt index 336c8d9a..5ca6bf95 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt @@ -272,6 +272,16 @@ data class BaseItem( } } + BaseItemKind.AUDIO -> { + data.albumId?.let { albumId -> + Destination.MediaItem( + itemId = albumId, + type = BaseItemKind.MUSIC_ALBUM, + initialSongId = id, + ) + } ?: Destination.MediaItem(this) + } + else -> { Destination.MediaItem(this) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMusic.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMusic.kt index a95a1ea2..9ab7e33f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMusic.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMusic.kt @@ -75,13 +75,7 @@ class CollectionFolderMusicViewModel index: Int, item: BaseItem, ) { - if (item.type == BaseItemKind.AUDIO) { - viewModelScope.launchDefault { - musicService.setQueue(listOf(item), false) - } - } else { - navigationManager.navigateTo(item.destination()) - } + navigationManager.navigateTo(item.destination()) } fun onClickPlayAll(shuffle: Boolean) { 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 f7f50bdb..c43b0fe6 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 @@ -124,11 +124,15 @@ class AlbumViewModel private val backdropService: BackdropService, private val imageUrlService: ImageUrlService, private val extrasService: ExtrasService, - @Assisted itemId: UUID, + @Assisted("itemId") itemId: UUID, + @Assisted("initialSongId") private val initialSongId: UUID?, ) : MusicViewModel(itemId, context, api, musicService, navigationManager, mediaManagementService) { @AssistedFactory interface Factory { - fun create(itemId: UUID): AlbumViewModel + fun create( + @Assisted("itemId") itemId: UUID, + @Assisted("initialSongId") initialSongId: UUID?, + ): AlbumViewModel } private val _state = MutableStateFlow(AlbumState.EMPTY) @@ -158,6 +162,10 @@ class AlbumViewModel val songsDeferred = async { getPagerForAlbum(api, itemId) } val album = itemDeferred.await() val songs = songsDeferred.await() + val initialSongIndex = + initialSongId?.let { id -> + songs.indexOfBlocking { it?.id == id }.takeIf { it >= 0 } + } val imageUrl = imageUrlService.getItemImageUrl(album, ImageType.PRIMARY) val allArtists = album.data.artists.orEmpty() + @@ -175,6 +183,7 @@ class AlbumViewModel isVariousArtists = isVariousArtists, imageUrl = imageUrl, songs = songs, + initialSongIndex = initialSongIndex, loading = LoadingState.Success, ) } @@ -289,6 +298,7 @@ data class AlbumState( val songs: List, val similar: List, val loading: LoadingState, + val initialSongIndex: Int? = null, val musicVideos: List = emptyList(), val extras: List = emptyList(), val canDelete: Boolean = false, @@ -309,9 +319,10 @@ fun AlbumDetailsPage( itemId: UUID, preferences: UserPreferences, modifier: Modifier = Modifier, + initialSongId: UUID? = null, viewModel: AlbumViewModel = hiltViewModel( - creationCallback = { it.create(itemId) }, + creationCallback = { it.create(itemId, initialSongId) }, ), playlistViewModel: AddPlaylistViewModel = hiltViewModel(), ) { @@ -320,7 +331,6 @@ fun AlbumDetailsPage( val state by viewModel.state.collectAsState() val currentMusic by viewModel.currentMusic.collectAsState() - var position by rememberPosition(0, 0) val focusRequesters = remember { List(SIMILAR_ROW + 1) { FocusRequester() } } val focusManager = LocalFocusManager.current @@ -356,12 +366,20 @@ fun AlbumDetailsPage( LoadingState.Success -> { val album = state.album!! + val itemsBefore = 2 + val initialSongIndex = state.initialSongIndex + var position by rememberPosition( + row = if (initialSongIndex != null) SONG_ROW else 0, + column = initialSongIndex ?: 0, + ) val firstFocusRequester = remember { FocusRequester() } val firstBringIntoViewRequester = remember { BringIntoViewRequester() } val bringIntoViewRequester = remember { BringIntoViewRequester() } - val listState = rememberLazyListState() - val itemsBefore = 2 + val listState = + rememberLazyListState( + initialFirstVisibleItemIndex = initialSongIndex?.let { itemsBefore + it } ?: 0, + ) val songFocusRequester = remember { FocusRequester() } LaunchedEffect(Unit) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicExpandableButtons.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicExpandableButtons.kt index b93483c9..96e82032 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicExpandableButtons.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicExpandableButtons.kt @@ -31,6 +31,7 @@ fun MusicExpandableButtons( canDelete: Boolean, buttonOnFocusChanged: (FocusState) -> Unit, modifier: Modifier = Modifier, + showShuffle: Boolean = true, ) { val firstFocus = remember { FocusRequester() } LazyRow( @@ -53,13 +54,15 @@ fun MusicExpandableButtons( .onFocusChanged(buttonOnFocusChanged), ) } - item("shuffle") { - ExpandableFaButton( - title = R.string.shuffle, - iconStringRes = R.string.fa_shuffle, - onClick = { actions.onClickPlay.invoke(true) }, - modifier = Modifier.onFocusChanged(buttonOnFocusChanged), - ) + if (showShuffle) { + item("shuffle") { + ExpandableFaButton( + title = R.string.shuffle, + iconStringRes = R.string.fa_shuffle, + onClick = { actions.onClickPlay.invoke(true) }, + modifier = Modifier.onFocusChanged(buttonOnFocusChanged), + ) + } } item("instant_mix") { ExpandableFaButton( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/SongDetailsPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/SongDetailsPage.kt new file mode 100644 index 00000000..acc802a8 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/SongDetailsPage.kt @@ -0,0 +1,270 @@ +package com.github.damontecres.wholphin.ui.detail.music + +import android.content.Context +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.services.FavoriteWatchManager +import com.github.damontecres.wholphin.services.ImageUrlService +import com.github.damontecres.wholphin.services.MediaManagementService +import com.github.damontecres.wholphin.services.MusicService +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus +import com.github.damontecres.wholphin.ui.components.ContextMenu +import com.github.damontecres.wholphin.ui.components.ContextMenuDialog +import com.github.damontecres.wholphin.ui.components.ErrorMessage +import com.github.damontecres.wholphin.ui.components.LoadingPage +import com.github.damontecres.wholphin.ui.components.MusicContextActions +import com.github.damontecres.wholphin.ui.components.Optional +import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel +import com.github.damontecres.wholphin.ui.detail.PlaylistDialog +import com.github.damontecres.wholphin.ui.launchDefault +import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.util.ExceptionHandler +import com.github.damontecres.wholphin.util.LoadingState +import com.github.damontecres.wholphin.util.WholphinDispatchers +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.userLibraryApi +import org.jellyfin.sdk.model.api.ImageType +import java.util.UUID + +@HiltViewModel(assistedFactory = SongViewModel.Factory::class) +class SongViewModel + @AssistedInject + constructor( + @ApplicationContext context: Context, + api: ApiClient, + musicService: MusicService, + navigationManager: NavigationManager, + mediaManagementService: MediaManagementService, + private val favoriteWatchManager: FavoriteWatchManager, + private val imageUrlService: ImageUrlService, + @Assisted itemId: UUID, + ) : MusicViewModel(itemId, context, api, musicService, navigationManager, mediaManagementService) { + @AssistedFactory + interface Factory { + fun create(itemId: UUID): SongViewModel + } + + private val _state = MutableStateFlow(SongState.EMPTY) + val state: StateFlow = _state + + init { + init() + viewModelScope.launchDefault { + mediaManagementService.collectCanDelete(state.map { it.song }) { canDelete -> + _state.update { it.copy(canDelete = canDelete) } + } + } + } + + override fun init() { + viewModelScope.launchIO { + try { + val song = + api.userLibraryApi + .getItem(itemId = itemId) + .content + .let { BaseItem(it, false) } + val imageUrl = imageUrlService.getItemImageUrl(song, ImageType.PRIMARY) + _state.update { + it.copy( + song = song, + imageUrl = imageUrl, + loading = LoadingState.Success, + ) + } + } catch (ex: Exception) { + _state.update { it.copy(loading = LoadingState.Error(ex)) } + } + } + } + + fun setFavorite( + itemId: UUID, + favorite: Boolean, + ) = viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { + // The toggle returns the updated user data, so no refetch of the song is needed + val userData = favoriteWatchManager.setFavorite(itemId, favorite) + _state.update { state -> + state.copy( + song = state.song?.let { it.copy(data = it.data.copy(userData = userData)) }, + ) + } + } + } + +data class SongState( + val song: BaseItem?, + val imageUrl: String?, + val loading: LoadingState, + val canDelete: Boolean = false, +) { + companion object { + val EMPTY = SongState(null, null, LoadingState.Pending) + } +} + +/** + * Details page for a song that has no album, and therefore no album page to open. + */ +@Composable +fun SongDetailsPage( + itemId: UUID, + preferences: UserPreferences, + modifier: Modifier = Modifier, + viewModel: SongViewModel = + hiltViewModel( + creationCallback = { it.create(itemId) }, + ), + playlistViewModel: AddPlaylistViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsState() + var showPlaylistDialog by remember { mutableStateOf>(Optional.absent()) } + val playlistState by playlistViewModel.playlistState.collectAsState() + var showContextMenu by remember { mutableStateOf(null) } + val moreDialogActions = + remember { + MusicContextActions( + navigateTo = { viewModel.navigationManager.navigateTo(it) }, + onClickPlay = { _, song -> viewModel.play(song) }, + onClickPlayNext = { _, song -> viewModel.playNext(song) }, + onClickAddToQueue = { item -> viewModel.addToQueue(item, -1) }, + onClickFavorite = { id, favorite -> viewModel.setFavorite(id, favorite) }, + onClickAddPlaylist = { id -> + playlistViewModel.loadPlaylists() + showPlaylistDialog.makePresent(id) + }, + onClickRemoveFromQueue = { _, _ -> }, + onDeleteItem = viewModel::deleteItem, + ) + } + when (val loading = state.loading) { + is LoadingState.Error -> { + ErrorMessage(loading, modifier) + } + + LoadingState.Loading, + LoadingState.Pending, + -> { + LoadingPage(modifier) + } + + LoadingState.Success -> { + val song = state.song!! + val bringIntoViewRequester = remember { BringIntoViewRequester() } + val buttonsFocusRequester = remember { FocusRequester() } + RequestOrRestoreFocus(buttonsFocusRequester) + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = + modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .bringIntoViewRequester(bringIntoViewRequester) + .padding(bottom = 32.dp), + ) { + AlbumHeader( + album = song, + imageUrl = state.imageUrl, + overviewOnClick = {}, + bringIntoViewRequester = bringIntoViewRequester, + modifier = Modifier.fillMaxWidth(), + ) + MusicExpandableButtons( + title = song.title ?: "", + actions = + remember(song) { + MusicButtonActions( + onClickPlay = { shuffled -> + viewModel.play(song, shuffled = shuffled) + }, + onClickInstantMix = { viewModel.startInstantMix(song.id) }, + onClickFavorite = { + viewModel.setFavorite(song.id, !song.favorite) + }, + onClickMore = { + showContextMenu = + ContextMenu.ForMusic( + fromLongClick = false, + item = song, + index = 0, + canDelete = + viewModel.canDelete( + song, + preferences.appPreferences, + ), + canRemoveFromQueue = false, + actions = moreDialogActions, + ) + }, + onConfirmDelete = { viewModel.deleteItem(song) }, + ) + }, + favorite = song.favorite, + canDelete = state.canDelete, + showShuffle = false, + buttonOnFocusChanged = {}, + modifier = Modifier.focusRequester(buttonsFocusRequester), + ) + } + } + } + showContextMenu?.let { contextMenu -> + ContextMenuDialog( + onDismissRequest = { showContextMenu = null }, + getMediaSource = null, + contextMenu = contextMenu, + preferredSubtitleLanguage = null, + ) + } + showPlaylistDialog.compose { songId -> + PlaylistDialog( + title = stringResource(R.string.add_to_playlist), + state = playlistState, + onDismissRequest = { showPlaylistDialog.makeAbsent() }, + onClick = { + playlistViewModel.addToPlaylist(it.id, songId) + showPlaylistDialog.makeAbsent() + }, + createEnabled = true, + onCreatePlaylist = { + playlistViewModel.createPlaylistAndAddItem(it, songId) + showPlaylistDialog.makeAbsent() + }, + onSearch = playlistViewModel::loadPlaylists, + elevation = 3.dp, + ) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt index 2468cd78..d9d027fc 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt @@ -74,6 +74,7 @@ sealed class Destination( val itemId: UUID, val type: BaseItemKind, val collectionType: CollectionType? = null, + val initialSongId: UUID? = null, ) : Destination() { constructor(item: BaseItem) : this(item.id, item.type, item.data.collectionType) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt index d7756161..7677c6aa 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt @@ -36,6 +36,7 @@ import com.github.damontecres.wholphin.ui.detail.movie.MovieDetails import com.github.damontecres.wholphin.ui.detail.music.AlbumDetailsPage import com.github.damontecres.wholphin.ui.detail.music.ArtistDetailsPage import com.github.damontecres.wholphin.ui.detail.music.NowPlayingPage +import com.github.damontecres.wholphin.ui.detail.music.SongDetailsPage import com.github.damontecres.wholphin.ui.detail.series.SeriesDetails import com.github.damontecres.wholphin.ui.detail.series.SeriesOverview import com.github.damontecres.wholphin.ui.discover.DiscoverPage @@ -257,6 +258,7 @@ fun DestinationContent( AlbumDetailsPage( preferences = preferences, itemId = destination.itemId, + initialSongId = destination.initialSongId, modifier = modifier, ) } @@ -270,6 +272,15 @@ fun DestinationContent( ) } + BaseItemKind.AUDIO -> { + LaunchedEffect(Unit) { onClearBackdrop.invoke() } + SongDetailsPage( + preferences = preferences, + itemId = destination.itemId, + modifier = modifier, + ) + } + else -> { Timber.w("Unsupported item type: ${destination.type}") Text("Unsupported item type: ${destination.type}", modifier) diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/BaseItemDestinationTest.kt b/app/src/test/java/com/github/damontecres/wholphin/test/BaseItemDestinationTest.kt new file mode 100644 index 00000000..4ef8ab8e --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/test/BaseItemDestinationTest.kt @@ -0,0 +1,49 @@ +package com.github.damontecres.wholphin.test + +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.ui.nav.Destination +import org.jellyfin.sdk.model.api.BaseItemKind +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import java.util.UUID + +class BaseItemDestinationTest { + @Test + fun audioWithAlbumId_navigatesToAlbumWithInitialSongId() { + val songId = UUID.randomUUID() + val albumId = UUID.randomUUID() + val item = BaseItem(song(id = songId, albumId = albumId), false) + + val dest = item.destination() + + assertEquals(Destination.MediaItem::class, dest::class) + val media = dest as Destination.MediaItem + assertEquals(albumId, media.itemId) + assertEquals(BaseItemKind.MUSIC_ALBUM, media.type) + assertEquals(songId, media.initialSongId) + } + + @Test + fun audioWithoutAlbumId_returnsSelfAsMediaItem() { + val songId = UUID.randomUUID() + val item = BaseItem(song(id = songId, albumId = null), false) + + val dest = item.destination() + + assertEquals(songId, (dest as Destination.MediaItem).itemId) + assertEquals(BaseItemKind.AUDIO, dest.type) + assertNull(dest.initialSongId) + } + + @Test + fun movie_usesStandardDestination() { + val movieId = UUID.randomUUID() + val item = BaseItem(movie(id = movieId), false) + + val dest = item.destination() + + assertEquals(movieId, (dest as Destination.MediaItem).itemId) + assertEquals(BaseItemKind.MOVIE, dest.type) + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestUtils.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestUtils.kt index a1bcb2f1..87a44416 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/test/TestUtils.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestUtils.kt @@ -42,6 +42,24 @@ fun episode( genreItems = genres, ) +/** + * Create a simple [BaseItemDto] song + */ +fun song( + id: UUID = UUID.randomUUID(), + albumId: UUID? = null, + name: String = "Test Song", + genres: List? = null, +): BaseItemDto = + BaseItemDto( + id = id, + type = BaseItemKind.AUDIO, + name = name, + albumId = albumId, + seriesId = null, + genreItems = genres, + ) + /** * Create a simple [BaseItemDto] playlist */