Refactor PlaybackViewModel to handle generic playlists

This commit is contained in:
Damontecres 2025-10-13 13:38:49 -04:00
parent 3906eb196a
commit f737818873
No known key found for this signature in database
5 changed files with 314 additions and 185 deletions

View file

@ -0,0 +1,74 @@
package com.github.damontecres.dolphin.data.model
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.setValue
import com.github.damontecres.dolphin.ui.DefaultItemFields
import com.github.damontecres.dolphin.ui.indexOfFirstOrNull
import com.github.damontecres.dolphin.util.GetEpisodesRequestHandler
import com.github.damontecres.dolphin.util.GetPlaylistItemsRequestHandler
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.model.api.request.GetEpisodesRequest
import org.jellyfin.sdk.model.api.request.GetPlaylistItemsRequest
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
class Playlist(
items: List<BaseItem>,
startIndex: Int = 0,
) {
val items = items.subList(startIndex, items.size)
var index by mutableIntStateOf(0)
fun hasPrevious(): Boolean = index > 0
fun hasNext(): Boolean = index < items.size
fun getPreviousAndReverse(): BaseItem = items[--index]
fun getAndAdvance(): BaseItem = items[++index]
fun peek(): BaseItem? = items.getOrNull(index + 1)
companion object {
const val MAX_SIZE = 100
}
}
@Singleton
class PlaylistCreator
@Inject
constructor(
private val api: ApiClient,
) {
suspend fun createFromEpisode(
seriesId: UUID,
episodeId: UUID,
): Playlist {
val request =
GetEpisodesRequest(
seriesId = seriesId,
fields = DefaultItemFields,
startItemId = episodeId,
limit = Playlist.MAX_SIZE,
)
val episodes = GetEpisodesRequestHandler.execute(api, request).content.items
val startIndex =
episodes.indexOfFirstOrNull { it.id == episodeId }
?: throw IllegalStateException("Episode $episodeId was not returned")
return Playlist(episodes.map { BaseItem.from(it, api) }, startIndex)
}
suspend fun createFromPlaylistId(playlistId: UUID): Playlist {
val request =
GetPlaylistItemsRequest(
playlistId = playlistId,
fields = DefaultItemFields,
limit = Playlist.MAX_SIZE,
)
val items = GetPlaylistItemsRequestHandler.execute(api, request).content.items
return Playlist(items.map { BaseItem.from(it, api) }, 0)
}
}

View file

@ -91,6 +91,10 @@ sealed interface PlaybackAction {
data class Scale(
val scale: ContentScale,
) : PlaybackAction
data object Previous : PlaybackAction
data object Next : PlaybackAction
}
@OptIn(UnstableApi::class)
@ -174,6 +178,7 @@ fun PlaybackControls(
player = playerControls,
initialFocusRequester = initialFocusRequester,
onControllerInteraction = onControllerInteraction,
onPlaybackActionClick = onPlaybackActionClick,
showPlay = showPlay,
previousEnabled = previousEnabled,
nextEnabled = nextEnabled,
@ -443,6 +448,7 @@ fun PlaybackButtons(
player: Player,
initialFocusRequester: FocusRequester,
onControllerInteraction: () -> Unit,
onPlaybackActionClick: (PlaybackAction) -> Unit,
showPlay: Boolean,
previousEnabled: Boolean,
nextEnabled: Boolean,
@ -459,7 +465,7 @@ fun PlaybackButtons(
iconRes = R.drawable.baseline_skip_previous_24,
onClick = {
onControllerInteraction.invoke()
player.seekToPrevious()
onPlaybackActionClick.invoke(PlaybackAction.Previous)
},
enabled = previousEnabled,
onControllerInteraction = onControllerInteraction,
@ -500,7 +506,7 @@ fun PlaybackButtons(
iconRes = R.drawable.baseline_skip_next_24,
onClick = {
onControllerInteraction.invoke()
player.seekToNext()
onPlaybackActionClick.invoke(PlaybackAction.Next)
},
enabled = nextEnabled,
onControllerInteraction = onControllerInteraction,

View file

@ -50,14 +50,13 @@ import androidx.media3.ui.SubtitleView
import androidx.media3.ui.compose.PlayerSurface
import androidx.media3.ui.compose.SURFACE_TYPE_SURFACE_VIEW
import androidx.media3.ui.compose.modifiers.resizeWithContentScale
import androidx.media3.ui.compose.state.rememberNextButtonState
import androidx.media3.ui.compose.state.rememberPlayPauseButtonState
import androidx.media3.ui.compose.state.rememberPresentationState
import androidx.media3.ui.compose.state.rememberPreviousButtonState
import androidx.tv.material3.Button
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.dolphin.data.model.Playlist
import com.github.damontecres.dolphin.preferences.UserPreferences
import com.github.damontecres.dolphin.preferences.skipBackOnResume
import com.github.damontecres.dolphin.ui.OneTimeLaunchedEffect
@ -105,7 +104,8 @@ fun PlaybackPage(
var cues by remember { mutableStateOf<List<Cue>>(listOf()) }
var showDebugInfo by remember { mutableStateOf(prefs.showDebugInfo) }
val nextUp by viewModel.nextUpEpisode.observeAsState(null)
val nextUp by viewModel.nextUp.observeAsState(null)
val playlist by viewModel.playlist.observeAsState(Playlist(listOf()))
// TODO move to viewmodel?
val cueListener =
@ -138,8 +138,6 @@ fun PlaybackPage(
Modifier.resizeWithContentScale(contentScale, presentationState.videoSizeDp)
val focusRequester = remember { FocusRequester() }
val playPauseState = rememberPlayPauseButtonState(player)
val previousState = rememberPreviousButtonState(player)
val nextState = rememberNextButtonState(player)
val seekBarState = rememberSeekBarState(player, scope)
LaunchedEffect(Unit) {
@ -261,8 +259,8 @@ fun PlaybackPage(
playerControls = player,
controllerViewState = controllerViewState,
showPlay = playPauseState.showPlay,
previousEnabled = previousState.isEnabled,
nextEnabled = nextState.isEnabled,
previousEnabled = true,
nextEnabled = playlist.hasNext(),
seekEnabled = true,
seekForward = preferences.appPreferences.playbackPreferences.skipForwardMs.milliseconds,
seekBack = preferences.appPreferences.playbackPreferences.skipBackMs.milliseconds,
@ -290,6 +288,20 @@ fun PlaybackPage(
is PlaybackAction.ToggleCaptions -> {
viewModel.changeSubtitleStream(it.index)
}
PlaybackAction.Next -> {
// TODO focus is lost
viewModel.playUpNextUp()
}
PlaybackAction.Previous -> {
val pos = player.currentPosition
if (pos < player.maxSeekToPreviousPosition && playlist.hasPrevious()) {
viewModel.playPrevious()
} else {
player.seekToPrevious()
}
}
}
},
onSeekBarChange = seekBarState::onValueChange,
@ -381,14 +393,14 @@ fun PlaybackPage(
if (autoPlayEnabled) {
LaunchedEffect(Unit) {
if (timeLeft == 0L) {
viewModel.playUpNextEpisode()
viewModel.playUpNextUp()
} else {
while (timeLeft > 0) {
delay(1.seconds)
timeLeft--
}
if (timeLeft == 0L && autoPlayEnabled) {
viewModel.playUpNextEpisode()
viewModel.playUpNextUp()
}
}
}
@ -402,7 +414,7 @@ fun PlaybackPage(
description = it.data.overview,
imageUrl = it.imageUrl,
aspectRatio = it.data.primaryImageAspectRatio?.toFloat() ?: (16f / 9),
onClick = { viewModel.playUpNextEpisode() },
onClick = { viewModel.playUpNextUp() },
timeLeft = if (autoPlayEnabled) timeLeft.seconds else null,
modifier =
Modifier

View file

@ -16,17 +16,15 @@ import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import com.github.damontecres.dolphin.data.model.BaseItem
import com.github.damontecres.dolphin.data.model.Chapter
import com.github.damontecres.dolphin.data.model.Playlist
import com.github.damontecres.dolphin.data.model.PlaylistCreator
import com.github.damontecres.dolphin.preferences.AppPreference
import com.github.damontecres.dolphin.preferences.SkipSegmentBehavior
import com.github.damontecres.dolphin.preferences.UserPreferences
import com.github.damontecres.dolphin.ui.DefaultItemFields
import com.github.damontecres.dolphin.ui.indexOfFirstOrNull
import com.github.damontecres.dolphin.ui.nav.Destination
import com.github.damontecres.dolphin.ui.nav.NavigationManager
import com.github.damontecres.dolphin.util.ApiRequestPager
import com.github.damontecres.dolphin.util.EqualityMutableLiveData
import com.github.damontecres.dolphin.util.ExceptionHandler
import com.github.damontecres.dolphin.util.GetEpisodesRequestHandler
import com.github.damontecres.dolphin.util.TrackActivityPlaybackListener
import com.github.damontecres.dolphin.util.TrackSupport
import com.github.damontecres.dolphin.util.checkForSupport
@ -58,7 +56,6 @@ import org.jellyfin.sdk.model.api.PlayMethod
import org.jellyfin.sdk.model.api.PlaybackInfoDto
import org.jellyfin.sdk.model.api.SubtitlePlaybackMode
import org.jellyfin.sdk.model.api.TrickplayInfo
import org.jellyfin.sdk.model.api.request.GetEpisodesRequest
import org.jellyfin.sdk.model.extensions.inWholeTicks
import org.jellyfin.sdk.model.extensions.ticks
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
@ -86,6 +83,7 @@ class PlaybackViewModel
constructor(
@ApplicationContext context: Context,
val api: ApiClient,
val playlistCreator: PlaylistCreator,
val navigationManager: NavigationManager,
) : ViewModel() {
val player: ExoPlayer =
@ -114,9 +112,10 @@ class PlaybackViewModel
private lateinit var dto: BaseItemDto
private var activityListener: TrackActivityPlaybackListener? = null
private val episodes = MutableLiveData<ApiRequestPager<GetEpisodesRequest>>()
private var currentEpisodeIndex = Int.MAX_VALUE
val nextUpEpisode = MutableLiveData<BaseItem?>()
val nextUp = MutableLiveData<BaseItem?>()
private var isPlaylist = false
val playlist = MutableLiveData<Playlist>(Playlist(listOf()))
init {
addCloseable { this@PlaybackViewModel.activityListener?.release() }
@ -128,14 +127,45 @@ class PlaybackViewModel
deviceProfile: DeviceProfile,
preferences: UserPreferences,
) {
nextUpEpisode.value = null
nextUp.value = null
this.preferences = preferences
this.deviceProfile = deviceProfile
val itemId = destination.itemId
this.itemId = itemId
val item = destination.item
viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
val base = item?.data ?: api.userLibraryApi.getItem(itemId).content
val queriedItem = item?.data ?: api.userLibraryApi.getItem(itemId).content
val base =
if (queriedItem.type == BaseItemKind.PLAYLIST) {
isPlaylist = true
val playlist = playlistCreator.createFromPlaylistId(queriedItem.id)
withContext(Dispatchers.Main) {
this@PlaybackViewModel.playlist.value = playlist
}
// TODO start index
playlist.items.first().data
} else {
queriedItem
}
play(base, destination.positionMs)
if (!isPlaylist && queriedItem.type == BaseItemKind.EPISODE) {
val playlist =
playlistCreator.createFromEpisode(queriedItem.seriesId!!, queriedItem.id)
withContext(Dispatchers.Main) {
this@PlaybackViewModel.playlist.value = playlist
}
}
maybeSetupPlaylistListener()
}
}
private suspend fun play(
base: BaseItemDto,
positionMs: Long,
) = withContext(Dispatchers.IO) {
Timber.i("Playing ${base.id}")
dto = base
val title =
if (base.type == BaseItemKind.EPISODE) {
@ -249,30 +279,27 @@ class PlaybackViewModel
this@PlaybackViewModel.subtitleStreams.value = subtitleStreams
changeStreams(
itemId,
base,
audioIndex,
subtitleIndex,
if (destination.positionMs > 0) destination.positionMs else C.TIME_UNSET,
if (positionMs > 0) positionMs else C.TIME_UNSET,
)
player.prepare()
this@PlaybackViewModel.chapters.value = Chapter.fromDto(dto, api)
Timber.v("chapters=${this@PlaybackViewModel.chapters.value}")
}
if (base.type == BaseItemKind.EPISODE) {
base.seriesId?.let(::getEpisodes)
this@PlaybackViewModel.chapters.value = Chapter.fromDto(base, api)
Timber.v("chapters=${this@PlaybackViewModel.chapters.value?.size}")
}
listenForSegments()
}
}
@OptIn(UnstableApi::class)
private suspend fun changeStreams(
itemId: UUID,
item: BaseItemDto,
audioIndex: Int?,
subtitleIndex: Int?,
positionMs: Long = C.TIME_UNSET,
) {
val itemId = item.id
if (currentPlayback.value?.let {
it.itemId == itemId &&
it.audioIndex == audioIndex &&
@ -416,7 +443,7 @@ class PlaybackViewModel
}
}
val trickPlayInfo =
dto.trickplay
item.trickplay
?.get(source.id)
?.values
?.firstOrNull()
@ -431,7 +458,7 @@ class PlaybackViewModel
val itemId = currentPlayback.value?.itemId ?: return
viewModelScope.launch(ExceptionHandler()) {
changeStreams(
itemId,
dto,
index,
currentPlayback.value?.subtitleIndex,
player.currentPosition,
@ -443,7 +470,7 @@ class PlaybackViewModel
val itemId = currentPlayback.value?.itemId ?: return
viewModelScope.launch(ExceptionHandler()) {
changeStreams(
itemId,
dto,
currentPlayback.value?.audioIndex,
index,
player.currentPosition,
@ -463,56 +490,26 @@ class PlaybackViewModel
)
}
fun getEpisodes(seriesId: UUID) {
viewModelScope.launch(Dispatchers.IO) {
val request =
GetEpisodesRequest(
seriesId = seriesId,
fields = DefaultItemFields,
startItemId = itemId,
limit = 2,
)
val episodes = GetEpisodesRequestHandler.execute(api, request).content.items
val currentEpisodeIndex = episodes.indexOfFirstOrNull { it.id == itemId }
Timber.v("Current episode is $currentEpisodeIndex of ${episodes.size}")
if (currentEpisodeIndex != null) {
val nextIndex = currentEpisodeIndex + 1
if (nextIndex < episodes.size) {
private fun maybeSetupPlaylistListener() {
playlist.value?.let { playlist ->
if (playlist.hasNext()) {
Timber.v("Adding lister for playlist with ${playlist.items.size} items")
val listener =
object : Player.Listener {
override fun onPlaybackStateChanged(playbackState: Int) {
if (playbackState == Player.STATE_ENDED) {
viewModelScope.launch(Dispatchers.IO) {
val nextItem = BaseItem.from(episodes[nextIndex], api)
Timber.v("Setting next up episode to ${nextItem.id}")
viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) {
val nextItem = playlist.peek()
Timber.v("Setting next up to ${nextItem?.id}")
withContext(Dispatchers.Main) {
nextUpEpisode.value = nextItem
nextUp.value = nextItem
}
}
player.removeListener(this)
}
}
}
player.addListener(listener)
// viewModelScope.launch(Dispatchers.IO) {
// while (this.isActive) {
// delay(5.seconds)
// val remaining =
// withContext(Dispatchers.Main) {
// (player.duration - player.currentPosition).milliseconds
// }
// if (remaining < 2.minutes) { // TODO time & preference
// val nextItem = episodes.getBlocking(nextIndex)
// Timber.v("Setting next up episode to ${nextItem?.id}")
// withContext(Dispatchers.Main) {
// nextUpEpisode.value = nextItem
// }
// break
// }
// }
// }
}
addCloseable { player.removeListener(listener) }
}
}
}
@ -576,14 +573,30 @@ class PlaybackViewModel
}
}
fun playUpNextEpisode() {
nextUpEpisode.value?.let {
init(Destination.Playback(it.id, 0, it), deviceProfile, preferences)
fun playUpNextUp() {
playlist.value?.let {
if (it.hasNext()) {
viewModelScope.launch(ExceptionHandler()) {
cancelUpNextEpisode()
play(it.getAndAdvance().data, 0)
}
}
}
}
fun playPrevious() {
playlist.value?.let {
if (it.hasPrevious()) {
viewModelScope.launch(ExceptionHandler()) {
cancelUpNextEpisode()
play(it.getPreviousAndReverse().data, 0)
}
}
}
}
fun cancelUpNextEpisode() {
nextUpEpisode.value = null
nextUp.value = null
}
}
@ -670,3 +683,6 @@ private fun applyTrackSelections(
}
}
}
private val singlePlays =
setOf(BaseItemKind.EPISODE, BaseItemKind.MOVIE, BaseItemKind.VIDEO, BaseItemKind.MUSIC_VIDEO)

View file

@ -16,12 +16,14 @@ import kotlinx.coroutines.sync.withLock
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.Response
import org.jellyfin.sdk.api.client.extensions.itemsApi
import org.jellyfin.sdk.api.client.extensions.playlistsApi
import org.jellyfin.sdk.api.client.extensions.suggestionsApi
import org.jellyfin.sdk.api.client.extensions.tvShowsApi
import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult
import org.jellyfin.sdk.model.api.request.GetEpisodesRequest
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.jellyfin.sdk.model.api.request.GetNextUpRequest
import org.jellyfin.sdk.model.api.request.GetPlaylistItemsRequest
import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest
import org.jellyfin.sdk.model.api.request.GetSuggestionsRequest
import timber.log.Timber
@ -271,3 +273,22 @@ val GetSuggestionsRequestHandler =
request: GetSuggestionsRequest,
): Response<BaseItemDtoQueryResult> = api.suggestionsApi.getSuggestions(request)
}
val GetPlaylistItemsRequestHandler =
object : RequestHandler<GetPlaylistItemsRequest> {
override fun prepare(
request: GetPlaylistItemsRequest,
startIndex: Int,
limit: Int,
enableTotalRecordCount: Boolean,
): GetPlaylistItemsRequest =
request.copy(
startIndex = startIndex,
limit = limit,
)
override suspend fun execute(
api: ApiClient,
request: GetPlaylistItemsRequest,
): Response<BaseItemDtoQueryResult> = api.playlistsApi.getPlaylistItems(request)
}