Buttons & instant mix

This commit is contained in:
Damontecres 2026-02-11 10:25:42 -05:00
parent ee0c73f0e2
commit 11e28a605c
No known key found for this signature in database
6 changed files with 184 additions and 18 deletions

View file

@ -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<MusicServiceState> = _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<BaseItem>,
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<AudioItem>() {
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
}
}

View file

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

View file

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

View file

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

View file

@ -51,4 +51,5 @@
<string name="fa_clock" translatable="false">&#xf017;</string>
<string name="fa_bell" translatable="false">&#xf0f3;</string>
<string name="fa_check" translatable="false">&#xf00c;</string>
<string name="fa_compass" translatable="false">&#xf14e;</string>
</resources>

View file

@ -497,6 +497,7 @@
<string name="songs">Songs</string>
<string name="go_to_artist">Go to artist</string>
<string name="now_playing">Now playing</string>
<string name="instant_mix">Instant mix</string>
<string-array name="theme_song_volume">
<item>Disabled</item>