From 3a255fcd8a0206a2d026408fbee7b0a3dff2c41a Mon Sep 17 00:00:00 2001 From: Damontecres Date: Sun, 5 Oct 2025 22:01:52 -0400 Subject: [PATCH] Basic up next implementation for tv shows --- .../damontecres/dolphin/ui/nav/Destination.kt | 12 +- .../dolphin/ui/playback/NextUpEpisode.kt | 81 ++++++ .../dolphin/ui/playback/PlaybackContent.kt | 268 ++++++++++-------- .../dolphin/ui/playback/PlaybackViewModel.kt | 70 +++++ .../dolphin/util/ApiRequestPager.kt | 31 +- .../damontecres/dolphin/util/BlockingList.kt | 9 + 6 files changed, 338 insertions(+), 133 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/dolphin/ui/playback/NextUpEpisode.kt create mode 100644 app/src/main/java/com/github/damontecres/dolphin/util/BlockingList.kt diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/nav/Destination.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/nav/Destination.kt index ddf3c27e..51fb9117 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/nav/Destination.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/nav/Destination.kt @@ -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) diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/NextUpEpisode.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/NextUpEpisode.kt new file mode 100644 index 00000000..a9063ab7 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/NextUpEpisode.kt @@ -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, + ) + } + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackContent.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackContent.kt index 52f53c0b..eaf37cda 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackContent.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackContent.kt @@ -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>(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), + ) + } } } } diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackViewModel.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackViewModel.kt index a9ea5e3c..942379fa 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackViewModel.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackViewModel.kt @@ -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(null) val chapters = MutableLiveData>(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>() + private var currentEpisodeIndex = Int.MAX_VALUE + val nextUpEpisode = MutableLiveData() + 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( diff --git a/app/src/main/java/com/github/damontecres/dolphin/util/ApiRequestPager.kt b/app/src/main/java/com/github/damontecres/dolphin/util/ApiRequestPager.kt index 6732f165..755a3406 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/util/ApiRequestPager.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/util/ApiRequestPager.kt @@ -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 { fun prepare( @@ -83,7 +83,8 @@ class ApiRequestPager( private val scope: CoroutineScope, private val pageSize: Int = DEFAULT_PAGE_SIZE, cacheSize: Long = 8, -) : AbstractList() { +) : AbstractList(), + BlockingList { private var items by mutableStateOf(ItemList(0, pageSize, mapOf())) private var totalCount by mutableIntStateOf(-1) private val mutex = Mutex() @@ -93,12 +94,13 @@ class ApiRequestPager( .maximumSize(cacheSize) .build>() - suspend fun init() { + suspend fun init(): ApiRequestPager { 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( } } - suspend fun getBlocking(position: Int): BaseItem? { + override suspend fun getBlocking(position: Int): BaseItem? { if (position in 0..( } } + override suspend fun indexOfBlocking(predicate: Predicate): 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( } } - suspend fun toBlockingList(): List { - init() - return object : AbstractList() { - override val size: Int - get() = totalCount - - override fun get(index: Int): BaseItem = runBlocking { getBlocking(index)!! } - } - } - companion object { private const val DEBUG = false } diff --git a/app/src/main/java/com/github/damontecres/dolphin/util/BlockingList.kt b/app/src/main/java/com/github/damontecres/dolphin/util/BlockingList.kt new file mode 100644 index 00000000..ea11698d --- /dev/null +++ b/app/src/main/java/com/github/damontecres/dolphin/util/BlockingList.kt @@ -0,0 +1,9 @@ +package com.github.damontecres.dolphin.util + +import java.util.function.Predicate + +interface BlockingList : List { + suspend fun getBlocking(index: Int): T + + suspend fun indexOfBlocking(predicate: Predicate): Int +}