diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/Playlist.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/Playlist.kt index 698a5f34..3e3cbe18 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/Playlist.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/Playlist.kt @@ -12,7 +12,7 @@ import java.util.UUID * This is not the same thing as a Jellyfin server playlist */ class Playlist( - items: List, + items: List, 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 = items.subList(index + 1, items.size) + fun upcomingItems(): List = 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 + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt index 3cac262a..ecaa0fac 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt @@ -455,6 +455,18 @@ sealed interface AppPreference { summaryOff = R.string.disabled, ) + val CinemaMode = + AppSwitchPreference( + 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( title = R.string.remember_selected_tab, @@ -1166,6 +1178,7 @@ val advancedPreferences = preferences = listOf( AppPreference.OneClickPause, + AppPreference.CinemaMode, AppPreference.GlobalContentScale, AppPreference.SkipSegments, AppPreference.MaxBitrate, diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt index 9b46de83..278c148c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt @@ -54,6 +54,7 @@ class AppPreferencesSerializer refreshRateSwitching = AppPreference.RefreshRateSwitching.defaultValue resolutionSwitching = AppPreference.ResolutionSwitching.defaultValue + cinemaMode = AppPreference.CinemaMode.defaultValue overrides = PlaybackOverrides diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt b/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt index 8899fe8e..778f300c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt @@ -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 } + } + } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/PlaylistCreator.kt b/app/src/main/java/com/github/damontecres/wholphin/services/PlaylistCreator.kt index fbbc9b70..7e115212 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/PlaylistCreator.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/PlaylistCreator.kt @@ -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.convertAndAddParts(useSeriesForPrimary: Boolean = false): List = + private suspend fun List.convertAndAddParts(useSeriesForPrimary: Boolean = false): List = 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) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayExternalPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayExternalPage.kt index e3592372..915fc5a7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayExternalPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayExternalPage.kt @@ -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, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt index 551fdd64..680750c3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt @@ -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() @@ -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, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SubtitleSearchUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SubtitleSearchUtils.kt index 27188f57..b2e82eae 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SubtitleSearchUtils.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SubtitleSearchUtils.kt @@ -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, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/QueueRowOverlay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/QueueRowOverlay.kt index 47ee1c85..a44d1fc7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/QueueRowOverlay.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/QueueRowOverlay.kt @@ -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 = {}, diff --git a/app/src/main/proto/WholphinDataStore.proto b/app/src/main/proto/WholphinDataStore.proto index 7b7b543b..69df8689 100644 --- a/app/src/main/proto/WholphinDataStore.proto +++ b/app/src/main/proto/WholphinDataStore.proto @@ -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{ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fa9364cb..63a83aae 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -767,6 +767,7 @@ Direct play with ExoPlayer built-in Burn in/transcode on server SSA/ASS subtitle playback + Cinema mode Show %1$s more Skip behavior For intros, credits, etc