From f7378188730d5135691d0d1eba108c23eed1a525 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Mon, 13 Oct 2025 13:38:49 -0400 Subject: [PATCH] Refactor PlaybackViewModel to handle generic playlists --- .../dolphin/data/model/Playlist.kt | 74 ++++ .../dolphin/ui/playback/PlaybackControls.kt | 10 +- .../dolphin/ui/playback/PlaybackPage.kt | 32 +- .../dolphin/ui/playback/PlaybackViewModel.kt | 362 +++++++++--------- .../dolphin/util/ApiRequestPager.kt | 21 + 5 files changed, 314 insertions(+), 185 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/dolphin/data/model/Playlist.kt diff --git a/app/src/main/java/com/github/damontecres/dolphin/data/model/Playlist.kt b/app/src/main/java/com/github/damontecres/dolphin/data/model/Playlist.kt new file mode 100644 index 00000000..32406465 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/dolphin/data/model/Playlist.kt @@ -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, + 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) + } + } diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackControls.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackControls.kt index 886308ee..212ce009 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackControls.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackControls.kt @@ -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, diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackPage.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackPage.kt index 4bf0f763..db20299b 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackPage.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackPage.kt @@ -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>(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 diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackViewModel.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackViewModel.kt index 157ce0b1..b333911c 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackViewModel.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackViewModel.kt @@ -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>() - private var currentEpisodeIndex = Int.MAX_VALUE - val nextUpEpisode = MutableLiveData() + val nextUp = MutableLiveData() + private var isPlaylist = false + + val playlist = MutableLiveData(Playlist(listOf())) init { addCloseable { this@PlaybackViewModel.activityListener?.release() } @@ -128,151 +127,179 @@ 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 - dto = base - val title = - if (base.type == BaseItemKind.EPISODE) { - base.seriesName + 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 { - base.name + queriedItem } - val subtitle = - if (base.type == BaseItemKind.EPISODE) { - buildList { - add(base.seasonEpisodePadded) - add(base.name) - add(base.premiereDate?.let { formatDateTime(it) }) - }.filterNotNull().joinToString(" - ") - } else { - base.productionYear?.toString() + + 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 } - withContext(Dispatchers.Main) { - this@PlaybackViewModel.title.value = title - this@PlaybackViewModel.subtitle.value = subtitle } + 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) { + base.seriesName + } else { + base.name + } + val subtitle = + if (base.type == BaseItemKind.EPISODE) { + buildList { + add(base.seasonEpisodePadded) + add(base.name) + add(base.premiereDate?.let { formatDateTime(it) }) + }.filterNotNull().joinToString(" - ") + } else { + base.productionYear?.toString() + } + withContext(Dispatchers.Main) { + this@PlaybackViewModel.title.value = title + this@PlaybackViewModel.subtitle.value = subtitle + } + base.mediaStreams + ?.filter { it.type == MediaStreamType.VIDEO } + ?.forEach { Timber.v("${it.videoRangeType}, ${it.videoRange}") } + val subtitleStreams = base.mediaStreams - ?.filter { it.type == MediaStreamType.VIDEO } - ?.forEach { Timber.v("${it.videoRangeType}, ${it.videoRange}") } - val subtitleStreams = - base.mediaStreams - ?.filter { it.type == MediaStreamType.SUBTITLE } - ?.map { - SubtitleStream( - it.index, - it.language, - it.title, - it.codec, - it.codecTag, - it.isExternal, - it.isForced, - it.isDefault, - ) - }.orEmpty() - val audioStreams = - base.mediaStreams - ?.filter { it.type == MediaStreamType.AUDIO } - ?.map { - AudioStream( - it.index, - it.language, - it.title, - it.codec, - it.codecTag, - it.channels, - it.channelLayout, - ) - }?.sortedWith(compareBy { it.language }.thenByDescending { it.channels }) - .orEmpty() + ?.filter { it.type == MediaStreamType.SUBTITLE } + ?.map { + SubtitleStream( + it.index, + it.language, + it.title, + it.codec, + it.codecTag, + it.isExternal, + it.isForced, + it.isDefault, + ) + }.orEmpty() + val audioStreams = + base.mediaStreams + ?.filter { it.type == MediaStreamType.AUDIO } + ?.map { + AudioStream( + it.index, + it.language, + it.title, + it.codec, + it.codecTag, + it.channels, + it.channelLayout, + ) + }?.sortedWith(compareBy { it.language }.thenByDescending { it.channels }) + .orEmpty() - // TODO audio selection based on channel layout or preferences or default - val audioLanguage = preferences.userConfig.audioLanguagePreference - val audioIndex = - if (audioLanguage != null) { - audioStreams.firstOrNull { it.language == audioLanguage }?.index - ?: audioStreams.firstOrNull()?.index - } else { - audioStreams.firstOrNull()?.index + // TODO audio selection based on channel layout or preferences or default + val audioLanguage = preferences.userConfig.audioLanguagePreference + val audioIndex = + if (audioLanguage != null) { + audioStreams.firstOrNull { it.language == audioLanguage }?.index + ?: audioStreams.firstOrNull()?.index + } else { + audioStreams.firstOrNull()?.index + } + val subtitleMode = preferences.userConfig.subtitleMode + val subtitleLanguage = preferences.userConfig.subtitleLanguagePreference + val subtitleIndex = + when (subtitleMode) { + SubtitlePlaybackMode.ALWAYS -> { + if (subtitleLanguage != null) { + subtitleStreams.firstOrNull { it.language == subtitleLanguage }?.index + } else { + subtitleStreams.firstOrNull()?.index + } } - val subtitleMode = preferences.userConfig.subtitleMode - val subtitleLanguage = preferences.userConfig.subtitleLanguagePreference - val subtitleIndex = - when (subtitleMode) { - SubtitlePlaybackMode.ALWAYS -> { - if (subtitleLanguage != null) { - subtitleStreams.firstOrNull { it.language == subtitleLanguage }?.index - } else { - subtitleStreams.firstOrNull()?.index - } + + SubtitlePlaybackMode.ONLY_FORCED -> + if (subtitleLanguage != null) { + subtitleStreams.firstOrNull { it.language == subtitleLanguage && it.forced }?.index + } else { + subtitleStreams.firstOrNull { it.forced }?.index } - SubtitlePlaybackMode.ONLY_FORCED -> - if (subtitleLanguage != null) { - subtitleStreams.firstOrNull { it.language == subtitleLanguage && it.forced }?.index - } else { - subtitleStreams.firstOrNull { it.forced }?.index - } - - SubtitlePlaybackMode.SMART -> { - if (audioLanguage != null && subtitleLanguage != null && audioLanguage != subtitleLanguage) { - subtitleStreams.firstOrNull { it.language == subtitleLanguage }?.index - } else { - null - } + SubtitlePlaybackMode.SMART -> { + if (audioLanguage != null && subtitleLanguage != null && audioLanguage != subtitleLanguage) { + subtitleStreams.firstOrNull { it.language == subtitleLanguage }?.index + } else { + null } - - SubtitlePlaybackMode.DEFAULT -> { - // TODO check for language? - ( - subtitleStreams.firstOrNull { it.default && it.forced } - ?: subtitleStreams.firstOrNull { it.default } - ?: subtitleStreams.firstOrNull { it.forced } - )?.index - } - - SubtitlePlaybackMode.NONE -> null } + SubtitlePlaybackMode.DEFAULT -> { + // TODO check for language? + ( + subtitleStreams.firstOrNull { it.default && it.forced } + ?: subtitleStreams.firstOrNull { it.default } + ?: subtitleStreams.firstOrNull { it.forced } + )?.index + } + + SubtitlePlaybackMode.NONE -> null + } + // Timber.v("base.mediaStreams=${base.mediaStreams}") // Timber.v("subtitleTracks=$subtitleStreams") // Timber.v("audioStreams=$audioStreams") - Timber.d("Selected audioIndex=$audioIndex, subtitleIndex=$subtitleIndex") + Timber.d("Selected audioIndex=$audioIndex, subtitleIndex=$subtitleIndex") - withContext(Dispatchers.Main) { - this@PlaybackViewModel.audioStreams.value = audioStreams - this@PlaybackViewModel.subtitleStreams.value = subtitleStreams + withContext(Dispatchers.Main) { + this@PlaybackViewModel.audioStreams.value = audioStreams + this@PlaybackViewModel.subtitleStreams.value = subtitleStreams - changeStreams( - itemId, - audioIndex, - subtitleIndex, - if (destination.positionMs > 0) destination.positionMs else C.TIME_UNSET, - ) - player.prepare() + changeStreams( + base, + audioIndex, + subtitleIndex, + 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) - } - listenForSegments() + 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) { - 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}") - withContext(Dispatchers.Main) { - nextUpEpisode.value = nextItem - } + 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 + ExceptionHandler()) { + val nextItem = playlist.peek() + Timber.v("Setting next up to ${nextItem?.id}") + withContext(Dispatchers.Main) { + 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 -// } -// } -// } - } + } + player.addListener(listener) + 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) diff --git a/app/src/main/java/com/github/damontecres/dolphin/util/ApiRequestPager.kt b/app/src/main/java/com/github/damontecres/dolphin/util/ApiRequestPager.kt index 2648db01..88c2c037 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/util/ApiRequestPager.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/util/ApiRequestPager.kt @@ -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 = api.suggestionsApi.getSuggestions(request) } + +val GetPlaylistItemsRequestHandler = + object : RequestHandler { + 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 = api.playlistsApi.getPlaylistItems(request) + }