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

View file

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

View file

@ -16,17 +16,15 @@ import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import com.github.damontecres.dolphin.data.model.BaseItem import com.github.damontecres.dolphin.data.model.BaseItem
import com.github.damontecres.dolphin.data.model.Chapter 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.AppPreference
import com.github.damontecres.dolphin.preferences.SkipSegmentBehavior import com.github.damontecres.dolphin.preferences.SkipSegmentBehavior
import com.github.damontecres.dolphin.preferences.UserPreferences 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.Destination
import com.github.damontecres.dolphin.ui.nav.NavigationManager 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.EqualityMutableLiveData
import com.github.damontecres.dolphin.util.ExceptionHandler 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.TrackActivityPlaybackListener
import com.github.damontecres.dolphin.util.TrackSupport import com.github.damontecres.dolphin.util.TrackSupport
import com.github.damontecres.dolphin.util.checkForSupport 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.PlaybackInfoDto
import org.jellyfin.sdk.model.api.SubtitlePlaybackMode import org.jellyfin.sdk.model.api.SubtitlePlaybackMode
import org.jellyfin.sdk.model.api.TrickplayInfo 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.inWholeTicks
import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.extensions.ticks
import org.jellyfin.sdk.model.serializer.toUUIDOrNull import org.jellyfin.sdk.model.serializer.toUUIDOrNull
@ -86,6 +83,7 @@ class PlaybackViewModel
constructor( constructor(
@ApplicationContext context: Context, @ApplicationContext context: Context,
val api: ApiClient, val api: ApiClient,
val playlistCreator: PlaylistCreator,
val navigationManager: NavigationManager, val navigationManager: NavigationManager,
) : ViewModel() { ) : ViewModel() {
val player: ExoPlayer = val player: ExoPlayer =
@ -114,9 +112,10 @@ class PlaybackViewModel
private lateinit var dto: BaseItemDto private lateinit var dto: BaseItemDto
private var activityListener: TrackActivityPlaybackListener? = null private var activityListener: TrackActivityPlaybackListener? = null
private val episodes = MutableLiveData<ApiRequestPager<GetEpisodesRequest>>() val nextUp = MutableLiveData<BaseItem?>()
private var currentEpisodeIndex = Int.MAX_VALUE private var isPlaylist = false
val nextUpEpisode = MutableLiveData<BaseItem?>()
val playlist = MutableLiveData<Playlist>(Playlist(listOf()))
init { init {
addCloseable { this@PlaybackViewModel.activityListener?.release() } addCloseable { this@PlaybackViewModel.activityListener?.release() }
@ -128,151 +127,179 @@ class PlaybackViewModel
deviceProfile: DeviceProfile, deviceProfile: DeviceProfile,
preferences: UserPreferences, preferences: UserPreferences,
) { ) {
nextUpEpisode.value = null nextUp.value = null
this.preferences = preferences this.preferences = preferences
this.deviceProfile = deviceProfile this.deviceProfile = deviceProfile
val itemId = destination.itemId val itemId = destination.itemId
this.itemId = itemId this.itemId = itemId
val item = destination.item val item = destination.item
viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
val base = item?.data ?: api.userLibraryApi.getItem(itemId).content val queriedItem = item?.data ?: api.userLibraryApi.getItem(itemId).content
dto = base val base =
val title = if (queriedItem.type == BaseItemKind.PLAYLIST) {
if (base.type == BaseItemKind.EPISODE) { isPlaylist = true
base.seriesName val playlist = playlistCreator.createFromPlaylistId(queriedItem.id)
withContext(Dispatchers.Main) {
this@PlaybackViewModel.playlist.value = playlist
}
// TODO start index
playlist.items.first().data
} else { } else {
base.name queriedItem
} }
val subtitle =
if (base.type == BaseItemKind.EPISODE) { play(base, destination.positionMs)
buildList {
add(base.seasonEpisodePadded) if (!isPlaylist && queriedItem.type == BaseItemKind.EPISODE) {
add(base.name) val playlist =
add(base.premiereDate?.let { formatDateTime(it) }) playlistCreator.createFromEpisode(queriedItem.seriesId!!, queriedItem.id)
}.filterNotNull().joinToString(" - ") withContext(Dispatchers.Main) {
} else { this@PlaybackViewModel.playlist.value = playlist
base.productionYear?.toString()
} }
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 base.mediaStreams
?.filter { it.type == MediaStreamType.VIDEO } ?.filter { it.type == MediaStreamType.SUBTITLE }
?.forEach { Timber.v("${it.videoRangeType}, ${it.videoRange}") } ?.map {
val subtitleStreams = SubtitleStream(
base.mediaStreams it.index,
?.filter { it.type == MediaStreamType.SUBTITLE } it.language,
?.map { it.title,
SubtitleStream( it.codec,
it.index, it.codecTag,
it.language, it.isExternal,
it.title, it.isForced,
it.codec, it.isDefault,
it.codecTag, )
it.isExternal, }.orEmpty()
it.isForced, val audioStreams =
it.isDefault, base.mediaStreams
) ?.filter { it.type == MediaStreamType.AUDIO }
}.orEmpty() ?.map {
val audioStreams = AudioStream(
base.mediaStreams it.index,
?.filter { it.type == MediaStreamType.AUDIO } it.language,
?.map { it.title,
AudioStream( it.codec,
it.index, it.codecTag,
it.language, it.channels,
it.title, it.channelLayout,
it.codec, )
it.codecTag, }?.sortedWith(compareBy<AudioStream> { it.language }.thenByDescending { it.channels })
it.channels, .orEmpty()
it.channelLayout,
)
}?.sortedWith(compareBy<AudioStream> { it.language }.thenByDescending { it.channels })
.orEmpty()
// TODO audio selection based on channel layout or preferences or default // TODO audio selection based on channel layout or preferences or default
val audioLanguage = preferences.userConfig.audioLanguagePreference val audioLanguage = preferences.userConfig.audioLanguagePreference
val audioIndex = val audioIndex =
if (audioLanguage != null) { if (audioLanguage != null) {
audioStreams.firstOrNull { it.language == audioLanguage }?.index audioStreams.firstOrNull { it.language == audioLanguage }?.index
?: audioStreams.firstOrNull()?.index ?: audioStreams.firstOrNull()?.index
} else { } else {
audioStreams.firstOrNull()?.index 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 SubtitlePlaybackMode.ONLY_FORCED ->
val subtitleIndex = if (subtitleLanguage != null) {
when (subtitleMode) { subtitleStreams.firstOrNull { it.language == subtitleLanguage && it.forced }?.index
SubtitlePlaybackMode.ALWAYS -> { } else {
if (subtitleLanguage != null) { subtitleStreams.firstOrNull { it.forced }?.index
subtitleStreams.firstOrNull { it.language == subtitleLanguage }?.index
} else {
subtitleStreams.firstOrNull()?.index
}
} }
SubtitlePlaybackMode.ONLY_FORCED -> SubtitlePlaybackMode.SMART -> {
if (subtitleLanguage != null) { if (audioLanguage != null && subtitleLanguage != null && audioLanguage != subtitleLanguage) {
subtitleStreams.firstOrNull { it.language == subtitleLanguage && it.forced }?.index subtitleStreams.firstOrNull { it.language == subtitleLanguage }?.index
} else { } else {
subtitleStreams.firstOrNull { it.forced }?.index 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("base.mediaStreams=${base.mediaStreams}")
// Timber.v("subtitleTracks=$subtitleStreams") // Timber.v("subtitleTracks=$subtitleStreams")
// Timber.v("audioStreams=$audioStreams") // Timber.v("audioStreams=$audioStreams")
Timber.d("Selected audioIndex=$audioIndex, subtitleIndex=$subtitleIndex") Timber.d("Selected audioIndex=$audioIndex, subtitleIndex=$subtitleIndex")
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
this@PlaybackViewModel.audioStreams.value = audioStreams this@PlaybackViewModel.audioStreams.value = audioStreams
this@PlaybackViewModel.subtitleStreams.value = subtitleStreams this@PlaybackViewModel.subtitleStreams.value = subtitleStreams
changeStreams( changeStreams(
itemId, base,
audioIndex, audioIndex,
subtitleIndex, subtitleIndex,
if (destination.positionMs > 0) destination.positionMs else C.TIME_UNSET, if (positionMs > 0) positionMs else C.TIME_UNSET,
) )
player.prepare() player.prepare()
this@PlaybackViewModel.chapters.value = Chapter.fromDto(dto, api) this@PlaybackViewModel.chapters.value = Chapter.fromDto(base, api)
Timber.v("chapters=${this@PlaybackViewModel.chapters.value}") Timber.v("chapters=${this@PlaybackViewModel.chapters.value?.size}")
}
if (base.type == BaseItemKind.EPISODE) {
base.seriesId?.let(::getEpisodes)
}
listenForSegments()
} }
listenForSegments()
} }
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
private suspend fun changeStreams( private suspend fun changeStreams(
itemId: UUID, item: BaseItemDto,
audioIndex: Int?, audioIndex: Int?,
subtitleIndex: Int?, subtitleIndex: Int?,
positionMs: Long = C.TIME_UNSET, positionMs: Long = C.TIME_UNSET,
) { ) {
val itemId = item.id
if (currentPlayback.value?.let { if (currentPlayback.value?.let {
it.itemId == itemId && it.itemId == itemId &&
it.audioIndex == audioIndex && it.audioIndex == audioIndex &&
@ -416,7 +443,7 @@ class PlaybackViewModel
} }
} }
val trickPlayInfo = val trickPlayInfo =
dto.trickplay item.trickplay
?.get(source.id) ?.get(source.id)
?.values ?.values
?.firstOrNull() ?.firstOrNull()
@ -431,7 +458,7 @@ class PlaybackViewModel
val itemId = currentPlayback.value?.itemId ?: return val itemId = currentPlayback.value?.itemId ?: return
viewModelScope.launch(ExceptionHandler()) { viewModelScope.launch(ExceptionHandler()) {
changeStreams( changeStreams(
itemId, dto,
index, index,
currentPlayback.value?.subtitleIndex, currentPlayback.value?.subtitleIndex,
player.currentPosition, player.currentPosition,
@ -443,7 +470,7 @@ class PlaybackViewModel
val itemId = currentPlayback.value?.itemId ?: return val itemId = currentPlayback.value?.itemId ?: return
viewModelScope.launch(ExceptionHandler()) { viewModelScope.launch(ExceptionHandler()) {
changeStreams( changeStreams(
itemId, dto,
currentPlayback.value?.audioIndex, currentPlayback.value?.audioIndex,
index, index,
player.currentPosition, player.currentPosition,
@ -463,56 +490,26 @@ class PlaybackViewModel
) )
} }
fun getEpisodes(seriesId: UUID) { private fun maybeSetupPlaylistListener() {
viewModelScope.launch(Dispatchers.IO) { playlist.value?.let { playlist ->
val request = if (playlist.hasNext()) {
GetEpisodesRequest( Timber.v("Adding lister for playlist with ${playlist.items.size} items")
seriesId = seriesId, val listener =
fields = DefaultItemFields, object : Player.Listener {
startItemId = itemId, override fun onPlaybackStateChanged(playbackState: Int) {
limit = 2, if (playbackState == Player.STATE_ENDED) {
) viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) {
val episodes = GetEpisodesRequestHandler.execute(api, request).content.items val nextItem = playlist.peek()
val currentEpisodeIndex = episodes.indexOfFirstOrNull { it.id == itemId } Timber.v("Setting next up to ${nextItem?.id}")
Timber.v("Current episode is $currentEpisodeIndex of ${episodes.size}") withContext(Dispatchers.Main) {
if (currentEpisodeIndex != null) { nextUp.value = nextItem
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
}
} }
player.removeListener(this)
} }
} }
} }
player.addListener(listener) }
player.addListener(listener)
// viewModelScope.launch(Dispatchers.IO) { addCloseable { player.removeListener(listener) }
// 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
// }
// }
// }
}
} }
} }
} }
@ -576,14 +573,30 @@ class PlaybackViewModel
} }
} }
fun playUpNextEpisode() { fun playUpNextUp() {
nextUpEpisode.value?.let { playlist.value?.let {
init(Destination.Playback(it.id, 0, it), deviceProfile, preferences) 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() { 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.ApiClient
import org.jellyfin.sdk.api.client.Response import org.jellyfin.sdk.api.client.Response
import org.jellyfin.sdk.api.client.extensions.itemsApi 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.suggestionsApi
import org.jellyfin.sdk.api.client.extensions.tvShowsApi import org.jellyfin.sdk.api.client.extensions.tvShowsApi
import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult
import org.jellyfin.sdk.model.api.request.GetEpisodesRequest import org.jellyfin.sdk.model.api.request.GetEpisodesRequest
import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.jellyfin.sdk.model.api.request.GetNextUpRequest 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.GetResumeItemsRequest
import org.jellyfin.sdk.model.api.request.GetSuggestionsRequest import org.jellyfin.sdk.model.api.request.GetSuggestionsRequest
import timber.log.Timber import timber.log.Timber
@ -271,3 +273,22 @@ val GetSuggestionsRequestHandler =
request: GetSuggestionsRequest, request: GetSuggestionsRequest,
): Response<BaseItemDtoQueryResult> = api.suggestionsApi.getSuggestions(request) ): 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)
}