Selecting a song on the search page navigates to a song's album with the song focused (#1661)

## Description
Selecting a song from search now resolves to its album's detail page,
scrolled to and focusing that song, instead of sending you to an
"unsupported item type" page. Implemented in the shared
BaseItem.destination() rather than in search specifically, so any caller
that navigates to a song gets the same behavior.

A song with no albumId plays directly while remaining in the search
results page: NavigationManager.navigateTo() intercepts a bare AUDIO
destination and hands it to a new MusicService.playSong(). Handling this
in the single navigation funnel means every caller gets it for free with
no loading-screen flash.

Adds BaseItemDestinationTest covering the new destination() logic.

### Related issues
https://github.com/damontecres/Wholphin/issues/1656

### Testing
Device: Sony Bravia 4K VH21

#### Tests on a search results page, song with albumId:
1. Select a song sends you to album page with cursor on song. Another
press plays it
2. Back button brings you back to search results.

Tested with both songs at the start of an album and songs deep into a
100+ songs album.

#### Tests on a search results page, song with no albumId:
1. Select a songs plays the song while staying in the search results
page. No loading screen.

## AI or LLM usage
I used Cursor and Claude Code for planning, implementation and code
review.

---------

Co-authored-by: Damontecres <damontecres@gmail.com>
This commit is contained in:
Stefan Bullones 2026-07-29 00:45:02 +02:00 • committed by GitHub
parent 79cb93db08
commit 438c9dd704
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 394 additions and 20 deletions

View file

@ -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)
}

View file

@ -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) {

View file

@ -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<BaseItem?>,
val similar: List<BaseItem>,
val loading: LoadingState,
val initialSongIndex: Int? = null,
val musicVideos: List<BaseItem?> = emptyList(),
val extras: List<ExtrasItem> = emptyList(),
val canDelete: Boolean = false,
@ -309,9 +319,10 @@ fun AlbumDetailsPage(
itemId: UUID,
preferences: UserPreferences,
modifier: Modifier = Modifier,
initialSongId: UUID? = null,
viewModel: AlbumViewModel =
hiltViewModel<AlbumViewModel, AlbumViewModel.Factory>(
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) {

View file

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

View file

@ -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<SongState> = _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<SongViewModel, SongViewModel.Factory>(
creationCallback = { it.create(itemId) },
),
playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsState()
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
val playlistState by playlistViewModel.playlistState.collectAsState()
var showContextMenu by remember { mutableStateOf<ContextMenu?>(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,
)
}
}

View file

@ -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)
}

View file

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

View file

@ -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)
}
}

View file

@ -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<NameGuidPair>? = null,
): BaseItemDto =
BaseItemDto(
id = id,
type = BaseItemKind.AUDIO,
name = name,
albumId = albumId,
seriesId = null,
genreItems = genres,
)
/**
* Create a simple [BaseItemDto] playlist
*/