Basic music playback

This commit is contained in:
Damontecres 2026-02-10 21:56:32 -05:00
parent e8eb975966
commit ee0c73f0e2
No known key found for this signature in database
10 changed files with 355 additions and 44 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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<NowPlayingViewModel, NowPlayingViewModel.Factory>(
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,
)
}
}
}
}
}

View file

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

View file

@ -126,6 +126,9 @@ sealed class Destination(
val item: DiscoverItem,
) : Destination(false)
@Serializable
data object NowPlaying : Destination(true)
@Serializable
data object UpdateApp : Destination(true)

View file

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

View file

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

View file

@ -496,6 +496,7 @@
<string name="artists">Artists</string>
<string name="songs">Songs</string>
<string name="go_to_artist">Go to artist</string>
<string name="now_playing">Now playing</string>
<string-array name="theme_song_volume">
<item>Disabled</item>