From ee0c73f0e2f343b4db488613d75f4e291dea337a Mon Sep 17 00:00:00 2001 From: Damontecres Date: Tue, 10 Feb 2026 21:56:32 -0500 Subject: [PATCH] Basic music playback --- .../wholphin/data/NavDrawerItemRepository.kt | 8 +- .../wholphin/data/model/AudioItem.kt | 26 +++ .../wholphin/services/MusicService.kt | 151 +++++++++++++++--- .../wholphin/ui/detail/music/AlbumDetails.kt | 58 +++++-- .../ui/detail/music/NowPlayingPage.kt | 124 ++++++++++++++ .../ui/detail/music/SongListDisplay.kt | 7 +- .../wholphin/ui/nav/Destination.kt | 3 + .../wholphin/ui/nav/DestinationContent.kt | 5 + .../damontecres/wholphin/ui/nav/NavDrawer.kt | 16 ++ app/src/main/res/values/strings.xml | 1 + 10 files changed, 355 insertions(+), 44 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/data/model/AudioItem.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingPage.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/NavDrawerItemRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/data/NavDrawerItemRepository.kt index 2312c62c..a724930d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/NavDrawerItemRepository.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/NavDrawerItemRepository.kt @@ -51,9 +51,13 @@ class NavDrawerItemRepository val builtins = if (seerrServerRepository.active.first()) { - listOf(NavDrawerItem.Favorites, NavDrawerItem.Discover) + listOf( + NavDrawerItem.Favorites, + NavDrawerItem.Discover, + NavDrawerItem.NowPlaying, + ) } else { - listOf(NavDrawerItem.Favorites) + listOf(NavDrawerItem.Favorites, NavDrawerItem.NowPlaying) } val libraries = diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/AudioItem.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/AudioItem.kt new file mode 100644 index 00000000..4a0dd890 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/AudioItem.kt @@ -0,0 +1,26 @@ +package com.github.damontecres.wholphin.data.model + +import org.jellyfin.sdk.model.extensions.ticks +import java.util.UUID +import kotlin.time.Duration + +data class AudioItem( + val id: UUID, + val albumId: UUID?, + val title: String?, + val albumTitle: String?, + val artistNames: String?, + val runtime: Duration?, +) { + companion object { + fun from(item: BaseItem): AudioItem = + AudioItem( + id = item.id, + albumId = item.data.albumId, + title = item.title, + albumTitle = item.data.album, + artistNames = item.data.albumArtist, + runtime = item.data.runTimeTicks?.ticks, + ) + } +} 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 ae546e65..b3d8982f 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 @@ -8,15 +8,22 @@ 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.model.AudioItem import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.util.profile.Codec import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +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.universalAudioApi import org.jellyfin.sdk.model.api.BaseItemKind +import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton @@ -29,7 +36,10 @@ class MusicService @param:AuthOkHttpClient private val authOkHttpClient: OkHttpClient, private val api: ApiClient, ) { - private val player: Player by lazy { + private val _state = MutableStateFlow(MusicServiceState.EMPTY) + val state: StateFlow = _state + + val player: Player by lazy { ExoPlayer .Builder(context) .setMediaSourceFactory( @@ -37,36 +47,129 @@ class MusicService OkHttpDataSource.Factory(authOkHttpClient), ), ).build() + .also { + it.addListener(MusicPlayerListener(it, _state)) + it.prepare() + } } - fun addToQueue( + suspend fun setQueue( + items: List, + shuffled: Boolean, + ) { + Timber.d("setQueue: %s items, shuffled=%s", items.size, shuffled) + val mediaItems = + items + .filter { it.type == BaseItemKind.AUDIO } + .map(::convert) + withContext(Dispatchers.Main) { + player.setMediaItems(mediaItems) + player.shuffleModeEnabled = shuffled + player.play() + } + } + + suspend fun addToQueue( item: BaseItem, position: Int? = null, ) { if (item.type == BaseItemKind.AUDIO) { - val url = - api.universalAudioApi.getUniversalAudioStreamUrl( - itemId = item.id, - container = - listOf( - Codec.Audio.OPUS, - Codec.Audio.MP3, - Codec.Audio.AAC, - Codec.Audio.FLAC, - ), - ) - val mediaItem = - MediaItem - .Builder() - .setUri(url) - .setMediaId(item.id.toServerString()) - .setTag(item) - .build() - player.addMediaItem(mediaItem) - if (player.mediaItemCount == 1) { - // Start playing if this was the first time added - player.play() + val mediaItem = convert(item) + withContext(Dispatchers.Main) { + player.addMediaItem(mediaItem) + if (player.mediaItemCount == 1) { + // Start playing if this was the first time added + player.play() + } } } } + + private fun convert(audio: BaseItem): MediaItem { + val url = + api.universalAudioApi.getUniversalAudioStreamUrl( + itemId = audio.id, + container = + listOf( + Codec.Audio.OPUS, + Codec.Audio.MP3, + Codec.Audio.AAC, + Codec.Audio.FLAC, + ), + ) + return MediaItem + .Builder() + .setUri(url) + .setMediaId(audio.id.toServerString()) + .setTag(AudioItem.from(audio)) + .build() + } } + +data class MusicServiceState( + val queue: List, + val currentIndex: Int, + val isPlaying: Boolean, +) { + companion object { + val EMPTY = MusicServiceState(emptyList(), 0, false) + } +} + +/** + * Listens to [Player] events and updates the [StateFlow] + */ +private class MusicPlayerListener( + private val player: Player, + private val state: MutableStateFlow, +) : Player.Listener { + init { + Timber.v("MusicPlayerListener init") + state.update { + it.copy( + queue = PlayerMediaItemList(player), + currentIndex = player.currentMediaItemIndex, + isPlaying = player.isPlaying, + ) + } + } + + override fun onIsPlayingChanged(isPlaying: Boolean) { + 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, + ) + } + } + + override fun onMediaItemTransition( + mediaItem: MediaItem?, + reason: Int, + ) { + Timber.v("MusicPlayerListener onMediaItemTransition") + 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) + return player.getMediaItemAt(index).localConfiguration?.tag as AudioItem + } + + override val size: Int + get() { + 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/AlbumDetails.kt index d9c47650..5d25d532 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/AlbumDetails.kt @@ -40,19 +40,14 @@ import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import coil3.compose.AsyncImage import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.data.ItemPlaybackRepository import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.services.BackdropService -import com.github.damontecres.wholphin.services.ExtrasService import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.ImageUrlService 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.PeopleFavorites -import com.github.damontecres.wholphin.services.StreamChoiceService -import com.github.damontecres.wholphin.services.ThemeSongPlayer -import com.github.damontecres.wholphin.services.TrailerService import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.ui.DefaultItemFields import com.github.damontecres.wholphin.ui.components.ErrorMessage @@ -79,10 +74,12 @@ 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.itemsApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.request.GetItemsRequest +import timber.log.Timber import java.util.UUID import kotlin.time.Duration @@ -94,17 +91,12 @@ class AlbumViewModel @param:ApplicationContext private val context: Context, private val navigationManager: NavigationManager, val serverRepository: ServerRepository, - val itemPlaybackRepository: ItemPlaybackRepository, - val streamChoiceService: StreamChoiceService, val mediaReportService: MediaReportService, - private val themeSongPlayer: ThemeSongPlayer, private val favoriteWatchManager: FavoriteWatchManager, - private val peopleFavorites: PeopleFavorites, - private val trailerService: TrailerService, - private val extrasService: ExtrasService, private val userPreferencesService: UserPreferencesService, private val backdropService: BackdropService, private val imageUrlService: ImageUrlService, + private val musicService: MusicService, @Assisted val itemId: UUID, ) : ViewModel() { @AssistedFactory @@ -151,11 +143,43 @@ class AlbumViewModel loading = LoadingState.Success, ) } + if (album.data.imageTags?.contains(ImageType.BACKDROP) == true) { + backdropService.submit(album) + } else { + val artistIds = + album.data.albumArtists + ?.shuffled() + ?.take(50) + ?.map { it.id } + api.itemsApi + .getItems( + ids = artistIds, + imageTypes = listOf(ImageType.BACKDROP), + ).content.items + .firstOrNull() + ?.let { backdropService.submit(BaseItem(it, false)) } + } } catch (ex: Exception) { _state.update { it.copy(loading = LoadingState.Error(ex)) } } } } + + fun play( + shuffled: Boolean, + startIndex: Int = 0, + ) { + viewModelScope.launchIO { + Timber.v("Playing album %s from %s", itemId, startIndex) + val songs = state.value.songs as ApiRequestPager<*> + val songsToAdd = + (startIndex..songs.lastIndex) + .mapNotNull { idx -> + songs.getBlocking(idx) + } + musicService.setQueue(songsToAdd, shuffled) + } + } } data class AlbumState( @@ -178,6 +202,7 @@ fun AlbumDetails( creationCallback = { it.create(itemId) }, ), ) { + val scope = rememberCoroutineScope() val state by viewModel.state.collectAsState() when (val loading = state.loading) { @@ -215,19 +240,22 @@ fun AlbumDetails( modifier = Modifier.fillMaxWidth(), ) AlbumButtons( - onClickPlay = {}, + onClickPlay = { viewModel.play(it, 0) }, onClickAddToPlaylist = {}, onClickGoToArtist = {}, onClickMore = { }, buttonOnFocusChanged = {}, - modifier = Modifier, + modifier = + Modifier.onFocusChanged { + if (it.hasFocus) scope.launch { bringIntoViewRequester.bringIntoView() } + }, ) } } itemsIndexed(state.songs) { index, song -> SongListItem( song = song, - onClick = {}, + onClick = { viewModel.play(false, index) }, onClickAddToQueue = {}, onClickAddToPlaylist = {}, modifier = Modifier.padding(horizontal = 16.dp), diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingPage.kt new file mode 100644 index 00000000..ad9533fb --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingPage.kt @@ -0,0 +1,124 @@ +package com.github.damontecres.wholphin.ui.detail.music + +import android.content.Context +import androidx.annotation.OptIn +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.ViewModel +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.tv.material3.Text +import com.github.damontecres.wholphin.services.BackdropService +import com.github.damontecres.wholphin.services.FavoriteWatchManager +import com.github.damontecres.wholphin.services.ImageUrlService +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.playback.PlaybackButtons +import com.github.damontecres.wholphin.ui.roundSeconds +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import org.jellyfin.sdk.api.client.ApiClient +import kotlin.time.Duration.Companion.seconds + +@HiltViewModel(assistedFactory = NowPlayingViewModel.Factory::class) +class NowPlayingViewModel + @AssistedInject + constructor( + private val api: ApiClient, + @param:ApplicationContext private val context: Context, + private val navigationManager: NavigationManager, + private val favoriteWatchManager: FavoriteWatchManager, + private val userPreferencesService: UserPreferencesService, + private val backdropService: BackdropService, + private val imageUrlService: ImageUrlService, + private val musicService: MusicService, + ) : ViewModel() { + @AssistedFactory + interface Factory { + fun create(): NowPlayingViewModel + } + + val state get() = musicService.state + val player get() = musicService.player + } + +@OptIn(UnstableApi::class) +@Composable +fun NowPlayingPage( + modifier: Modifier = Modifier, + viewModel: NowPlayingViewModel = + hiltViewModel( + creationCallback = { it.create() }, + ), +) { + val state by viewModel.state.collectAsState() + val player = viewModel.player + val current = state.queue.getOrNull(state.currentIndex) + + val playPauseState = rememberPlayPauseButtonState(player) + val previousState = rememberPreviousButtonState(player) + val nextState = rememberNextButtonState(player) + + Column(modifier = modifier.padding(16.dp)) { + Text( + text = current?.title ?: "", + ) + Row { + PlaybackButtons( + player = player, + initialFocusRequester = remember { FocusRequester() }, + onControllerInteraction = {}, + onPlaybackActionClick = {}, + showPlay = playPauseState.showPlay, + previousEnabled = previousState.isEnabled, + nextEnabled = nextState.isEnabled, + seekBack = 10.seconds, + skipBackOnResume = null, + seekForward = 30.seconds, + ) + } + if (state.queue.isEmpty()) { + Text("No items") + } else { + LazyColumn( + contentPadding = PaddingValues(16.dp), + modifier = Modifier.fillMaxSize(), + ) { + itemsIndexed(state.queue) { index, song -> + SongListItem( + title = song.title, + artist = song.artistNames, + indexNumber = index + 1, + runtime = song.runtime?.roundSeconds, + showArtist = true, + isPlaying = state.currentIndex == index, + onClick = { + player.seekTo(index, 0L) + }, + onClickAddToQueue = {}, + onClickAddToPlaylist = {}, + modifier = Modifier, + ) + } + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/SongListDisplay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/SongListDisplay.kt index 67c27973..515789af 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/SongListDisplay.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/SongListDisplay.kt @@ -62,7 +62,7 @@ fun SongListItem( showArtist: Boolean = false, ) = SongListItem( title = song?.title, - artist = if (showArtist) song?.artistsString else null, + artist = if (showArtist) song?.data?.albumArtist else null, indexNumber = song?.data?.indexNumber, runtime = song @@ -88,13 +88,14 @@ fun SongListItem( onClickAddToPlaylist: () -> Unit, modifier: Modifier = Modifier, showArtist: Boolean = false, + isPlaying: Boolean = false, ) { Row( verticalAlignment = Alignment.CenterVertically, modifier = modifier, ) { ListItem( - selected = false, + selected = isPlaying, onClick = onClick, leadingContent = { Text( @@ -121,7 +122,7 @@ fun SongListItem( text = runtime.toString(), ) }, - scale = ListItemDefaults.scale(1f, 1f), + scale = ListItemDefaults.scale(1f, 1f, .95f), modifier = Modifier.weight(1f), ) Button( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt index 0d16f36a..6d793ed4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt @@ -126,6 +126,9 @@ sealed class Destination( val item: DiscoverItem, ) : Destination(false) + @Serializable + data object NowPlaying : Destination(true) + @Serializable data object UpdateApp : Destination(true) 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 14a50f78..0ab48eec 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 @@ -30,6 +30,7 @@ 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.NowPlayingPage 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 @@ -270,6 +271,10 @@ fun DestinationContent( ) } + Destination.NowPlaying -> { + NowPlayingPage(modifier) + } + Destination.UpdateApp -> { InstallUpdatePage(preferences, modifier) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt index 6eac3206..37d9b250 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt @@ -207,6 +207,11 @@ class NavDrawerViewModel setIndex(index) navigationManager.navigateToFromDrawer(item.destination) } + + NavDrawerItem.NowPlaying -> { + setIndex(index) + navigationManager.navigateToFromDrawer(Destination.NowPlaying) + } } } @@ -246,6 +251,13 @@ sealed interface NavDrawerItem { override fun name(context: Context): String = context.getString(R.string.discover) } + + object NowPlaying : NavDrawerItem { + override val id: String + get() = "a_nowplaying" + + override fun name(context: Context): String = context.getString(R.string.now_playing) + } } data class ServerNavDrawerItem( @@ -625,6 +637,10 @@ fun NavigationDrawerScope.NavItem( else -> R.string.fa_film } } + + NavDrawerItem.NowPlaying -> { + R.string.fa_circle_play + } } } val focused by interactionSource.collectIsFocusedAsState() diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 594709d0..9e853f56 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -496,6 +496,7 @@ Artists Songs Go to artist + Now playing Disabled