mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 23:51:21 +02:00
Basic up next implementation for tv shows
This commit is contained in:
parent
25859a05f7
commit
3a255fcd8a
6 changed files with 338 additions and 133 deletions
|
|
@ -42,7 +42,9 @@ sealed class Destination(
|
||||||
val type: BaseItemKind,
|
val type: BaseItemKind,
|
||||||
@Transient val item: BaseItem? = null,
|
@Transient val item: BaseItem? = null,
|
||||||
val seasonEpisode: SeasonEpisode? = null,
|
val seasonEpisode: SeasonEpisode? = null,
|
||||||
) : Destination()
|
) : Destination() {
|
||||||
|
override fun toString(): String = "SeriesOverview(itemId=$itemId, type=$type, seasonEpisode=$seasonEpisode)"
|
||||||
|
}
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class MediaItem(
|
data class MediaItem(
|
||||||
|
|
@ -50,14 +52,18 @@ sealed class Destination(
|
||||||
val type: BaseItemKind,
|
val type: BaseItemKind,
|
||||||
@Transient val item: BaseItem? = null,
|
@Transient val item: BaseItem? = null,
|
||||||
val seasonEpisode: SeasonEpisode? = null,
|
val seasonEpisode: SeasonEpisode? = null,
|
||||||
) : Destination()
|
) : Destination() {
|
||||||
|
override fun toString(): String = "MediaItem(itemId=$itemId, type=$type, seasonEpisode=$seasonEpisode)"
|
||||||
|
}
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class Playback(
|
data class Playback(
|
||||||
val itemId: UUID,
|
val itemId: UUID,
|
||||||
val positionMs: Long,
|
val positionMs: Long,
|
||||||
@Transient val item: BaseItem? = null,
|
@Transient val item: BaseItem? = null,
|
||||||
) : Destination(true)
|
) : Destination(true) {
|
||||||
|
override fun toString(): String = "Playback(itemId=$itemId, positionMs=$positionMs)"
|
||||||
|
}
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data object License : Destination(true)
|
data object License : Destination(true)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,81 @@
|
||||||
|
package com.github.damontecres.dolphin.ui.playback
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.tv.material3.Card
|
||||||
|
import androidx.tv.material3.MaterialTheme
|
||||||
|
import androidx.tv.material3.Text
|
||||||
|
import coil3.compose.AsyncImage
|
||||||
|
import com.github.damontecres.dolphin.data.model.BaseItem
|
||||||
|
import com.github.damontecres.dolphin.ui.tryRequestFocus
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun NextUpEpisode(
|
||||||
|
ep: BaseItem,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
|
||||||
|
Card(
|
||||||
|
onClick = onClick,
|
||||||
|
modifier = modifier.focusRequester(focusRequester),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = Modifier.padding(8.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Up Next...",
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Box {
|
||||||
|
AsyncImage(
|
||||||
|
model = ep.imageUrl,
|
||||||
|
contentDescription = ep.name,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
modifier = Modifier.fillMaxHeight(),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = ep.data.name ?: "",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = ep.data.overview ?: "",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
package com.github.damontecres.dolphin.ui.playback
|
package com.github.damontecres.dolphin.ui.playback
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.annotation.OptIn
|
import androidx.annotation.OptIn
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
import androidx.compose.animation.slideInVertically
|
import androidx.compose.animation.slideInVertically
|
||||||
import androidx.compose.animation.slideOutVertically
|
import androidx.compose.animation.slideOutVertically
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
|
@ -91,6 +93,8 @@ fun PlaybackContent(
|
||||||
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)
|
||||||
|
|
||||||
// TODO move to viewmodel?
|
// TODO move to viewmodel?
|
||||||
val cueListener =
|
val cueListener =
|
||||||
remember {
|
remember {
|
||||||
|
|
@ -155,7 +159,7 @@ fun PlaybackContent(
|
||||||
val keyHandler =
|
val keyHandler =
|
||||||
PlaybackKeyHandler(
|
PlaybackKeyHandler(
|
||||||
player = player,
|
player = player,
|
||||||
controlsEnabled = true,
|
controlsEnabled = nextUp == null,
|
||||||
skipWithLeftRight = true,
|
skipWithLeftRight = 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,
|
||||||
|
|
@ -170,130 +174,162 @@ fun PlaybackContent(
|
||||||
.focusRequester(focusRequester)
|
.focusRequester(focusRequester)
|
||||||
.focusable(),
|
.focusable(),
|
||||||
) {
|
) {
|
||||||
PlayerSurface(
|
val playerSize by animateFloatAsState(if (nextUp == null) 1f else .66f)
|
||||||
player = player,
|
Box(
|
||||||
surfaceType = SURFACE_TYPE_SURFACE_VIEW,
|
modifier =
|
||||||
modifier = scaledModifier,
|
|
||||||
)
|
|
||||||
if (presentationState.coverSurface) {
|
|
||||||
Box(
|
|
||||||
Modifier
|
Modifier
|
||||||
.matchParentSize()
|
.fillMaxSize(playerSize)
|
||||||
.background(Color.Black),
|
.align(Alignment.TopCenter),
|
||||||
) {
|
) {
|
||||||
LoadingPage()
|
PlayerSurface(
|
||||||
}
|
player = player,
|
||||||
}
|
surfaceType = SURFACE_TYPE_SURFACE_VIEW,
|
||||||
|
modifier = scaledModifier,
|
||||||
if (!controllerViewState.controlsVisible && skipIndicatorDuration != 0L) {
|
|
||||||
SkipIndicator(
|
|
||||||
durationMs = skipIndicatorDuration,
|
|
||||||
onFinish = {
|
|
||||||
skipIndicatorDuration = 0L
|
|
||||||
},
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.align(Alignment.BottomCenter)
|
|
||||||
.padding(bottom = 70.dp),
|
|
||||||
)
|
)
|
||||||
val showSkipProgress = true // TODO get from preferences
|
if (presentationState.coverSurface) {
|
||||||
if (showSkipProgress) {
|
Box(
|
||||||
duration?.let {
|
Modifier
|
||||||
val percent = (skipPosition.milliseconds / it).toFloat()
|
.matchParentSize()
|
||||||
Box(
|
.background(Color.Black),
|
||||||
modifier =
|
) {
|
||||||
Modifier
|
LoadingPage()
|
||||||
.align(Alignment.BottomStart)
|
}
|
||||||
.background(MaterialTheme.colorScheme.border)
|
}
|
||||||
.clip(RectangleShape)
|
|
||||||
.height(3.dp)
|
if (!controllerViewState.controlsVisible && skipIndicatorDuration != 0L) {
|
||||||
.fillMaxWidth(percent),
|
SkipIndicator(
|
||||||
) {
|
durationMs = skipIndicatorDuration,
|
||||||
// No-op
|
onFinish = {
|
||||||
|
skipIndicatorDuration = 0L
|
||||||
|
},
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.BottomCenter)
|
||||||
|
.padding(bottom = 70.dp),
|
||||||
|
)
|
||||||
|
val showSkipProgress = true // TODO get from preferences
|
||||||
|
if (showSkipProgress) {
|
||||||
|
duration?.let {
|
||||||
|
val percent = (skipPosition.milliseconds / it).toFloat()
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.BottomStart)
|
||||||
|
.background(MaterialTheme.colorScheme.border)
|
||||||
|
.clip(RectangleShape)
|
||||||
|
.height(3.dp)
|
||||||
|
.fillMaxWidth(percent),
|
||||||
|
) {
|
||||||
|
// No-op
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AnimatedVisibility(
|
||||||
|
controllerViewState.controlsVisible,
|
||||||
|
Modifier,
|
||||||
|
slideInVertically { it },
|
||||||
|
slideOutVertically { it },
|
||||||
|
) {
|
||||||
|
PlaybackOverlay(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.padding(WindowInsets.systemBars.asPaddingValues())
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color.Transparent),
|
||||||
|
title = title,
|
||||||
|
subtitle = subtitle,
|
||||||
|
subtitleStreams = subtitleStreams,
|
||||||
|
playerControls = player,
|
||||||
|
controllerViewState = controllerViewState,
|
||||||
|
showPlay = playPauseState.showPlay,
|
||||||
|
previousEnabled = previousState.isEnabled,
|
||||||
|
nextEnabled = nextState.isEnabled,
|
||||||
|
seekEnabled = true,
|
||||||
|
seekForward = preferences.appPreferences.playbackPreferences.skipForwardMs.milliseconds,
|
||||||
|
seekBack = preferences.appPreferences.playbackPreferences.skipBackMs.milliseconds,
|
||||||
|
onPlaybackActionClick = {
|
||||||
|
when (it) {
|
||||||
|
is PlaybackAction.PlaybackSpeed -> {
|
||||||
|
playbackSpeed = it.value
|
||||||
|
}
|
||||||
|
|
||||||
|
is PlaybackAction.Scale -> {
|
||||||
|
contentScale = it.scale
|
||||||
|
}
|
||||||
|
|
||||||
|
PlaybackAction.ShowDebug -> {
|
||||||
|
showDebugInfo = !showDebugInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
PlaybackAction.ShowPlaylist -> TODO()
|
||||||
|
PlaybackAction.ShowVideoFilterDialog -> TODO()
|
||||||
|
is PlaybackAction.ToggleAudio -> {
|
||||||
|
viewModel.changeAudioStream(it.index)
|
||||||
|
}
|
||||||
|
|
||||||
|
is PlaybackAction.ToggleCaptions -> {
|
||||||
|
viewModel.changeSubtitleStream(it.index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSeekBarChange = seekBarState::onValueChange,
|
||||||
|
showDebugInfo = showDebugInfo,
|
||||||
|
scale = contentScale,
|
||||||
|
playbackSpeed = playbackSpeed,
|
||||||
|
moreButtonOptions = MoreButtonOptions(mapOf()),
|
||||||
|
currentPlayback = currentPlayback,
|
||||||
|
audioStreams = audioStreams,
|
||||||
|
trickplayInfo = trickplay,
|
||||||
|
trickplayUrlFor = viewModel::getTrickplayUrl,
|
||||||
|
chapters = chapters,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!controllerViewState.controlsVisible && skipIndicatorDuration == 0L && currentPlayback?.subtitleIndex != null) {
|
||||||
|
AndroidView(
|
||||||
|
factory = { context ->
|
||||||
|
SubtitleView(context).apply {
|
||||||
|
setUserDefaultStyle()
|
||||||
|
setUserDefaultTextSize()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
update = {
|
||||||
|
it.setCues(cues)
|
||||||
|
},
|
||||||
|
onReset = {
|
||||||
|
it.setCues(null)
|
||||||
|
},
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color.Transparent),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
BackHandler(nextUp != null) {
|
||||||
|
viewModel.cancelUpNextEpisode()
|
||||||
|
}
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
controllerViewState.controlsVisible,
|
nextUp != null,
|
||||||
Modifier,
|
modifier =
|
||||||
slideInVertically { it },
|
Modifier
|
||||||
slideOutVertically { it },
|
.align(Alignment.BottomCenter),
|
||||||
) {
|
) {
|
||||||
PlaybackOverlay(
|
nextUp?.let {
|
||||||
modifier =
|
NextUpEpisode(
|
||||||
Modifier
|
ep = it,
|
||||||
.padding(WindowInsets.systemBars.asPaddingValues())
|
onClick = { viewModel.playUpNextEpisode() },
|
||||||
.fillMaxSize()
|
modifier =
|
||||||
.background(Color.Transparent),
|
Modifier
|
||||||
title = title,
|
.padding(16.dp)
|
||||||
subtitle = subtitle,
|
.height(128.dp)
|
||||||
subtitleStreams = subtitleStreams,
|
.fillMaxWidth(.4f)
|
||||||
playerControls = player,
|
.align(Alignment.BottomCenter),
|
||||||
controllerViewState = controllerViewState,
|
)
|
||||||
showPlay = playPauseState.showPlay,
|
}
|
||||||
previousEnabled = previousState.isEnabled,
|
|
||||||
nextEnabled = nextState.isEnabled,
|
|
||||||
seekEnabled = true,
|
|
||||||
seekForward = preferences.appPreferences.playbackPreferences.skipForwardMs.milliseconds,
|
|
||||||
seekBack = preferences.appPreferences.playbackPreferences.skipBackMs.milliseconds,
|
|
||||||
onPlaybackActionClick = {
|
|
||||||
when (it) {
|
|
||||||
is PlaybackAction.PlaybackSpeed -> {
|
|
||||||
playbackSpeed = it.value
|
|
||||||
}
|
|
||||||
|
|
||||||
is PlaybackAction.Scale -> {
|
|
||||||
contentScale = it.scale
|
|
||||||
}
|
|
||||||
|
|
||||||
PlaybackAction.ShowDebug -> {
|
|
||||||
showDebugInfo = !showDebugInfo
|
|
||||||
}
|
|
||||||
PlaybackAction.ShowPlaylist -> TODO()
|
|
||||||
PlaybackAction.ShowVideoFilterDialog -> TODO()
|
|
||||||
is PlaybackAction.ToggleAudio -> {
|
|
||||||
viewModel.changeAudioStream(it.index)
|
|
||||||
}
|
|
||||||
|
|
||||||
is PlaybackAction.ToggleCaptions -> {
|
|
||||||
viewModel.changeSubtitleStream(it.index)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onSeekBarChange = seekBarState::onValueChange,
|
|
||||||
showDebugInfo = showDebugInfo,
|
|
||||||
scale = contentScale,
|
|
||||||
playbackSpeed = playbackSpeed,
|
|
||||||
moreButtonOptions = MoreButtonOptions(mapOf()),
|
|
||||||
currentPlayback = currentPlayback,
|
|
||||||
audioStreams = audioStreams,
|
|
||||||
trickplayInfo = trickplay,
|
|
||||||
trickplayUrlFor = viewModel::getTrickplayUrl,
|
|
||||||
chapters = chapters,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!controllerViewState.controlsVisible && skipIndicatorDuration == 0L && currentPlayback?.subtitleIndex != null) {
|
|
||||||
AndroidView(
|
|
||||||
factory = { context ->
|
|
||||||
SubtitleView(context).apply {
|
|
||||||
setUserDefaultStyle()
|
|
||||||
setUserDefaultTextSize()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
update = {
|
|
||||||
it.setCues(cues)
|
|
||||||
},
|
|
||||||
onReset = {
|
|
||||||
it.setCues(null)
|
|
||||||
},
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.background(Color.Transparent),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,10 +14,14 @@ import androidx.media3.common.TrackSelectionOverride
|
||||||
import androidx.media3.common.Tracks
|
import androidx.media3.common.Tracks
|
||||||
import androidx.media3.common.util.UnstableApi
|
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.Chapter
|
import com.github.damontecres.dolphin.data.model.Chapter
|
||||||
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.nav.Destination
|
import com.github.damontecres.dolphin.ui.nav.Destination
|
||||||
|
import com.github.damontecres.dolphin.util.ApiRequestPager
|
||||||
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
|
||||||
|
|
@ -26,6 +30,8 @@ import com.github.damontecres.dolphin.util.subtitleMimeTypes
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
|
@ -41,12 +47,16 @@ import org.jellyfin.sdk.model.api.MediaStreamType
|
||||||
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.ticks
|
import org.jellyfin.sdk.model.extensions.ticks
|
||||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
import kotlin.time.Duration.Companion.minutes
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
enum class TranscodeType {
|
enum class TranscodeType {
|
||||||
DIRECT_PLAY,
|
DIRECT_PLAY,
|
||||||
|
|
@ -86,9 +96,15 @@ class PlaybackViewModel
|
||||||
val trickplay = MutableLiveData<TrickplayInfo?>(null)
|
val trickplay = MutableLiveData<TrickplayInfo?>(null)
|
||||||
val chapters = MutableLiveData<List<Chapter>>(listOf())
|
val chapters = MutableLiveData<List<Chapter>>(listOf())
|
||||||
|
|
||||||
|
private lateinit var preferences: UserPreferences
|
||||||
private lateinit var deviceProfile: DeviceProfile
|
private lateinit var deviceProfile: DeviceProfile
|
||||||
|
private lateinit var itemId: UUID
|
||||||
private lateinit var dto: BaseItemDto
|
private lateinit var dto: BaseItemDto
|
||||||
|
|
||||||
|
private val episodes = MutableLiveData<ApiRequestPager<GetEpisodesRequest>>()
|
||||||
|
private var currentEpisodeIndex = Int.MAX_VALUE
|
||||||
|
val nextUpEpisode = MutableLiveData<BaseItem?>()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
addCloseable { player.release() }
|
addCloseable { player.release() }
|
||||||
}
|
}
|
||||||
|
|
@ -98,8 +114,11 @@ class PlaybackViewModel
|
||||||
deviceProfile: DeviceProfile,
|
deviceProfile: DeviceProfile,
|
||||||
preferences: UserPreferences,
|
preferences: UserPreferences,
|
||||||
) {
|
) {
|
||||||
|
nextUpEpisode.value = null
|
||||||
|
this.preferences = preferences
|
||||||
this.deviceProfile = deviceProfile
|
this.deviceProfile = deviceProfile
|
||||||
val itemId = destination.itemId
|
val itemId = destination.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 base = item?.data ?: api.userLibraryApi.getItem(itemId).content
|
||||||
|
|
@ -234,6 +253,9 @@ class PlaybackViewModel
|
||||||
this@PlaybackViewModel.chapters.value = Chapter.fromDto(dto, api)
|
this@PlaybackViewModel.chapters.value = Chapter.fromDto(dto, api)
|
||||||
Timber.v("chapters=${this@PlaybackViewModel.chapters.value}")
|
Timber.v("chapters=${this@PlaybackViewModel.chapters.value}")
|
||||||
}
|
}
|
||||||
|
if (base.type == BaseItemKind.EPISODE) {
|
||||||
|
base.seriesId?.let(::getEpisodes)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -411,6 +433,54 @@ class PlaybackViewModel
|
||||||
mediaSourceId,
|
mediaSourceId,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getEpisodes(seriesId: UUID) {
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
val episodes =
|
||||||
|
if (!this@PlaybackViewModel.episodes.isInitialized) {
|
||||||
|
val request =
|
||||||
|
GetEpisodesRequest(seriesId = seriesId, fields = DefaultItemFields)
|
||||||
|
val pager =
|
||||||
|
ApiRequestPager(api, request, GetEpisodesRequestHandler, viewModelScope)
|
||||||
|
pager.init()
|
||||||
|
currentEpisodeIndex = pager.indexOfBlocking { it?.id == itemId }
|
||||||
|
pager
|
||||||
|
} else {
|
||||||
|
this@PlaybackViewModel.episodes.value!!
|
||||||
|
}
|
||||||
|
Timber.v("Current episode is $currentEpisodeIndex of ${episodes.size}")
|
||||||
|
val nextIndex = currentEpisodeIndex + 1
|
||||||
|
if (nextIndex < episodes.size) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun playUpNextEpisode() {
|
||||||
|
nextUpEpisode.value?.let {
|
||||||
|
init(Destination.Playback(it.id, 0, it), deviceProfile, preferences)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancelUpNextEpisode() {
|
||||||
|
nextUpEpisode.value = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data class CurrentPlayback(
|
data class CurrentPlayback(
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.runBlocking
|
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
|
@ -22,6 +21,7 @@ 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 timber.log.Timber
|
import timber.log.Timber
|
||||||
|
import java.util.function.Predicate
|
||||||
|
|
||||||
interface RequestHandler<T> {
|
interface RequestHandler<T> {
|
||||||
fun prepare(
|
fun prepare(
|
||||||
|
|
@ -83,7 +83,8 @@ class ApiRequestPager<T>(
|
||||||
private val scope: CoroutineScope,
|
private val scope: CoroutineScope,
|
||||||
private val pageSize: Int = DEFAULT_PAGE_SIZE,
|
private val pageSize: Int = DEFAULT_PAGE_SIZE,
|
||||||
cacheSize: Long = 8,
|
cacheSize: Long = 8,
|
||||||
) : AbstractList<BaseItem?>() {
|
) : AbstractList<BaseItem?>(),
|
||||||
|
BlockingList<BaseItem?> {
|
||||||
private var items by mutableStateOf(ItemList<BaseItem>(0, pageSize, mapOf()))
|
private var items by mutableStateOf(ItemList<BaseItem>(0, pageSize, mapOf()))
|
||||||
private var totalCount by mutableIntStateOf(-1)
|
private var totalCount by mutableIntStateOf(-1)
|
||||||
private val mutex = Mutex()
|
private val mutex = Mutex()
|
||||||
|
|
@ -93,12 +94,13 @@ class ApiRequestPager<T>(
|
||||||
.maximumSize(cacheSize)
|
.maximumSize(cacheSize)
|
||||||
.build<Int, List<BaseItem>>()
|
.build<Int, List<BaseItem>>()
|
||||||
|
|
||||||
suspend fun init() {
|
suspend fun init(): ApiRequestPager<T> {
|
||||||
if (totalCount < 0) {
|
if (totalCount < 0) {
|
||||||
val newRequest = requestHandler.prepare(request, 0, 1, true)
|
val newRequest = requestHandler.prepare(request, 0, 1, true)
|
||||||
val result = requestHandler.execute(api, newRequest).content
|
val result = requestHandler.execute(api, newRequest).content
|
||||||
totalCount = result.totalRecordCount
|
totalCount = result.totalRecordCount
|
||||||
}
|
}
|
||||||
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
override operator fun get(index: Int): BaseItem? {
|
override operator fun get(index: Int): BaseItem? {
|
||||||
|
|
@ -113,7 +115,7 @@ class ApiRequestPager<T>(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getBlocking(position: Int): BaseItem? {
|
override suspend fun getBlocking(position: Int): BaseItem? {
|
||||||
if (position in 0..<totalCount) {
|
if (position in 0..<totalCount) {
|
||||||
val item = items[position]
|
val item = items[position]
|
||||||
if (item == null) {
|
if (item == null) {
|
||||||
|
|
@ -126,6 +128,17 @@ class ApiRequestPager<T>(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun indexOfBlocking(predicate: Predicate<BaseItem?>): Int {
|
||||||
|
init()
|
||||||
|
for (i in 0 until totalCount) {
|
||||||
|
val currentItem = getBlocking(i)
|
||||||
|
if (currentItem != null && predicate.test(currentItem)) {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
override val size: Int
|
override val size: Int
|
||||||
get() = totalCount
|
get() = totalCount
|
||||||
|
|
||||||
|
|
@ -150,16 +163,6 @@ class ApiRequestPager<T>(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun toBlockingList(): List<BaseItem> {
|
|
||||||
init()
|
|
||||||
return object : AbstractList<BaseItem>() {
|
|
||||||
override val size: Int
|
|
||||||
get() = totalCount
|
|
||||||
|
|
||||||
override fun get(index: Int): BaseItem = runBlocking { getBlocking(index)!! }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val DEBUG = false
|
private const val DEBUG = false
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
package com.github.damontecres.dolphin.util
|
||||||
|
|
||||||
|
import java.util.function.Predicate
|
||||||
|
|
||||||
|
interface BlockingList<T> : List<T> {
|
||||||
|
suspend fun getBlocking(index: Int): T
|
||||||
|
|
||||||
|
suspend fun indexOfBlocking(predicate: Predicate<T>): Int
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue