Add support for cinema mode (#1305)

## Description
Adds support for cinema mode, which is enabled by default.

When enabled and if starting playback from the beginning, the server is
queried for any "intros", ie prerolls or trailers, to play first. These
are shown in the queue and can be skipped if desired.

The server has to provide the intros, so a plugin like
https://github.com/jellyfin/jellyfin-plugin-intros or
https://github.com/CherryFloors/jellyfin-plugin-cinemamode is required
server-side.

### Related issues
Closes #418
Closes #1180

### Testing
Emulator and server w/
https://github.com/CherryFloors/jellyfin-plugin-cinemamode

## Screenshots
N/A

## AI or LLM usage
None
This commit is contained in:
Damontecres 2026-04-26 10:16:26 -04:00 committed by GitHub
parent ea6a0227b0
commit edb37f5d69
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 170 additions and 64 deletions

View file

@ -12,7 +12,7 @@ import java.util.UUID
* This is not the same thing as a Jellyfin server playlist
*/
class Playlist(
items: List<BaseItem>,
items: List<PlaylistItem>,
startIndex: Int = 0,
) {
val items = items.subList(startIndex, items.size)
@ -23,15 +23,15 @@ class Playlist(
fun hasNext(): Boolean = (index + 1) < items.size
fun getPreviousAndReverse(): BaseItem = items[--index]
fun getPreviousAndReverse(): PlaylistItem = items[--index]
fun getAndAdvance(): BaseItem = items[++index]
fun getAndAdvance(): PlaylistItem = items[++index]
fun peek(): BaseItem? = items.getOrNull(index + 1)
fun peek(): PlaylistItem? = items.getOrNull(index + 1)
fun upcomingItems(): List<BaseItem> = items.subList(index + 1, items.size)
fun upcomingItems(): List<PlaylistItem> = items.subList(index + 1, items.size)
fun advanceTo(id: UUID): BaseItem? {
fun advanceTo(id: UUID): PlaylistItem? {
while (hasNext()) {
val potential = getAndAdvance()
if (potential.id == id) {
@ -52,3 +52,22 @@ data class PlaylistInfo(
val count: Int,
val mediaType: MediaType,
)
sealed interface PlaylistItem {
val id: UUID
val item: BaseItem
data class Media(
override val item: BaseItem,
) : PlaylistItem {
override val id: UUID
get() = item.id
}
data class Intro(
override val item: BaseItem,
) : PlaylistItem {
override val id: UUID
get() = item.id
}
}

View file

@ -455,6 +455,18 @@ sealed interface AppPreference<Pref, T> {
summaryOff = R.string.disabled,
)
val CinemaMode =
AppSwitchPreference<AppPreferences>(
title = R.string.cinema_mode,
defaultValue = true,
getter = { it.playbackPreferences.cinemaMode },
setter = { prefs, value ->
prefs.updatePlaybackPreferences { cinemaMode = value }
},
summaryOn = R.string.enabled,
summaryOff = R.string.disabled,
)
val RememberSelectedTab =
AppSwitchPreference<AppPreferences>(
title = R.string.remember_selected_tab,
@ -1166,6 +1178,7 @@ val advancedPreferences =
preferences =
listOf(
AppPreference.OneClickPause,
AppPreference.CinemaMode,
AppPreference.GlobalContentScale,
AppPreference.SkipSegments,
AppPreference.MaxBitrate,

View file

@ -54,6 +54,7 @@ class AppPreferencesSerializer
refreshRateSwitching =
AppPreference.RefreshRateSwitching.defaultValue
resolutionSwitching = AppPreference.ResolutionSwitching.defaultValue
cinemaMode = AppPreference.CinemaMode.defaultValue
overrides =
PlaybackOverrides

View file

@ -19,6 +19,7 @@ import com.github.damontecres.wholphin.preferences.updateMpvOptions
import com.github.damontecres.wholphin.preferences.updateMusicPreferences
import com.github.damontecres.wholphin.preferences.updatePhotoPreferences
import com.github.damontecres.wholphin.preferences.updatePlaybackOverrides
import com.github.damontecres.wholphin.preferences.updatePlaybackPreferences
import com.github.damontecres.wholphin.preferences.updateScreensaverPreferences
import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences
import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel
@ -327,5 +328,11 @@ class AppUpgradeHandler
}
}
}
if (previous.isEqualOrBefore(Version.fromString("0.6.2-18-g0"))) {
appPreferences.updateData {
it.updatePlaybackPreferences { cinemaMode = AppPreference.CinemaMode.defaultValue }
}
}
}
}

View file

@ -7,12 +7,14 @@ import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.GetItemsFilter
import com.github.damontecres.wholphin.data.model.Playlist
import com.github.damontecres.wholphin.data.model.PlaylistInfo
import com.github.damontecres.wholphin.data.model.PlaylistItem
import com.github.damontecres.wholphin.ui.DefaultItemFields
import com.github.damontecres.wholphin.ui.components.baseItemKinds
import com.github.damontecres.wholphin.ui.data.SortAndDirection
import com.github.damontecres.wholphin.ui.gt
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull
import com.github.damontecres.wholphin.ui.playback.playable
import com.github.damontecres.wholphin.ui.toBaseItems
import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.GetEpisodesRequestHandler
@ -22,6 +24,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.playlistsApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.api.client.extensions.videosApi
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
@ -247,14 +250,14 @@ class PlaylistCreator
-> {
val list =
buildList {
add(BaseItem(item, false))
add(PlaylistItem.Media(BaseItem(item, false)))
if (item.partCount.gt(1)) {
api.videosApi
.getAdditionalPart(item.id)
.content.items
.map {
BaseItem(it, false)
PlaylistItem.Media(BaseItem(it, false))
}.let(::addAll)
}
}
@ -274,14 +277,14 @@ class PlaylistCreator
}
}
private suspend fun List<BaseItemDto>.convertAndAddParts(useSeriesForPrimary: Boolean = false): List<BaseItem> =
private suspend fun List<BaseItemDto>.convertAndAddParts(useSeriesForPrimary: Boolean = false): List<PlaylistItem> =
buildList {
this@convertAndAddParts.forEach { ep ->
add(BaseItem(ep, useSeriesForPrimary))
add(PlaylistItem.Media(BaseItem(ep, useSeriesForPrimary)))
if (ep.partCount.gt(1)) {
val parts =
api.videosApi.getAdditionalPart(ep.id).content.items.map { part ->
BaseItem(part, useSeriesForPrimary)
PlaylistItem.Media(BaseItem(part, useSeriesForPrimary))
}
addAll(parts)
}

View file

@ -24,6 +24,7 @@ import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.data.ItemPlaybackDao
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.PlaylistItem
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.PlaylistCreationResult
@ -105,9 +106,9 @@ class PlayExternalViewModel
}
try {
val queriedItem = api.userLibraryApi.getItem(itemId).content
val base =
val playlistItem =
if (queriedItem.type.playable) {
queriedItem
PlaylistItem.Media(BaseItem(queriedItem))
} else if (destination is Destination.PlaybackList) {
val playlistResult =
playlistCreator.createFrom(
@ -134,18 +135,15 @@ class PlayExternalViewModel
navigationManager.goBack()
return@launchDefault
}
r.playlist.items
.first()
.data
r.playlist.items.first()
}
}
} else {
throw IllegalArgumentException("Item is not playable and not PlaybackList: ${queriedItem.type}")
}
val item = BaseItem(base, false)
val playbackConfig =
serverRepository.currentUser.value?.let { user ->
itemPlaybackDao.getItem(user, base.id)?.let {
itemPlaybackDao.getItem(user, playlistItem.id)?.let {
Timber.v("Fetched itemPlayback from DB: %s", it)
if (it.sourceId != null) {
it
@ -154,20 +152,25 @@ class PlayExternalViewModel
}
}
}
val mediaSource = streamChoiceService.chooseSource(base, playbackConfig)
val plc = streamChoiceService.getPlaybackLanguageChoice(base)
val item =
when (playlistItem) {
is PlaylistItem.Intro -> playlistItem.item
is PlaylistItem.Media -> playlistItem.item
}
val mediaSource = streamChoiceService.chooseSource(item.data, playbackConfig)
val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
if (mediaSource == null) {
Timber.w("Media source is null")
return@launchDefault
}
savedStateHandle[KEY_ID] = base.id
savedStateHandle[KEY_ID] = playlistItem.id
savedStateHandle[KEY_MEDIA_ID] = mediaSource.id
val subtitleIndex =
streamChoiceService
.chooseSubtitleStream(
source = mediaSource,
audioStream = null,
seriesId = base.seriesId,
seriesId = item.data.seriesId,
itemPlayback = playbackConfig,
plc = plc,
prefs = prefs,

View file

@ -35,6 +35,7 @@ import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.Chapter
import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.data.model.Playlist
import com.github.damontecres.wholphin.data.model.PlaylistItem
import com.github.damontecres.wholphin.data.model.TrackIndex
import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.PlayerBackend
@ -185,7 +186,7 @@ class PlaybackViewModel
private lateinit var preferences: UserPreferences
internal lateinit var itemId: UUID
internal lateinit var item: BaseItem
internal lateinit var currentItem: PlaylistItem
internal var forceTranscoding: Boolean = false
private var activityListener: TrackActivityPlaybackListener? = null
private val jobs = mutableListOf<Job>()
@ -319,9 +320,9 @@ class PlaybackViewModel
}
this.itemId = itemId
val queriedItem = api.userLibraryApi.getItem(itemId).content
val base =
val playlistItem =
if (queriedItem.type.playable) {
queriedItem
PlaylistItem.Media(BaseItem(queriedItem, false))
} else if (destination is Destination.PlaybackList) {
val playlistResult =
playlistCreator.createFrom(
@ -349,9 +350,7 @@ class PlaybackViewModel
this@PlaybackViewModel.playlist.value = r.playlist
}
}
r.playlist.items
.first()
.data
r.playlist.items.first()
}
}
} else {
@ -359,10 +358,39 @@ class PlaybackViewModel
}
viewModelScope.launch(ExceptionHandler()) { controllerViewState.observe() }
val item = BaseItem.from(base, api)
val intros =
// If not resuming playback & cinema mode is enabled, get potential intros
if (positionMs == 0L && preferences.appPreferences.playbackPreferences.cinemaMode) {
api.userLibraryApi
.getIntros(
itemId = playlistItem.id,
userId = serverRepository.currentUser.value?.id,
).content.items
.map {
PlaylistItem.Intro(BaseItem(it))
}
} else {
emptyList()
}
val firstItem =
if (intros.isNotEmpty()) {
Timber.v("Got %s intros", intros.size)
val currentPlaylist =
this@PlaybackViewModel
.playlist.value
?.items
.orEmpty()
val newPlaylist = Playlist(intros + currentPlaylist)
this@PlaybackViewModel.playlist.setValueOnMain(newPlaylist)
intros.first()
} else {
playlistItem
}
val played =
play(
item,
firstItem,
positionMs,
itemPlayback,
forceTranscoding,
@ -374,9 +402,13 @@ class PlaybackViewModel
if (!isPlaylist && preferences.appPreferences.playbackPreferences.showNextUpWhen != ShowNextUpWhen.NEXT_UP_NEVER) {
val result = playlistCreator.createFrom(queriedItem)
if (result is PlaylistCreationResult.Success && result.playlist.items.isNotEmpty()) {
withContext(Dispatchers.Main) {
this@PlaybackViewModel.playlist.value = result.playlist
}
val currentPlaylist =
this@PlaybackViewModel
.playlist.value
?.items
.orEmpty()
val newPlaylist = Playlist(currentPlaylist + result.playlist.items)
this@PlaybackViewModel.playlist.setValueOnMain(newPlaylist)
}
}
}
@ -384,18 +416,24 @@ class PlaybackViewModel
/**
* Play an item
*
* @param item the item to play
* @param currentItem the item to play
* @param positionMs the starting playback position in milliseconds
* @param itemPlayback the parameters for playback such chosen subtitle or audio streams
* @param forceTranscoding whether the user has requested to force playback via transcoding
*/
private suspend fun play(
item: BaseItem,
playlistItem: PlaylistItem,
positionMs: Long,
itemPlayback: ItemPlayback? = null,
forceTranscoding: Boolean = this.forceTranscoding,
): Boolean =
withContext(Dispatchers.IO) {
val item =
when (playlistItem) {
is PlaylistItem.Intro -> playlistItem.item
is PlaylistItem.Media -> playlistItem.item
}
Timber.i("Playing ${item.id}")
// New item, so we can clear the media segment tracker & subtitle cues
@ -415,7 +453,7 @@ class PlaybackViewModel
)
return@withContext false
}
this@PlaybackViewModel.item = item
this@PlaybackViewModel.currentItem = playlistItem
this@PlaybackViewModel.itemId = item.id
val isLiveTv = item.type == BaseItemKind.TV_CHANNEL
@ -875,7 +913,7 @@ class PlaybackViewModel
Timber.d("Changing audio track to %s", index)
val itemPlayback =
itemPlaybackRepository.saveTrackSelection(
item = item,
item = currentItem.item,
itemPlayback = currentItemPlayback.value!!,
trackIndex = index,
type = MediaStreamType.AUDIO,
@ -889,7 +927,7 @@ class PlaybackViewModel
streamChoiceService.resolveSubtitleIndex(
source = source,
audioStreamIndex = index,
seriesId = item.data.seriesId,
seriesId = currentItem.item.data.seriesId,
subtitleIndex = itemPlayback.subtitleIndex,
prefs = preferences,
)
@ -898,7 +936,7 @@ class PlaybackViewModel
}
changeStreams(
item,
currentItem.item,
itemPlayback,
index,
resolvedSubtitleIndex,
@ -913,7 +951,7 @@ class PlaybackViewModel
Timber.d("Changing subtitle track to %s", index)
val itemPlayback =
itemPlaybackRepository.saveTrackSelection(
item = item,
item = currentItem.item,
itemPlayback = currentItemPlayback.value!!,
trackIndex = index,
type = MediaStreamType.SUBTITLE,
@ -927,7 +965,7 @@ class PlaybackViewModel
streamChoiceService.resolveSubtitleIndex(
source = source,
audioStreamIndex = itemPlayback.audioIndex,
seriesId = item.data.seriesId,
seriesId = currentItem.item.data.seriesId,
subtitleIndex = index,
prefs = preferences,
)
@ -936,7 +974,7 @@ class PlaybackViewModel
}
changeStreams(
item,
currentItem.item,
itemPlayback,
itemPlayback.audioIndex,
resolvedIndex,
@ -971,7 +1009,7 @@ class PlaybackViewModel
mediaSourceId: UUID? = currentItemPlayback.value?.sourceId,
): String? =
trickPlayInfo?.let {
val itemId = item.id
val itemId = currentItem.id
return api.trickplayApi.getTrickplayTileImageUrl(
itemId,
trickPlayInfo.width,
@ -982,12 +1020,28 @@ class PlaybackViewModel
override fun onPlaybackStateChanged(playbackState: Int) {
if (playbackState == Player.STATE_ENDED) {
viewModelScope.launchIO {
val nextItem = playlist.value?.peek()
Timber.v("Setting next up to ${nextItem?.id}")
withContext(Dispatchers.Main) {
nextUp.value = nextItem
if (nextItem == null) {
Timber.v("Playback state is STATE_ENDED")
viewModelScope.launchDefault {
when (val nextItem = playlist.value?.peek()) {
is PlaylistItem.Intro -> {
Timber.v("Next item is intro, so playing immediately")
playNextUp()
}
is PlaylistItem.Media -> {
if (currentItem is PlaylistItem.Intro) {
Timber.v("Current item is intro, so playing next up immediately")
playNextUp()
} else {
Timber.v("Setting next up to ${nextItem.id}")
withContext(Dispatchers.Main) {
nextUp.value = nextItem.item
}
}
}
null -> {
Timber.v("No next up")
navigationManager.goBack()
}
}
@ -1053,9 +1107,11 @@ class PlaybackViewModel
outroShownSegments.add(currentSegment.id)
) {
val nextItem = playlist.peek()
Timber.v("Setting next up during outro to ${nextItem?.id}")
withContext(Dispatchers.Main) {
nextUp.value = nextItem
if (nextItem is PlaylistItem.Media) {
Timber.v("Setting next up during outro to ${nextItem?.id}")
withContext(Dispatchers.Main) {
nextUp.value = nextItem.item
}
}
} else {
val behavior =
@ -1180,7 +1236,7 @@ class PlaybackViewModel
fun playNextUp() {
playlist.value?.let {
if (it.hasNext()) {
viewModelScope.launchIO {
viewModelScope.launchDefault {
cancelUpNextEpisode()
val item = it.getAndAdvance()
val played = play(item, 0)
@ -1195,7 +1251,7 @@ class PlaybackViewModel
fun playPrevious() {
playlist.value?.let {
if (it.hasPrevious()) {
viewModelScope.launchIO {
viewModelScope.launchDefault {
cancelUpNextEpisode()
val item = it.getPreviousAndReverse()
val played = play(item, 0)
@ -1256,7 +1312,7 @@ class PlaybackViewModel
PlayMethod.DIRECT_STREAM, PlayMethod.DIRECT_PLAY -> {
Timber.w("Playback error during ${it.playMethod}, falling back to transcoding")
changeStreams(
item,
currentItem.item,
currentItemPlayback.value!!,
currentItemPlayback.value?.audioIndex,
currentItemPlayback.value?.subtitleIndex,

View file

@ -5,6 +5,7 @@ import androidx.compose.ui.text.intl.Locale
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.PlaylistItem
import com.github.damontecres.wholphin.data.model.TrackIndex
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.onMain
@ -106,12 +107,13 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles(
while (maxAttempts > 0 && subtitleCount == newCount) {
maxAttempts--
delay(1500)
item =
BaseItem.from(
api.userLibraryApi.getItem(itemId = it.itemId).content,
api,
)
mediaSource = streamChoiceService.chooseSource(item.data, it)
val base = BaseItem(api.userLibraryApi.getItem(itemId = it.itemId).content)
currentItem =
when (currentItem) {
is PlaylistItem.Intro -> PlaylistItem.Intro(base)
is PlaylistItem.Media -> PlaylistItem.Media(base)
}
mediaSource = streamChoiceService.chooseSource(currentItem.item.data, it)
if (mediaSource == null) {
// This shouldn't happen, but just in case
showToast(
@ -158,7 +160,7 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles(
)
}
this@downloadAndSwitchSubtitles.changeStreams(
item,
currentItem.item,
currentItemPlayback.value!!,
audioIndex,
newStream.index,

View file

@ -79,9 +79,9 @@ fun QueueRowOverlay(
if (isFocused) controllerViewState.pulseControls()
}
SeasonCard(
item = item,
item = item.item,
onClick = {
onClickPlaylist.invoke(item)
onClickPlaylist.invoke(item.item)
controllerViewState.hideControls()
},
onLongClick = {},

View file

@ -86,6 +86,7 @@ message PlaybackPreferences {
bool refresh_rate_switching = 22;
bool resolution_switching = 23;
string external_player = 24;
bool cinema_mode = 25;
}
message HomePagePreferences{

View file

@ -767,6 +767,7 @@
<string name="ass_subtitle_mode_exoplayer">Direct play with ExoPlayer built-in</string>
<string name="ass_subtitle_mode_transcode">Burn in/transcode on server</string>
<string name="ass_subtitle_playback">SSA/ASS subtitle playback</string>
<string name="cinema_mode">Cinema mode</string>
<string name="show_more">Show %1$s more</string>
<string name="skip_behavior">Skip behavior</string>
<string name="skip_behavior_summary">For intros, credits, etc</string>