Refactoring queue

This commit is contained in:
Damontecres 2026-02-24 18:28:19 -05:00
parent 65b38d1231
commit 7703bcbd13
No known key found for this signature in database
4 changed files with 89 additions and 56 deletions

View file

@ -1,9 +1,11 @@
package com.github.damontecres.wholphin.data.model
import androidx.compose.runtime.Stable
import org.jellyfin.sdk.model.extensions.ticks
import java.util.UUID
import kotlin.time.Duration
@Stable
data class AudioItem(
val id: UUID,
val albumId: UUID?,

View file

@ -2,7 +2,9 @@ package com.github.damontecres.wholphin.services
import android.content.Context
import androidx.annotation.OptIn
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.remember
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.common.Timeline
@ -15,6 +17,7 @@ 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.onMain
import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.util.BlockingList
import com.github.damontecres.wholphin.util.profile.Codec
@ -30,6 +33,7 @@ import org.jellyfin.sdk.api.client.extensions.instantMixApi
import org.jellyfin.sdk.api.client.extensions.universalAudioApi
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber
import java.util.UUID
import javax.inject.Inject
@ -95,15 +99,7 @@ class MusicService
player.shuffleModeEnabled = shuffled
player.play()
}
(startIndex..items.lastIndex).forEach {
val item = items.getBlocking(it)
if (item != null && item.type == BaseItemKind.AUDIO) {
val mediaItem = convert(item)
withContext(Dispatchers.Main) {
player.addMediaItem(mediaItem)
}
}
}
addAllToQueue(items)
}
suspend fun setQueue(
@ -116,6 +112,7 @@ class MusicService
.filter { it.type == BaseItemKind.AUDIO }
.map(::convert)
withContext(Dispatchers.Main) {
updateQueueSize()
player.setMediaItems(mediaItems)
player.shuffleModeEnabled = shuffled
player.play()
@ -124,12 +121,17 @@ class MusicService
suspend fun addToQueue(
item: BaseItem,
position: Int? = null,
index: Int? = null,
) {
if (item.type == BaseItemKind.AUDIO) {
val mediaItem = convert(item)
withContext(Dispatchers.Main) {
player.addMediaItem(mediaItem)
if (index != null) {
player.addMediaItem(index, mediaItem)
} else {
player.addMediaItem(mediaItem)
}
updateQueueSize()
if (player.mediaItemCount == 1) {
// Start playing if this was the first time added
player.play()
@ -138,6 +140,22 @@ class MusicService
}
}
suspend fun addAllToQueue(list: BlockingList<BaseItem?>) {
list.indices
.chunked(25)
.forEach {
val mediaItems =
it.mapNotNull {
list
.getBlocking(it)
?.takeIf { it.type == BaseItemKind.AUDIO }
?.let(::convert)
}
onMain { player.addMediaItems(mediaItems) }
}
updateQueueSize()
}
private fun convert(audio: BaseItem): MediaItem {
val url =
api.universalAudioApi.getUniversalAudioStreamUrl(
@ -164,19 +182,26 @@ class MusicService
.setTag(AudioItem.from(audio, imageUrl))
.build()
}
private suspend fun updateQueueSize() {
withContext(Dispatchers.Main) {
_state.update {
it.copy(queueSize = player.mediaItemCount)
}
}
}
}
@Stable
data class MusicServiceState(
val queue: List<AudioItem>,
val queueSize: Int,
val currentIndex: Int,
val currentItemId: UUID?,
val isPlaying: Boolean,
) {
companion object {
val EMPTY = MusicServiceState(emptyList(), 0, false)
val EMPTY = MusicServiceState(0, 0, null, false)
}
val currentItemId: UUID? get() = if (isPlaying) queue.getOrNull(currentIndex)?.id else null
}
/**
@ -190,7 +215,7 @@ private class MusicPlayerListener(
Timber.v("MusicPlayerListener init")
state.update {
it.copy(
queue = PlayerMediaItemList(player, player.mediaItemCount),
queueSize = player.mediaItemCount,
currentIndex = player.currentMediaItemIndex,
isPlaying = player.isPlaying,
)
@ -211,11 +236,7 @@ private class MusicPlayerListener(
reason: Int,
) {
Timber.v("MusicPlayerListener onMediaItemTransition")
state.update {
it.copy(
currentIndex = player.currentMediaItemIndex,
)
}
updateCurrentIndex()
}
override fun onTimelineChanged(
@ -224,40 +245,41 @@ private class MusicPlayerListener(
) {
Timber.v("MusicPlayerListener onTimelineChanged")
if (reason == Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED) {
state.update {
it.copy(
queue = PlayerMediaItemList(player, player.mediaItemCount),
currentIndex = player.currentMediaItemIndex,
)
}
updateCurrentIndex()
}
}
private fun updateCurrentIndex() {
state.update {
it.copy(
currentIndex = player.currentMediaItemIndex,
currentItemId = player.getMediaItemAt(player.currentMediaItemIndex).mediaId.toUUIDOrNull(),
)
}
}
}
private class PlayerMediaItemList(
data class PlayerMediaItemList(
private val player: Player,
override val size: Int,
) : AbstractList<AudioItem>() {
override fun get(index: Int): AudioItem {
) {
val size: Int get() = player.mediaItemCount
operator fun get(index: Int): AudioItem {
// Timber.v("get %s", index)
return player.getMediaItemAt(index).localConfiguration?.tag as AudioItem
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is PlayerMediaItemList) return false
if (!super.equals(other)) return false
if (size != other.size) return false
if (player != other.player) return false
return true
}
override fun hashCode(): Int {
var result = super.hashCode()
result = 31 * result + size
result = 31 * result + player.hashCode()
return result
}
}
@Composable
fun rememberQueue(
player: Player,
queueSize: Int,
): List<AudioItem> =
remember(queueSize) {
object : AbstractList<AudioItem>() {
override val size: Int
get() = player.mediaItemCount
override fun get(index: Int): AudioItem = player.getMediaItemAt(index).localConfiguration?.tag as AudioItem
}
}

View file

@ -23,6 +23,7 @@ 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.data.model.AudioItem
import com.github.damontecres.wholphin.services.MusicServiceState
import com.github.damontecres.wholphin.ui.playback.ControllerViewState
import com.github.damontecres.wholphin.ui.playback.PlaybackButtons
@ -37,11 +38,11 @@ import kotlin.time.Duration.Companion.seconds
fun NowPlayingOverlay(
state: MusicServiceState,
player: Player,
current: AudioItem?,
queue: List<AudioItem>,
controllerViewState: ControllerViewState,
modifier: Modifier = Modifier,
) {
val current = state.queue.getOrNull(state.currentIndex)
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
val playPauseState = rememberPlayPauseButtonState(player)
@ -77,7 +78,7 @@ fun NowPlayingOverlay(
PlaybackButtons(
player = player,
initialFocusRequester = focusRequester,
onControllerInteraction = {},
onControllerInteraction = { controllerViewState.pulseControls() },
onPlaybackActionClick = {},
showPlay = playPauseState.showPlay,
previousEnabled = previousState.isEnabled,
@ -87,21 +88,21 @@ fun NowPlayingOverlay(
seekForward = 30.seconds,
)
}
if (state.queue.isEmpty()) {
if (queue.isEmpty()) {
Text("No items")
} else {
LazyColumn(
contentPadding = PaddingValues(16.dp),
modifier = Modifier.fillMaxSize(),
) {
itemsIndexed(state.queue) { index, song ->
itemsIndexed(queue) { index, song ->
SongListItem(
title = song.title,
artist = song.artistNames,
indexNumber = index + 1,
runtime = song.runtime?.roundSeconds,
showArtist = true,
isPlaying = state.currentIndex == index,
isPlaying = current?.id == song.id,
onClick = {
player.seekTo(index, 0L)
},

View file

@ -30,6 +30,7 @@ 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.services.rememberQueue
import com.github.damontecres.wholphin.ui.playback.ControllerViewState
import com.github.damontecres.wholphin.ui.playback.PlaybackKeyHandler
import com.github.damontecres.wholphin.ui.tryRequestFocus
@ -67,6 +68,9 @@ class NowPlayingViewModel
val state get() = musicService.state
val player get() = musicService.player
init {
}
fun reportInteraction() {
controllerViewState.pulseControls()
}
@ -83,7 +87,9 @@ fun NowPlayingPage(
) {
val state by viewModel.state.collectAsState()
val player = viewModel.player
val current = state.queue.getOrNull(state.currentIndex)
val queue = rememberQueue(player, state.queueSize)
val current = queue.getOrNull(state.currentIndex)
val controllerViewState = viewModel.controllerViewState
val preferences by viewModel.userPreferencesService.flow.collectAsState(AppPreferences.getDefaultInstance())
@ -131,6 +137,8 @@ fun NowPlayingPage(
NowPlayingOverlay(
state = state,
player = player,
current = current,
queue = queue,
controllerViewState = controllerViewState,
modifier = Modifier.fillMaxSize(),
)