From dfed290125dd2e0386d0ce25ccd54c9e8ca5d69b Mon Sep 17 00:00:00 2001 From: Damontecres Date: Mon, 2 Mar 2026 17:58:57 -0500 Subject: [PATCH] Additional rows for albums & artists --- .../ui/detail/music/AlbumDetailsPage.kt | 132 +++++++++++++++++- .../ui/detail/music/ArtistDetailsPage.kt | 102 +++++++++++++- .../ui/detail/music/NowPlayingOverlay.kt | 10 -- app/src/main/res/values/strings.xml | 1 + 4 files changed, 230 insertions(+), 15 deletions(-) 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 e790b103..98b0908f 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,6 +1,7 @@ package com.github.damontecres.wholphin.ui.detail.music import android.content.Context +import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -10,12 +11,14 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.relocation.BringIntoViewRequester import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf @@ -50,6 +53,11 @@ 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.AspectRatios +import com.github.damontecres.wholphin.ui.DefaultItemFields +import com.github.damontecres.wholphin.ui.SlimItemFields +import com.github.damontecres.wholphin.ui.cards.BannerCardWithTitle +import com.github.damontecres.wholphin.ui.cards.ItemRow import com.github.damontecres.wholphin.ui.components.DialogParams import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.components.ErrorMessage @@ -61,10 +69,14 @@ 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.ifElse import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.letNotEmpty +import com.github.damontecres.wholphin.ui.toBaseItems +import com.github.damontecres.wholphin.ui.tryRequestFocus 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 @@ -79,9 +91,13 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.itemsApi +import org.jellyfin.sdk.api.client.extensions.libraryApi 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.MediaType +import org.jellyfin.sdk.model.api.request.GetItemsRequest +import org.jellyfin.sdk.model.api.request.GetSimilarItemsRequest import java.util.UUID @HiltViewModel(assistedFactory = AlbumViewModel.Factory::class) @@ -135,7 +151,7 @@ class AlbumViewModel .orEmpty() .size > 1 _state.update { - AlbumState( + it.copy( album = album, isVariousArtists = isVariousArtists, imageUrl = imageUrl, @@ -159,6 +175,41 @@ class AlbumViewModel .firstOrNull() ?.let { backdropService.submit(BaseItem(it, false)) } } + if (state.value.similar.isEmpty()) { + viewModelScope.launchIO { + val similar = + api.libraryApi + .getSimilarItems( + GetSimilarItemsRequest( + userId = serverRepository.currentUser.value?.id, + itemId = itemId, + excludeArtistIds = album.data.albumArtists?.map { it.id }, + fields = SlimItemFields, + limit = 25, + ), + ).content.items + .map { BaseItem.from(it, api) } + _state.update { it.copy(similar = similar) } + } + } + viewModelScope.launchIO { + val request = + GetItemsRequest( + userId = serverRepository.currentUser.value?.id, + albumIds = listOf(itemId), + parentId = null, + fields = DefaultItemFields, + recursive = true, + includeItemTypes = listOf(BaseItemKind.MUSIC_VIDEO), + ) + val musicVideos = + GetItemsRequestHandler.execute(api, request).toBaseItems(api, false) + if (musicVideos.isNotEmpty()) { + _state.update { + it.copy(musicVideos = musicVideos) + } + } + } } catch (ex: Exception) { _state.update { it.copy(loading = LoadingState.Error(ex)) } } @@ -192,10 +243,12 @@ data class AlbumState( val isVariousArtists: Boolean, val imageUrl: String?, val songs: List, + val similar: List, val loading: LoadingState, + val musicVideos: List = emptyList(), ) { companion object { - val EMPTY = AlbumState(null, false, null, emptyList(), LoadingState.Pending) + val EMPTY = AlbumState(null, false, null, emptyList(), emptyList(), LoadingState.Pending) } } @@ -246,10 +299,27 @@ fun AlbumDetailsPage( LoadingState.Success -> { val album = state.album!! val focusRequester = remember { FocusRequester() } + val firstFocusRequester = remember { FocusRequester() } + val firstBringIntoViewRequester = remember { BringIntoViewRequester() } LaunchedEffect(Unit) { focusRequester.requestFocus() } val bringIntoViewRequester = remember { BringIntoViewRequester() } + val listState = rememberLazyListState() + val itemsBefore = 2 + val backHandlerActive by remember { + derivedStateOf { + listState.firstVisibleItemIndex > itemsBefore + } + } + BackHandler(backHandlerActive) { + scope.launch { + listState.animateScrollToItem(itemsBefore) + firstBringIntoViewRequester.bringIntoView() + firstFocusRequester.tryRequestFocus() + } + } Box(modifier = modifier) { LazyColumn( + state = listState, verticalArrangement = Arrangement.spacedBy(0.dp), modifier = Modifier.fillMaxSize(), ) { @@ -344,7 +414,63 @@ fun AlbumDetailsPage( Modifier // .padding(horizontal = 16.dp) .fillMaxWidth(.75f) - .align(Alignment.Center), + .align(Alignment.Center) + .ifElse( + index == 0, + Modifier + .focusRequester(firstFocusRequester) + .bringIntoViewRequester(firstBringIntoViewRequester), + ), + ) + } + } + if (state.musicVideos.isNotEmpty()) { + item { + ItemRow( + title = stringResource(R.string.music_videos), + items = state.musicVideos, + onClickItem = { index, item -> + viewModel.navigationManager.navigateTo(item.destination()) + }, + onLongClickItem = { index, item -> + // TODO + }, + cardContent = { index: Int, item: BaseItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit -> + BannerCardWithTitle( + title = item?.name, + subtitle = item?.data?.productionYear?.toString(), + item = item, + onClick = onClick, + onLongClick = onLongClick, + aspectRatio = AspectRatios.WIDE, + modifier = mod, + ) + }, + ) + } + } + if (state.similar.isNotEmpty()) { + item { + ItemRow( + title = stringResource(R.string.more_like_this), + items = state.similar, + onClickItem = { index, item -> + viewModel.navigationManager.navigateTo(item.destination()) + }, + onLongClickItem = { index, item -> + // TODO + }, + cardContent = { index: Int, item: BaseItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit -> + BannerCardWithTitle( + title = item?.name, + subtitle = item?.data?.productionYear?.toString(), + item = item, + onClick = onClick, + onLongClick = onLongClick, + aspectRatio = AspectRatios.SQUARE, + modifier = mod, + ) + }, ) } } 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 7b29a571..8b69dfb8 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 @@ -52,6 +52,7 @@ import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.DefaultItemFields +import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.cards.BannerCardWithTitle import com.github.damontecres.wholphin.ui.cards.ItemRow import com.github.damontecres.wholphin.ui.components.DialogParams @@ -85,6 +86,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.libraryApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.ImageType @@ -92,6 +94,7 @@ 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 org.jellyfin.sdk.model.api.request.GetSimilarItemsRequest import java.util.UUID @HiltViewModel(assistedFactory = ArtistViewModel.Factory::class) @@ -181,6 +184,41 @@ class ArtistViewModel } } } + if (state.value.similar.isEmpty()) { + viewModelScope.launchIO { + val similar = + api.libraryApi + .getSimilarItems( + GetSimilarItemsRequest( + userId = serverRepository.currentUser.value?.id, + itemId = itemId, + excludeArtistIds = listOf(itemId), + fields = SlimItemFields, + limit = 25, + ), + ).content.items + .map { BaseItem.from(it, api) } + _state.update { it.copy(similar = similar) } + } + } + viewModelScope.launchIO { + val request = + GetItemsRequest( + userId = serverRepository.currentUser.value?.id, + artistIds = listOf(itemId), + parentId = null, + fields = DefaultItemFields, + recursive = true, + includeItemTypes = listOf(BaseItemKind.MUSIC_VIDEO), + ) + val musicVideos = + GetItemsRequestHandler.execute(api, request).toBaseItems(api, false) + if (musicVideos.isNotEmpty()) { + _state.update { + it.copy(musicVideos = musicVideos) + } + } + } } catch (ex: Exception) { _state.update { it.copy(loading = LoadingState.Error(ex)) } } @@ -214,11 +252,21 @@ data class ArtistState( val imageUrl: String?, val topSongs: List, val albums: List, - val similar: List, + val similar: List, val loading: LoadingState, + val musicVideos: List, ) { companion object { - val EMPTY = ArtistState(null, null, emptyList(), emptyList(), emptyList(), LoadingState.Pending) + val EMPTY = + ArtistState( + null, + null, + emptyList(), + emptyList(), + emptyList(), + LoadingState.Pending, + emptyList(), + ) } } @@ -392,6 +440,56 @@ fun ArtistDetailsPage( }, ) } + if (state.musicVideos.isNotEmpty()) { + item { + ItemRow( + title = stringResource(R.string.music_videos), + items = state.musicVideos, + onClickItem = { index, item -> + viewModel.navigationManager.navigateTo(item.destination()) + }, + onLongClickItem = { index, item -> + // TODO + }, + cardContent = { index: Int, item: BaseItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit -> + BannerCardWithTitle( + title = item?.name, + subtitle = item?.data?.productionYear?.toString(), + item = item, + onClick = onClick, + onLongClick = onLongClick, + aspectRatio = AspectRatios.WIDE, + modifier = mod, + ) + }, + ) + } + } + if (state.similar.isNotEmpty()) { + item { + ItemRow( + title = stringResource(R.string.more_like_this), + items = state.similar, + onClickItem = { index, item -> + viewModel.navigationManager.navigateTo(item.destination()) + }, + onLongClickItem = { index, item -> + // TODO + }, + cardContent = { index: Int, item: BaseItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit -> + BannerCardWithTitle( + title = item?.name, + subtitle = item?.data?.productionYear?.toString(), + item = item, + onClick = onClick, + onLongClick = onLongClick, + aspectRatio = AspectRatios.SQUARE, + modifier = mod, + ) + }, + ) + } + } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingOverlay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingOverlay.kt index 59de5279..fa1ca473 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingOverlay.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingOverlay.kt @@ -37,11 +37,6 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi -import androidx.media3.ui.compose.state.rememberNextButtonState -import androidx.media3.ui.compose.state.rememberPlayPauseButtonState -import androidx.media3.ui.compose.state.rememberPreviousButtonState -import androidx.media3.ui.compose.state.rememberRepeatButtonState -import androidx.media3.ui.compose.state.rememberShuffleButtonState import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.wholphin.R @@ -70,11 +65,6 @@ fun NowPlayingOverlay( val scope = rememberCoroutineScope() val focusRequester = remember { FocusRequester() } LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } - val playPauseState = rememberPlayPauseButtonState(player) - val previousState = rememberPreviousButtonState(player) - val nextState = rememberNextButtonState(player) - val shuffleState = rememberShuffleButtonState(player) - val repeatState = rememberRepeatButtonState(player) var queueHasFocus by remember { mutableStateOf(false) } val height by animateFloatAsState( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 831d168c..bc531301 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -728,5 +728,6 @@ Album Popular songs Play next + Music videos