Additional rows for albums & artists

This commit is contained in:
Damontecres 2026-03-02 17:58:57 -05:00
parent 5a4528541a
commit dfed290125
No known key found for this signature in database
4 changed files with 230 additions and 15 deletions

View file

@ -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<BaseItem?>,
val similar: List<BaseItem>,
val loading: LoadingState,
val musicVideos: List<BaseItem?> = 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,
)
},
)
}
}

View file

@ -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<BaseItem?>,
val albums: List<BaseItem?>,
val similar: List<BaseItem?>,
val similar: List<BaseItem>,
val loading: LoadingState,
val musicVideos: List<BaseItem?>,
) {
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,
)
},
)
}
}
}
}
}

View file

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

View file

@ -728,5 +728,6 @@
<string name="album">Album</string>
<string name="popular_songs">Popular songs</string>
<string name="play_next">Play next</string>
<string name="music_videos">Music videos</string>
</resources>