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

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

View file

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

View file

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

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
}