mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 23:51:21 +02:00
Refactor PlaybackViewModel to handle generic playlists
This commit is contained in:
parent
3906eb196a
commit
f737818873
5 changed files with 314 additions and 185 deletions
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,14 +127,45 @@ 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
|
||||||
|
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
|
dto = base
|
||||||
val title =
|
val title =
|
||||||
if (base.type == BaseItemKind.EPISODE) {
|
if (base.type == BaseItemKind.EPISODE) {
|
||||||
|
|
@ -249,30 +279,27 @@ class PlaybackViewModel
|
||||||
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,
|
|
||||||
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 =
|
val listener =
|
||||||
object : Player.Listener {
|
object : Player.Listener {
|
||||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||||
if (playbackState == Player.STATE_ENDED) {
|
if (playbackState == Player.STATE_ENDED) {
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) {
|
||||||
val nextItem = BaseItem.from(episodes[nextIndex], api)
|
val nextItem = playlist.peek()
|
||||||
Timber.v("Setting next up episode to ${nextItem.id}")
|
Timber.v("Setting next up to ${nextItem?.id}")
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
nextUpEpisode.value = nextItem
|
nextUp.value = nextItem
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
player.removeListener(this)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
player.addListener(listener)
|
player.addListener(listener)
|
||||||
|
addCloseable { player.removeListener(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
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue