From 11e28a605c139eb48b0fabe503008c185b476823 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Wed, 11 Feb 2026 10:25:42 -0500 Subject: [PATCH] Buttons & instant mix --- .../wholphin/services/MusicService.kt | 44 ++++++++- .../{AlbumDetails.kt => AlbumDetailsPage.kt} | 61 ++++++++++--- .../ui/detail/music/MusicExpandableButtons.kt | 91 +++++++++++++++++++ .../wholphin/ui/nav/DestinationContent.kt | 4 +- app/src/main/res/values/fa_strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + 6 files changed, 184 insertions(+), 18 deletions(-) rename app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/{AlbumDetails.kt => AlbumDetailsPage.kt} (86%) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicExpandableButtons.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt index b3d8982f..1de02176 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt @@ -4,13 +4,16 @@ import android.content.Context import androidx.annotation.OptIn import androidx.media3.common.MediaItem import androidx.media3.common.Player +import androidx.media3.common.Timeline import androidx.media3.common.util.UnstableApi import androidx.media3.datasource.okhttp.OkHttpDataSource import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.source.DefaultMediaSourceFactory +import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.AudioItem import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient +import com.github.damontecres.wholphin.ui.DefaultItemFields import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.util.profile.Codec import dagger.hilt.android.qualifiers.ApplicationContext @@ -21,9 +24,11 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.withContext import okhttp3.OkHttpClient import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.instantMixApi import org.jellyfin.sdk.api.client.extensions.universalAudioApi import org.jellyfin.sdk.model.api.BaseItemKind import timber.log.Timber +import java.util.UUID import javax.inject.Inject import javax.inject.Singleton @@ -35,6 +40,7 @@ class MusicService @param:ApplicationContext private val context: Context, @param:AuthOkHttpClient private val authOkHttpClient: OkHttpClient, private val api: ApiClient, + private val serverRepository: ServerRepository, ) { private val _state = MutableStateFlow(MusicServiceState.EMPTY) val state: StateFlow = _state @@ -53,6 +59,22 @@ class MusicService } } + /** + * Fetches instant mix items, replaces the queue, and begins playback + */ + suspend fun startInstantMix(itemId: UUID) { + val items = + api.instantMixApi + .getInstantMixFromItem( + userId = serverRepository.currentUser.value?.id, + itemId = itemId, + limit = 200, + fields = DefaultItemFields, + ).content.items + .map { BaseItem(it, false) } + setQueue(items, false) + } + suspend fun setQueue( items: List, shuffled: Boolean, @@ -138,8 +160,6 @@ private class MusicPlayerListener( Timber.v("MusicPlayerListener onIsPlayingChanged") state.update { it.copy( - // TODO this is hack to force the UI to update, but is very inefficient - queue = PlayerMediaItemList(player), isPlaying = isPlaying, ) } @@ -152,24 +172,38 @@ private class MusicPlayerListener( Timber.v("MusicPlayerListener onMediaItemTransition") state.update { it.copy( - queue = PlayerMediaItemList(player), currentIndex = player.currentMediaItemIndex, ) } } + + override fun onTimelineChanged( + timeline: Timeline, + reason: Int, + ) { + Timber.v("MusicPlayerListener onTimelineChanged") + if (reason == Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED) { + state.update { + it.copy( + queue = PlayerMediaItemList(player), + currentIndex = player.currentMediaItemIndex, + ) + } + } + } } private class PlayerMediaItemList( private val player: Player, ) : AbstractList() { override fun get(index: Int): AudioItem { - Timber.v("get %s", index) +// Timber.v("get %s", index) return player.getMediaItemAt(index).localConfiguration?.tag as AudioItem } override val size: Int get() { - Timber.v("size %s", player.mediaItemCount) +// Timber.v("size %s", player.mediaItemCount) return player.mediaItemCount } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/AlbumDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/AlbumDetailsPage.kt similarity index 86% rename from app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/AlbumDetails.kt rename to app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/AlbumDetailsPage.kt index 5d25d532..d5b3ce4d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/AlbumDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/AlbumDetailsPage.kt @@ -19,6 +19,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AccountCircle import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember @@ -29,6 +30,7 @@ import androidx.compose.ui.focus.FocusState import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp @@ -59,6 +61,7 @@ import com.github.damontecres.wholphin.ui.components.OverviewText import com.github.damontecres.wholphin.ui.components.QuickDetails import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.letNotEmpty +import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler @@ -68,6 +71,7 @@ import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -165,6 +169,17 @@ class AlbumViewModel } } + fun setFavorite(favorite: Boolean) = + viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { + favoriteWatchManager.setFavorite(itemId, favorite) + val album = + api.userLibraryApi + .getItem(itemId = itemId) + .content + .let { BaseItem(it, false) } + _state.update { it.copy(album = album) } + } + fun play( shuffled: Boolean, startIndex: Int = 0, @@ -180,6 +195,14 @@ class AlbumViewModel musicService.setQueue(songsToAdd, shuffled) } } + + fun startInstantMix() { + viewModelScope.launchIO { + Timber.v("Starting instant mix for %s", itemId) + musicService.startInstantMix(itemId) + navigationManager.navigateTo(Destination.NowPlaying) + } + } } data class AlbumState( @@ -194,7 +217,7 @@ data class AlbumState( } @Composable -fun AlbumDetails( +fun AlbumDetailsPage( itemId: UUID, modifier: Modifier = Modifier, viewModel: AlbumViewModel = @@ -217,6 +240,9 @@ fun AlbumDetails( } LoadingState.Success -> { + val album = state.album!! + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { focusRequester.requestFocus() } val bringIntoViewRequester = remember { BringIntoViewRequester() } Box(modifier = modifier) { LazyColumn( @@ -233,25 +259,38 @@ fun AlbumDetails( .padding(bottom = 32.dp), ) { AlbumHeader( - album = state.album!!, + album = album, imageUrl = state.imageUrl, overviewOnClick = {}, bringIntoViewRequester = bringIntoViewRequester, modifier = Modifier.fillMaxWidth(), ) - AlbumButtons( - onClickPlay = { viewModel.play(it, 0) }, - onClickAddToPlaylist = {}, - onClickGoToArtist = {}, - onClickMore = { }, - buttonOnFocusChanged = {}, - modifier = - Modifier.onFocusChanged { - if (it.hasFocus) scope.launch { bringIntoViewRequester.bringIntoView() } + MusicExpandableButtons( + actions = + remember { + MusicButtonActions( + onClickPlay = { viewModel.play(it, 0) }, + onClickInstantMix = viewModel::startInstantMix, + onClickFavorite = { viewModel.setFavorite(!album.favorite) }, + onClickMore = { + // TODO + }, + ) }, + favorite = album.favorite, + modifier = + Modifier + .onFocusChanged { + if (it.hasFocus) scope.launch { bringIntoViewRequester.bringIntoView() } + }.focusRequester(focusRequester), ) } } + item { + Text( + text = stringResource(R.string.songs), + ) + } itemsIndexed(state.songs) { index, song -> SongListItem( song = song, 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 new file mode 100644 index 00000000..c78b3dc4 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicExpandableButtons.kt @@ -0,0 +1,91 @@ +package com.github.damontecres.wholphin.ui.detail.music + +import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusRestorer +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.ui.components.ExpandableFaButton +import com.github.damontecres.wholphin.ui.components.ExpandablePlayButton +import kotlin.time.Duration + +@Composable +fun MusicExpandableButtons( + actions: MusicButtonActions, + favorite: Boolean, + modifier: Modifier = Modifier, +) { + val firstFocus = remember { FocusRequester() } + LazyRow( + horizontalArrangement = Arrangement.spacedBy(16.dp), + contentPadding = PaddingValues(8.dp), + modifier = + modifier + .focusGroup() + .focusRestorer(firstFocus), + ) { + item("play") { + ExpandablePlayButton( + title = R.string.play, + resume = Duration.ZERO, + icon = Icons.Default.PlayArrow, + onClick = { actions.onClickPlay.invoke(false) }, + modifier = + Modifier + .focusRequester(firstFocus), + ) + } + item("shuffle") { + ExpandableFaButton( + title = R.string.shuffle, + iconStringRes = R.string.fa_shuffle, + onClick = { actions.onClickPlay.invoke(true) }, + modifier = Modifier, + ) + } + item("instant_mix") { + ExpandableFaButton( + title = R.string.instant_mix, + iconStringRes = R.string.fa_compass, + onClick = actions.onClickInstantMix, + modifier = Modifier, + ) + } + item("favorite") { + ExpandableFaButton( + title = if (favorite) R.string.remove_favorite else R.string.add_favorite, + iconStringRes = R.string.fa_heart, + onClick = actions.onClickFavorite, + iconColor = if (favorite) Color.Red else Color.Unspecified, + modifier = Modifier, + ) + } + item("more") { + ExpandablePlayButton( + title = R.string.more, + resume = Duration.ZERO, + icon = Icons.Default.MoreVert, + onClick = { actions.onClickMore.invoke() }, + modifier = Modifier, + ) + } + } +} + +data class MusicButtonActions( + val onClickPlay: (shuffle: Boolean) -> Unit, + val onClickInstantMix: () -> Unit, + val onClickFavorite: () -> Unit, + val onClickMore: () -> Unit, +) 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 0ab48eec..e94e6b38 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 @@ -29,7 +29,7 @@ import com.github.damontecres.wholphin.ui.detail.discover.DiscoverPersonPage import com.github.damontecres.wholphin.ui.detail.discover.DiscoverSeriesDetails import com.github.damontecres.wholphin.ui.detail.episode.EpisodeDetails import com.github.damontecres.wholphin.ui.detail.movie.MovieDetails -import com.github.damontecres.wholphin.ui.detail.music.AlbumDetails +import com.github.damontecres.wholphin.ui.detail.music.AlbumDetailsPage import com.github.damontecres.wholphin.ui.detail.music.NowPlayingPage import com.github.damontecres.wholphin.ui.detail.series.SeriesDetails import com.github.damontecres.wholphin.ui.detail.series.SeriesOverview @@ -212,7 +212,7 @@ fun DestinationContent( BaseItemKind.MUSIC_ALBUM -> { LaunchedEffect(Unit) { onClearBackdrop.invoke() } - AlbumDetails( + AlbumDetailsPage( itemId = destination.itemId, modifier = modifier, ) diff --git a/app/src/main/res/values/fa_strings.xml b/app/src/main/res/values/fa_strings.xml index 371560a1..9fcb0779 100644 --- a/app/src/main/res/values/fa_strings.xml +++ b/app/src/main/res/values/fa_strings.xml @@ -51,4 +51,5 @@ + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9e853f56..64bd8e9b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -497,6 +497,7 @@ Songs Go to artist Now playing + Instant mix Disabled