Fixes to artist detail page (#1762)

## Description
Fixes an incorrect query for an artist's albums (thanks @StefanBS for
reporting & fixing in #1761!)

Additionally, another album row for "Appears on" is added. This is
definitely necessary because Wholphin only has a tab for all artists
which includes artists featured on albums attributed to a different
artist. So those albums will now show in the "Appears on" similar to the
web UI instead of an empty artist page.

Finally, the long-click context menu is added for music videos on an
artist's page.

### Related issues
Fixes #1760
Replaced #1761 

### Testing
Emulator, particularly with artists who are not album artists

Also, verified the query fix against the web UI dev console

## Screenshots
N/A

## AI or LLM usage
None, but the fix included from #1761 did use some AI

---------

Co-authored-by: Stefan Bullones <stefan.bullones@gmail.com>
This commit is contained in:
Damontecres 2026-07-28 15:17:57 -04:00 • committed by GitHub
parent bf43a41405
commit 79cb93db08
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 178 additions and 60 deletions

View file

@ -59,6 +59,7 @@ import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.cards.BannerCardWithTitle import com.github.damontecres.wholphin.ui.cards.BannerCardWithTitle
import com.github.damontecres.wholphin.ui.cards.ItemRow import com.github.damontecres.wholphin.ui.cards.ItemRow
import com.github.damontecres.wholphin.ui.components.ContextMenu import com.github.damontecres.wholphin.ui.components.ContextMenu
import com.github.damontecres.wholphin.ui.components.ContextMenuActions
import com.github.damontecres.wholphin.ui.components.ContextMenuDialog import com.github.damontecres.wholphin.ui.components.ContextMenuDialog
import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.GenreText import com.github.damontecres.wholphin.ui.components.GenreText
@ -154,7 +155,8 @@ class ArtistViewModel
async { async {
val request = val request =
GetItemsRequest( GetItemsRequest(
parentId = itemId, albumArtistIds = listOf(itemId),
recursive = true,
fields = DefaultItemFields, fields = DefaultItemFields,
includeItemTypes = listOf(BaseItemKind.MUSIC_ALBUM), includeItemTypes = listOf(BaseItemKind.MUSIC_ALBUM),
sortBy = sortBy =
@ -166,14 +168,38 @@ class ArtistViewModel
) )
ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope).init() ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope).init()
} }
val appearsOnDeferred =
async {
val request =
GetItemsRequest(
contributingArtistIds = listOf(itemId),
recursive = true,
fields = DefaultItemFields,
includeItemTypes = listOf(BaseItemKind.MUSIC_ALBUM),
sortBy =
listOf(
ItemSortBy.PREMIERE_DATE,
ItemSortBy.SORT_NAME,
),
sortOrder = listOf(SortOrder.DESCENDING, SortOrder.ASCENDING),
)
ApiRequestPager(
api,
request,
GetItemsRequestHandler,
viewModelScope,
).init()
}
val artist = itemDeferred.await() val artist = itemDeferred.await()
val albums = albumsDeferred.await() val albums = albumsDeferred.await()
val appearsOn = appearsOnDeferred.await()
val imageUrl = imageUrlService.getItemImageUrl(artist, ImageType.PRIMARY) val imageUrl = imageUrlService.getItemImageUrl(artist, ImageType.PRIMARY)
_state.update { _state.update {
it.copy( it.copy(
artist = artist, artist = artist,
imageUrl = imageUrl, imageUrl = imageUrl,
albums = albums, albums = albums,
appearsOnAlbums = appearsOn,
loading = LoadingState.Success, loading = LoadingState.Success,
) )
} }
@ -218,24 +244,7 @@ class ArtistViewModel
_state.update { it.copy(similar = similar) } _state.update { it.copy(similar = similar) }
} }
} }
viewModelScope.launchIO { updateMusicVideos()
val request =
GetItemsRequest(
userId = serverRepository.currentUser?.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) { } catch (ex: Exception) {
_state.update { it.copy(loading = LoadingState.Error(ex)) } _state.update { it.copy(loading = LoadingState.Error(ex)) }
} }
@ -250,17 +259,52 @@ class ArtistViewModel
} }
} }
private fun updateMusicVideos() {
viewModelScope.launchIO {
val request =
GetItemsRequest(
userId = serverRepository.currentUser?.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)
}
}
}
}
fun setFavorite( fun setFavorite(
itemId: UUID, itemId: UUID,
favorite: Boolean, favorite: Boolean,
) = viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { ) = viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) {
favoriteWatchManager.setFavorite(itemId, favorite) favoriteWatchManager.setFavorite(itemId, favorite)
val artist = // Refresh if artist, otherwise only could be a music video
api.userLibraryApi if (itemId == this@ArtistViewModel.itemId) {
.getItem(itemId = itemId) val artist =
.content api.userLibraryApi
.let { BaseItem(it, false) } .getItem(itemId = itemId)
_state.update { it.copy(artist = artist) } .content
.let { BaseItem(it, false) }
_state.update { it.copy(artist = artist) }
} else {
updateMusicVideos()
}
}
fun setWatched(
itemId: UUID,
played: Boolean,
) = viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) {
favoriteWatchManager.setWatched(itemId, played)
// Only applies to music videos
updateMusicVideos()
} }
} }
@ -269,6 +313,7 @@ data class ArtistState(
val imageUrl: String?, val imageUrl: String?,
val topSongs: List<BaseItem?>, val topSongs: List<BaseItem?>,
val albums: List<BaseItem?>, val albums: List<BaseItem?>,
val appearsOnAlbums: List<BaseItem?>,
val similar: List<BaseItem>, val similar: List<BaseItem>,
val loading: LoadingState, val loading: LoadingState,
val musicVideos: List<BaseItem?>, val musicVideos: List<BaseItem?>,
@ -282,6 +327,7 @@ data class ArtistState(
emptyList(), emptyList(),
emptyList(), emptyList(),
emptyList(), emptyList(),
emptyList(),
LoadingState.Pending, LoadingState.Pending,
emptyList(), emptyList(),
false, false,
@ -292,7 +338,8 @@ data class ArtistState(
private const val HEADER_ROW = 0 private const val HEADER_ROW = 0
private const val SONG_ROW = HEADER_ROW + 1 private const val SONG_ROW = HEADER_ROW + 1
private const val ALBUM_ROW = SONG_ROW + 1 private const val ALBUM_ROW = SONG_ROW + 1
private const val MUSIC_VIDEO_ROW = ALBUM_ROW + 1 private const val APPEARS_ON_ROW = SONG_ROW + 1
private const val MUSIC_VIDEO_ROW = APPEARS_ON_ROW + 1
private const val SIMILAR_ROW = MUSIC_VIDEO_ROW + 1 private const val SIMILAR_ROW = MUSIC_VIDEO_ROW + 1
@Composable @Composable
@ -499,41 +546,81 @@ fun ArtistDetailsPage(
} }
} }
} }
item { if (state.albums.isNotEmpty()) {
ItemRow( item {
title = stringResource(R.string.albums), ItemRow(
items = state.albums, title = stringResource(R.string.albums),
onClickItem = { index, album -> items = state.albums,
position = RowColumn(ALBUM_ROW, index) onClickItem = { index, album ->
viewModel.navigationManager.navigateTo(album.destination()) position = RowColumn(ALBUM_ROW, index)
}, viewModel.navigationManager.navigateTo(album.destination())
onLongClickItem = { index, album -> },
showContextMenu = onLongClickItem = { index, album ->
ContextMenu.ForMusic( showContextMenu =
fromLongClick = true, ContextMenu.ForMusic(
fromLongClick = true,
item = album,
index = index,
canDelete =
viewModel.canDelete(
album,
preferences.appPreferences,
),
canRemoveFromQueue = false,
actions = moreDialogActions,
)
},
cardContent = { index: Int, album: BaseItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit ->
BannerCardWithTitle(
title = album?.name,
subtitle = album?.data?.productionYear?.toString(),
item = album, item = album,
index = index, onClick = onClick,
canDelete = onLongClick = onLongClick,
viewModel.canDelete( aspectRatio = AspectRatios.SQUARE,
album,
preferences.appPreferences,
),
canRemoveFromQueue = false,
actions = moreDialogActions,
) )
}, },
cardContent = { index: Int, album: BaseItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit -> modifier = Modifier.focusRequester(focusRequesters[ALBUM_ROW]),
BannerCardWithTitle( )
title = album?.name, }
subtitle = album?.data?.productionYear?.toString(), }
item = album, if (state.appearsOnAlbums.isNotEmpty()) {
onClick = onClick, item {
onLongClick = onLongClick, ItemRow(
aspectRatio = AspectRatios.SQUARE, title = stringResource(R.string.appears_on),
) items = state.appearsOnAlbums,
}, onClickItem = { index, album ->
modifier = Modifier.focusRequester(focusRequesters[ALBUM_ROW]), position = RowColumn(APPEARS_ON_ROW, index)
) viewModel.navigationManager.navigateTo(album.destination())
},
onLongClickItem = { index, album ->
showContextMenu =
ContextMenu.ForMusic(
fromLongClick = true,
item = album,
index = index,
canDelete =
viewModel.canDelete(
album,
preferences.appPreferences,
),
canRemoveFromQueue = false,
actions = moreDialogActions,
)
},
cardContent = { index: Int, album: BaseItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit ->
BannerCardWithTitle(
title = album?.name,
subtitle = album?.data?.productionYear?.toString(),
item = album,
onClick = onClick,
onLongClick = onLongClick,
aspectRatio = AspectRatios.SQUARE,
)
},
modifier = Modifier.focusRequester(focusRequesters[ALBUM_ROW]),
)
}
} }
if (state.musicVideos.isNotEmpty()) { if (state.musicVideos.isNotEmpty()) {
item { item {
@ -545,7 +632,37 @@ fun ArtistDetailsPage(
viewModel.navigationManager.navigateTo(item.destination()) viewModel.navigationManager.navigateTo(item.destination())
}, },
onLongClickItem = { index, item -> onLongClickItem = { index, item ->
// TODO showContextMenu =
ContextMenu.ForBaseItem(
fromLongClick = true,
item = item,
chosenStreams = null,
showGoTo = true,
showStreamChoices = false,
canDelete =
viewModel.canDelete(
item,
preferences.appPreferences,
),
canRemoveContinueWatching = false,
canRemoveNextUp = false,
actions =
ContextMenuActions(
navigateTo = viewModel.navigationManager::navigateTo,
onShowOverview = {},
onClickWatch = viewModel::setWatched,
onClickFavorite = viewModel::setFavorite,
onClickAddPlaylist = { itemId ->
playlistViewModel.loadPlaylists()
showPlaylistDialog.makePresent(itemId)
},
onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
onDeleteItem = viewModel::deleteItem,
onChooseVersion = { _, _ -> },
onChooseTracks = { },
onClearChosenStreams = {},
),
)
}, },
cardContent = { index: Int, item: BaseItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit -> cardContent = { index: Int, item: BaseItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit ->
BannerCardWithTitle( BannerCardWithTitle(

View file

@ -902,4 +902,5 @@
<string name="books">Books</string> <string name="books">Books</string>
<string name="audio_books">Audio books</string> <string name="audio_books">Audio books</string>
<string name="server_version_not_supported">Server version not supported</string> <string name="server_version_not_supported">Server version not supported</string>
<string name="appears_on">Appears on</string>
</resources> </resources>