Basic up next implementation for tv shows

This commit is contained in:
Damontecres 2025-10-05 22:01:52 -04:00
parent 25859a05f7
commit 3a255fcd8a
No known key found for this signature in database
6 changed files with 338 additions and 133 deletions

View file

@ -42,7 +42,9 @@ sealed class Destination(
val type: BaseItemKind,
@Transient val item: BaseItem? = null,
val seasonEpisode: SeasonEpisode? = null,
) : Destination()
) : Destination() {
override fun toString(): String = "SeriesOverview(itemId=$itemId, type=$type, seasonEpisode=$seasonEpisode)"
}
@Serializable
data class MediaItem(
@ -50,14 +52,18 @@ sealed class Destination(
val type: BaseItemKind,
@Transient val item: BaseItem? = null,
val seasonEpisode: SeasonEpisode? = null,
) : Destination()
) : Destination() {
override fun toString(): String = "MediaItem(itemId=$itemId, type=$type, seasonEpisode=$seasonEpisode)"
}
@Serializable
data class Playback(
val itemId: UUID,
val positionMs: Long,
@Transient val item: BaseItem? = null,
) : Destination(true)
) : Destination(true) {
override fun toString(): String = "Playback(itemId=$itemId, positionMs=$positionMs)"
}
@Serializable
data object License : Destination(true)

View file

@ -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,
)
}
}
}
}
}

View file

@ -1,7 +1,9 @@
package com.github.damontecres.dolphin.ui.playback
import androidx.activity.compose.BackHandler
import androidx.annotation.OptIn
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
@ -91,6 +93,8 @@ fun PlaybackContent(
var cues by remember { mutableStateOf<List<Cue>>(listOf()) }
var showDebugInfo by remember { mutableStateOf(prefs.showDebugInfo) }
val nextUp by viewModel.nextUpEpisode.observeAsState(null)
// TODO move to viewmodel?
val cueListener =
remember {
@ -155,7 +159,7 @@ fun PlaybackContent(
val keyHandler =
PlaybackKeyHandler(
player = player,
controlsEnabled = true,
controlsEnabled = nextUp == null,
skipWithLeftRight = true,
seekForward = preferences.appPreferences.playbackPreferences.skipForwardMs.milliseconds,
seekBack = preferences.appPreferences.playbackPreferences.skipBackMs.milliseconds,
@ -170,130 +174,162 @@ fun PlaybackContent(
.focusRequester(focusRequester)
.focusable(),
) {
PlayerSurface(
player = player,
surfaceType = SURFACE_TYPE_SURFACE_VIEW,
modifier = scaledModifier,
)
if (presentationState.coverSurface) {
Box(
val playerSize by animateFloatAsState(if (nextUp == null) 1f else .66f)
Box(
modifier =
Modifier
.matchParentSize()
.background(Color.Black),
) {
LoadingPage()
}
}
if (!controllerViewState.controlsVisible && skipIndicatorDuration != 0L) {
SkipIndicator(
durationMs = skipIndicatorDuration,
onFinish = {
skipIndicatorDuration = 0L
},
modifier =
Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 70.dp),
.fillMaxSize(playerSize)
.align(Alignment.TopCenter),
) {
PlayerSurface(
player = player,
surfaceType = SURFACE_TYPE_SURFACE_VIEW,
modifier = scaledModifier,
)
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
if (presentationState.coverSurface) {
Box(
Modifier
.matchParentSize()
.background(Color.Black),
) {
LoadingPage()
}
}
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 (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(
controllerViewState.controlsVisible,
Modifier,
slideInVertically { it },
slideOutVertically { it },
nextUp != null,
modifier =
Modifier
.align(Alignment.BottomCenter),
) {
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),
)
nextUp?.let {
NextUpEpisode(
ep = it,
onClick = { viewModel.playUpNextEpisode() },
modifier =
Modifier
.padding(16.dp)
.height(128.dp)
.fillMaxWidth(.4f)
.align(Alignment.BottomCenter),
)
}
}
}
}

View file

@ -14,10 +14,14 @@ import androidx.media3.common.TrackSelectionOverride
import androidx.media3.common.Tracks
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.preferences.UserPreferences
import com.github.damontecres.dolphin.ui.DefaultItemFields
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.GetEpisodesRequestHandler
import com.github.damontecres.dolphin.util.TrackActivityPlaybackListener
import com.github.damontecres.dolphin.util.TrackSupport
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.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
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.SubtitlePlaybackMode
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.serializer.toUUIDOrNull
import timber.log.Timber
import java.util.UUID
import javax.inject.Inject
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 {
DIRECT_PLAY,
@ -86,9 +96,15 @@ class PlaybackViewModel
val trickplay = MutableLiveData<TrickplayInfo?>(null)
val chapters = MutableLiveData<List<Chapter>>(listOf())
private lateinit var preferences: UserPreferences
private lateinit var deviceProfile: DeviceProfile
private lateinit var itemId: UUID
private lateinit var dto: BaseItemDto
private val episodes = MutableLiveData<ApiRequestPager<GetEpisodesRequest>>()
private var currentEpisodeIndex = Int.MAX_VALUE
val nextUpEpisode = MutableLiveData<BaseItem?>()
init {
addCloseable { player.release() }
}
@ -98,8 +114,11 @@ class PlaybackViewModel
deviceProfile: DeviceProfile,
preferences: UserPreferences,
) {
nextUpEpisode.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
@ -234,6 +253,9 @@ class PlaybackViewModel
this@PlaybackViewModel.chapters.value = Chapter.fromDto(dto, api)
Timber.v("chapters=${this@PlaybackViewModel.chapters.value}")
}
if (base.type == BaseItemKind.EPISODE) {
base.seriesId?.let(::getEpisodes)
}
}
}
@ -411,6 +433,54 @@ class PlaybackViewModel
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(

View file

@ -11,7 +11,6 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
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.GetItemsRequest
import timber.log.Timber
import java.util.function.Predicate
interface RequestHandler<T> {
fun prepare(
@ -83,7 +83,8 @@ class ApiRequestPager<T>(
private val scope: CoroutineScope,
private val pageSize: Int = DEFAULT_PAGE_SIZE,
cacheSize: Long = 8,
) : AbstractList<BaseItem?>() {
) : AbstractList<BaseItem?>(),
BlockingList<BaseItem?> {
private var items by mutableStateOf(ItemList<BaseItem>(0, pageSize, mapOf()))
private var totalCount by mutableIntStateOf(-1)
private val mutex = Mutex()
@ -93,12 +94,13 @@ class ApiRequestPager<T>(
.maximumSize(cacheSize)
.build<Int, List<BaseItem>>()
suspend fun init() {
suspend fun init(): ApiRequestPager<T> {
if (totalCount < 0) {
val newRequest = requestHandler.prepare(request, 0, 1, true)
val result = requestHandler.execute(api, newRequest).content
totalCount = result.totalRecordCount
}
return this
}
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) {
val item = items[position]
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
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 {
private const val DEBUG = false
}

View file

@ -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
}