From f57fd25f8e74d5f7f31daf8809c7ca8eb5c7d745 Mon Sep 17 00:00:00 2001 From: damontecres <154766448+damontecres@users.noreply.github.com> Date: Sat, 18 Oct 2025 14:11:06 -0400 Subject: [PATCH] Support playing multiple file versions (#30) Adds support for playing different versions of a media item. Additionally, you can pick the audio or subtitle tracks for the chosen version. The file with the highest resolution is the default, but you can switch versions or tracks in the "More" menu. These choices are persisted across playbacks, just as in #26. --- .../wholphin/data/ItemPlaybackRepository.kt | 108 +++++++++++--- .../wholphin/data/model/ItemPlayback.kt | 122 ++++++++++++++++ .../wholphin/ui/components/Dialogs.kt | 86 +++++++++++ .../wholphin/ui/detail/DetailUtils.kt | 125 ++++++++++++++++ .../wholphin/ui/detail/SeriesViewModel.kt | 42 ++++++ .../wholphin/ui/detail/movie/MovieDetails.kt | 122 ++++++++++++---- .../ui/detail/movie/MovieDetailsHeader.kt | 25 ++-- .../ui/detail/series/FocusedEpisodeFooter.kt | 14 +- .../ui/detail/series/SeriesOverview.kt | 137 +++++++++++------- .../ui/detail/series/SeriesOverviewContent.kt | 3 + .../wholphin/ui/playback/PlaybackOverlay.kt | 26 +++- .../wholphin/ui/playback/PlaybackTrackInfo.kt | 10 +- .../wholphin/ui/playback/PlaybackViewModel.kt | 103 ++++++------- .../damontecres/wholphin/util/Formatting.kt | 32 +++- .../damontecres/wholphin/util/TrackSupport.kt | 12 +- 15 files changed, 771 insertions(+), 196 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt index 5ad64d1a..0a455bf9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt @@ -1,8 +1,13 @@ package com.github.damontecres.wholphin.data import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.data.model.TrackIndex +import com.github.damontecres.wholphin.data.model.chooseSource +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import org.jellyfin.sdk.model.api.MediaStream +import org.jellyfin.sdk.model.api.MediaStreamType import org.jellyfin.sdk.model.serializer.toUUIDOrNull import timber.log.Timber import java.util.UUID @@ -24,38 +29,93 @@ class ItemPlaybackRepository val itemPlayback = itemPlaybackDao.getItem(user = user, itemId = itemId) if (itemPlayback != null) { Timber.v("Got itemPlayback for %s", itemId) - val source = - item.data.mediaSources?.firstOrNull { it.id?.toUUIDOrNull() == itemPlayback.sourceId } - if (source != null) { - val audioStream = - if (itemPlayback.audioIndexEnabled) { - source.mediaStreams?.firstOrNull { it.index == itemPlayback.audioIndex } - } else { - null - } - val subtitleStream = - if (itemPlayback.subtitleIndexEnabled) { - source.mediaStreams?.firstOrNull { it.index == itemPlayback.subtitleIndex } - } else { - null - } - ChosenStreams( - itemId, - audioStream, - subtitleStream, - itemPlayback.subtitleIndex == TrackIndex.DISABLED, - ) - } else { - null - } + getChosenItemFromPlayback(item, itemPlayback) } else { null } } + + fun getChosenItemFromPlayback( + item: BaseItem, + itemPlayback: ItemPlayback, + ): ChosenStreams? { + val source = + item.data.mediaSources?.firstOrNull { it.id?.toUUIDOrNull() == itemPlayback.sourceId } + if (source != null) { + val audioStream = + if (itemPlayback.audioIndexEnabled) { + source.mediaStreams?.firstOrNull { it.index == itemPlayback.audioIndex } + } else { + null + } + val subtitleStream = + if (itemPlayback.subtitleIndexEnabled) { + source.mediaStreams?.firstOrNull { it.index == itemPlayback.subtitleIndex } + } else { + null + } + return ChosenStreams( + itemPlayback, + item.id, + source.id?.toUUIDOrNull(), + audioStream, + subtitleStream, + itemPlayback.subtitleIndex == TrackIndex.DISABLED, + ) + } else { + return null + } + } + + suspend fun savePlayVersion( + itemId: UUID, + sourceId: UUID, + ): ItemPlayback? = + withContext(Dispatchers.IO) { + serverRepository.currentUser?.let { user -> + val itemPlayback = + ItemPlayback( + userId = user.rowId, + itemId = itemId, + sourceId = sourceId, + ) + Timber.v("Saving play version %s", itemPlayback) + saveItemPlayback(itemPlayback) + } + } + + suspend fun saveTrackSelection( + item: BaseItem, + itemPlayback: ItemPlayback?, + trackIndex: Int, + type: MediaStreamType, + ) = serverRepository.currentUser?.let { user -> + var toSave = + itemPlayback ?: ItemPlayback( + userId = user.rowId, + itemId = item.id, + sourceId = chooseSource(item.data, null)?.id?.toUUIDOrNull(), + ) + toSave = + when (type) { + MediaStreamType.AUDIO -> toSave.copy(audioIndex = trackIndex) + MediaStreamType.SUBTITLE -> toSave.copy(subtitleIndex = trackIndex) + else -> toSave + } + Timber.v("Saving track selection %s", toSave) + saveItemPlayback(toSave) + } + + suspend fun saveItemPlayback(itemPlayback: ItemPlayback): ItemPlayback { + val rowId = itemPlaybackDao.saveItem(itemPlayback) + return itemPlayback.copy(rowId = rowId) + } } data class ChosenStreams( + val itemPlayback: ItemPlayback, val itemId: UUID, + val sourceId: UUID?, val audioStream: MediaStream?, val subtitleStream: MediaStream?, val subtitlesDisabled: Boolean, diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/ItemPlayback.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/ItemPlayback.kt index 9bd699a1..b54718c8 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/ItemPlayback.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/ItemPlayback.kt @@ -7,9 +7,18 @@ import androidx.room.ForeignKey import androidx.room.Index import androidx.room.PrimaryKey import com.github.damontecres.wholphin.data.JellyfinUser +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.ui.isNotNullOrBlank +import com.github.damontecres.wholphin.ui.letNotEmpty import com.github.damontecres.wholphin.util.UuidSerializer import kotlinx.serialization.Serializable import kotlinx.serialization.UseSerializers +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.MediaSourceInfo +import org.jellyfin.sdk.model.api.MediaStream +import org.jellyfin.sdk.model.api.MediaStreamType +import org.jellyfin.sdk.model.api.SubtitlePlaybackMode +import org.jellyfin.sdk.model.serializer.toUUIDOrNull import java.util.UUID @Entity( @@ -39,6 +48,119 @@ data class ItemPlayback( @Transient val subtitleIndexEnabled = subtitleIndex >= 0 } +/** + * Returns the [MediaSourceInfo] with the highest video resolution + */ +fun chooseSource(sources: List?) = + sources?.letNotEmpty { sources -> + val result = + sources.maxByOrNull { s -> + s.mediaStreams?.firstOrNull { it.type == MediaStreamType.VIDEO }?.let { video -> + (video.width ?: 0) * (video.height ?: 0) + } ?: 0 + } + result + } + +/** + * Returns the [MediaSourceInfo] that matched the [ItemPlayback] or else the one with the highest resolution + */ +fun chooseSource( + dto: BaseItemDto, + itemPlayback: ItemPlayback?, +) = itemPlayback?.sourceId?.let { dto.mediaSources?.firstOrNull { it.id?.toUUIDOrNull() == itemPlayback.sourceId } } + ?: chooseSource(dto.mediaSources) // dto.mediaSources?.firstOrNull() + +fun chooseStream( + dto: BaseItemDto, + itemPlayback: ItemPlayback?, + type: MediaStreamType, + prefs: UserPreferences, +): MediaStream? { + val source = chooseSource(dto, itemPlayback) + return source?.mediaStreams?.letNotEmpty { streams -> + val candidates = streams.filter { it.type == type } + when (type) { + MediaStreamType.AUDIO -> chooseAudioStream(candidates, itemPlayback, prefs) + MediaStreamType.SUBTITLE -> chooseSubtitleStream(candidates, itemPlayback, prefs) + else -> candidates.firstOrNull() + } + } +} + +fun chooseAudioStream( + candidates: List, + itemPlayback: ItemPlayback?, + prefs: UserPreferences, +): MediaStream? = + if (itemPlayback?.audioIndexEnabled == true) { + candidates.firstOrNull { it.index == itemPlayback.audioIndex } + } else { + // TODO audio selection based on channel layout or preferences or default + val audioLanguage = prefs.userConfig.audioLanguagePreference + if (audioLanguage.isNotNullOrBlank()) { + val sorted = + candidates.sortedWith(compareBy { it.language }.thenByDescending { it.channels }) + sorted.firstOrNull { it.language == audioLanguage && it.isDefault } + ?: sorted.firstOrNull { it.language == audioLanguage } + ?: sorted.firstOrNull { it.isDefault } + ?: sorted.firstOrNull() + } else { + candidates.firstOrNull { it.isDefault } + ?: candidates.firstOrNull() + } + } + +fun chooseSubtitleStream( + candidates: List, + itemPlayback: ItemPlayback?, + prefs: UserPreferences, +): MediaStream? { + if (itemPlayback?.subtitleIndex == TrackIndex.DISABLED) { + return null + } else if (itemPlayback?.subtitleIndexEnabled == true) { + return candidates.firstOrNull { it.index == itemPlayback.subtitleIndex } + } else { + val subtitleLanguage = prefs.userConfig.subtitleLanguagePreference + return when (prefs.userConfig.subtitleMode) { + SubtitlePlaybackMode.ALWAYS -> { + if (subtitleLanguage != null) { + candidates.firstOrNull { it.language == subtitleLanguage } + } else { + candidates.firstOrNull() + } + } + + SubtitlePlaybackMode.ONLY_FORCED -> + if (subtitleLanguage != null) { + candidates.firstOrNull { it.language == subtitleLanguage && it.isForced } + } else { + candidates.firstOrNull { it.isForced } + } + + SubtitlePlaybackMode.SMART -> { + val audioLanguage = prefs.userConfig.audioLanguagePreference + if (audioLanguage != null && subtitleLanguage != null && audioLanguage != subtitleLanguage) { + candidates.firstOrNull { it.language == subtitleLanguage } + } else { + null + } + } + + SubtitlePlaybackMode.DEFAULT -> { + // TODO check for language? + ( + candidates.firstOrNull { it.isDefault && it.isForced } + ?: candidates.firstOrNull { it.isDefault } + ?: candidates.firstOrNull { it.isForced } + ) + } + + SubtitlePlaybackMode.NONE -> null + } + } +} + object TrackIndex { /** * The user has not explicitly specified a track to use diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt index eead11f0..3225a749 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt @@ -52,10 +52,15 @@ import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import androidx.tv.material3.surfaceColorAtElevation import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.model.TrackIndex import com.github.damontecres.wholphin.ui.FontAwesome +import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.util.ExceptionHandler import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import org.jellyfin.sdk.model.api.MediaSourceInfo +import org.jellyfin.sdk.model.api.MediaStream +import org.jellyfin.sdk.model.api.MediaStreamType /** * Parameters for rendering a [DialogPopup] @@ -79,6 +84,25 @@ data class DialogItem( val trailingContent: @Composable (() -> Unit)? = null, val enabled: Boolean = true, ) : DialogItemEntry { + constructor( + @StringRes text: Int, + @StringRes iconStringRes: Int, + onClick: () -> Unit, + ) : this( + headlineContent = { + Text( + text = stringResource(text), + ) + }, + leadingContent = { + Text( + text = stringResource(id = iconStringRes), + fontFamily = FontAwesome, + ) + }, + onClick = onClick, + ) + constructor( text: String, @StringRes iconStringRes: Int, @@ -413,3 +437,65 @@ fun ConfirmDialogContent( } } } + +fun chooseVersionParams( + sources: List, + onClick: (Int) -> Unit, +): DialogParams = + DialogParams( + fromLongClick = false, + title = "Play Version", + items = + sources.filter { it.id.isNotNullOrBlank() }.mapIndexed { index, source -> + val videoStream = + source.mediaStreams?.firstOrNull { it.type == MediaStreamType.VIDEO } + val title = source.name ?: source.path ?: source.id ?: "" + DialogItem( + headlineContent = { + Text(text = title) + }, + supportingContent = { + videoStream?.displayTitle?.let { Text(text = it) } + }, + onClick = { onClick.invoke(index) }, + ) + }, + ) + +fun chooseStream( + streams: List, + type: MediaStreamType, + onClick: (Int) -> Unit, +): DialogParams = + DialogParams( + fromLongClick = false, + title = "Choose ${type.serialName}", // TODO + items = + buildList { + if (type == MediaStreamType.SUBTITLE) { + add( + DialogItem( + headlineContent = { + Text(text = "None") + }, + supportingContent = { + }, + onClick = { onClick.invoke(TrackIndex.DISABLED) }, + ), + ) + } + addAll( + streams.filter { it.type == type }.mapIndexed { index, stream -> + val title = stream.displayTitle ?: stream.title ?: "" + DialogItem( + headlineContent = { + Text(text = title) + }, + supportingContent = { + }, + onClick = { onClick.invoke(stream.index) }, + ) + }, + ) + }, + ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt new file mode 100644 index 00000000..eef242b2 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt @@ -0,0 +1,125 @@ +package com.github.damontecres.wholphin.ui.detail + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.material.icons.filled.ArrowForward +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Settings +import androidx.compose.ui.graphics.Color +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.ui.components.DialogItem +import com.github.damontecres.wholphin.ui.letNotEmpty +import com.github.damontecres.wholphin.ui.nav.Destination +import org.jellyfin.sdk.model.UUID +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.MediaStreamType +import org.jellyfin.sdk.model.serializer.toUUIDOrNull + +/** + * Build the [DialogItem]s when clicking "More" + * + * If there are multiple versions, adds an option to pick one + * + * If there is more than one (ie two or more) audio track, adds an option to pick one + * + * If there are any (ie one or more) subtitle tracks, adds an option to disable or pick one + * + * @param item the media item to build for, typically an Episode or Movie + * @param series the item's series or null if not a TV episode; a non-null value will include a "Go to Series" option + * @param sourceId the item's media source UUID + * @param navigateTo a function to trigger a navigation + * @param onChooseVersion callback to pick a version of the item + * @param onChooseTracks callback to pick a track for the given type of the item + */ +fun buildMoreDialogItems( + item: BaseItem, + series: BaseItem?, + sourceId: UUID?, + watched: Boolean, + navigateTo: (Destination) -> Unit, + onClickWatch: (Boolean) -> Unit, + onChooseVersion: () -> Unit, + onChooseTracks: (MediaStreamType) -> Unit, +): List = + buildList { + add( + DialogItem( + "Play", + Icons.Default.PlayArrow, + iconColor = Color.Green.copy(alpha = .8f), + ) { + navigateTo( + Destination.Playback( + item.id, + item.resumeMs ?: 0L, + item, + ), + ) + }, + ) + add( + DialogItem( + text = if (watched) R.string.mark_unwatched else R.string.mark_watched, + iconStringRes = if (watched) R.string.fa_eye else R.string.fa_eye_slash, + ) { + onClickWatch.invoke(!watched) + }, + ) + series?.let { + add( + DialogItem( + "Go to series", + Icons.AutoMirrored.Filled.ArrowForward, + ) { + navigateTo( + Destination.MediaItem( + series.id, + BaseItemKind.SERIES, + series, + ), + ) + }, + ) + } + item.data.mediaSources?.letNotEmpty { sources -> + if (sources.size > 1) { + add( + DialogItem( + "Choose Version", + Icons.Default.ArrowForward, + iconColor = Color.Green.copy(alpha = .8f), + ) { + onChooseVersion.invoke() + }, + ) + } + val source = + sourceId?.let { sources.firstOrNull { it.id?.toUUIDOrNull() == sourceId } } + ?: sources.firstOrNull() + source?.mediaStreams?.letNotEmpty { streams -> + val audioCount = streams.count { it.type == MediaStreamType.AUDIO } + val subtitleCount = streams.count { it.type == MediaStreamType.SUBTITLE } + if (audioCount > 1) { + add( + DialogItem( + "Choose audio", + Icons.Default.Settings, + ) { + onChooseTracks.invoke(MediaStreamType.AUDIO) + }, + ) + } + if (subtitleCount > 0) { + add( + DialogItem( + "Choose subtitles", + Icons.Default.Settings, + ) { + onChooseTracks.invoke(MediaStreamType.SUBTITLE) + }, + ) + } + } + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/SeriesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/SeriesViewModel.kt index 58b0428f..848ea109 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/SeriesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/SeriesViewModel.kt @@ -14,6 +14,7 @@ import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import com.github.damontecres.wholphin.data.ChosenStreams import com.github.damontecres.wholphin.data.ItemPlaybackRepository import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.data.model.Person import com.github.damontecres.wholphin.hilt.AuthOkHttpClient import com.github.damontecres.wholphin.preferences.ThemeSongVolume @@ -46,6 +47,7 @@ import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.ItemFields import org.jellyfin.sdk.model.api.ItemSortBy +import org.jellyfin.sdk.model.api.MediaStreamType import org.jellyfin.sdk.model.api.SortOrder import org.jellyfin.sdk.model.api.request.GetEpisodesRequest import org.jellyfin.sdk.model.api.request.GetItemsRequest @@ -328,6 +330,46 @@ class SeriesViewModel } } } + + fun savePlayVersion( + item: BaseItem, + sourceId: UUID, + ) { + viewModelScope.launchIO { + val result = itemPlaybackRepository.savePlayVersion(item.id, sourceId) + val chosen = + result?.let { + itemPlaybackRepository.getChosenItemFromPlayback(item, result) + } + withContext(Dispatchers.Main) { + chosenStreams.value = chosen + } + } + } + + fun saveTrackSelection( + item: BaseItem, + itemPlayback: ItemPlayback?, + trackIndex: Int, + type: MediaStreamType, + ) { + viewModelScope.launchIO { + val result = + itemPlaybackRepository.saveTrackSelection( + item = item, + itemPlayback = itemPlayback, + trackIndex = trackIndex, + type = type, + ) + val chosen = + result?.let { + itemPlaybackRepository.getChosenItemFromPlayback(item, result) + } + withContext(Dispatchers.Main) { + chosenStreams.value = chosen + } + } + } } data class ItemListAndMapping( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt index ee2c5860..bfd9a198 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt @@ -11,8 +11,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.relocation.BringIntoViewRequester import androidx.compose.foundation.relocation.bringIntoViewRequester -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -40,19 +38,23 @@ import com.github.damontecres.wholphin.data.ChosenStreams import com.github.damontecres.wholphin.data.ItemPlaybackRepository 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.Person +import com.github.damontecres.wholphin.data.model.chooseSource import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.cards.ChapterRow import com.github.damontecres.wholphin.ui.cards.PersonRow -import com.github.damontecres.wholphin.ui.components.DialogItem import com.github.damontecres.wholphin.ui.components.DialogParams import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.ExpandablePlayButtons import com.github.damontecres.wholphin.ui.components.LoadingPage +import com.github.damontecres.wholphin.ui.components.chooseStream +import com.github.damontecres.wholphin.ui.components.chooseVersionParams import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo import com.github.damontecres.wholphin.ui.detail.LoadingItemViewModel +import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItems import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.letNotEmpty @@ -68,7 +70,9 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.playStateApi +import org.jellyfin.sdk.model.api.MediaStreamType import org.jellyfin.sdk.model.extensions.ticks +import org.jellyfin.sdk.model.serializer.toUUID import java.util.UUID import javax.inject.Inject import kotlin.time.Duration @@ -119,6 +123,46 @@ class MovieViewModel } init(itemId, null) } + + fun savePlayVersion( + item: BaseItem, + sourceId: UUID, + ) { + viewModelScope.launchIO { + val result = itemPlaybackRepository.savePlayVersion(item.id, sourceId) + val chosen = + result?.let { + itemPlaybackRepository.getChosenItemFromPlayback(item, result) + } + withContext(Dispatchers.Main) { + chosenStreams.value = chosen + } + } + } + + fun saveTrackSelection( + item: BaseItem, + itemPlayback: ItemPlayback?, + trackIndex: Int, + type: MediaStreamType, + ) { + viewModelScope.launchIO { + val result = + itemPlaybackRepository.saveTrackSelection( + item = item, + itemPlayback = itemPlayback, + trackIndex = trackIndex, + type = type, + ) + val chosen = + result?.let { + itemPlaybackRepository.getChosenItemFromPlayback(item, result) + } + withContext(Dispatchers.Main) { + chosenStreams.value = chosen + } + } + } } @Composable @@ -139,6 +183,7 @@ fun MovieDetails( var overviewDialog by remember { mutableStateOf(null) } var moreDialog by remember { mutableStateOf(null) } + var chooseVersion by remember { mutableStateOf(null) } when (val state = loading) { is LoadingState.Error -> ErrorMessage(state) @@ -179,30 +224,44 @@ fun MovieDetails( fromLongClick = false, title = movie.name + " (${movie.data.productionYear ?: ""})", items = - listOf( - DialogItem( - "Play", - Icons.Default.PlayArrow, - iconColor = Color.Green.copy(alpha = .8f), - ) { - viewModel.navigationManager.navigateTo( - Destination.Playback(movie), - ) + buildMoreDialogItems( + item = movie, + watched = movie.data.userData?.played ?: false, + series = null, + sourceId = chosenStreams?.sourceId, + navigateTo = viewModel.navigationManager::navigateTo, + onClickWatch = viewModel::setWatched, + onChooseVersion = { + chooseVersion = + chooseVersionParams(movie.data.mediaSources!!) { idx -> + val source = movie.data.mediaSources!![idx] + viewModel.savePlayVersion( + movie, + source.id!!.toUUID(), + ) + } + moreDialog = null + }, + onChooseTracks = { type -> + chooseSource( + movie.data, + chosenStreams?.itemPlayback, + )?.let { source -> + chooseVersion = + chooseStream( + streams = source.mediaStreams.orEmpty(), + type = type, + onClick = { trackIndex -> + viewModel.saveTrackSelection( + movie, + chosenStreams?.itemPlayback, + trackIndex, + type, + ) + }, + ) + } }, -// DialogItem( -// "Playback Settings", -// Icons.Default.Settings, -// // iconColor = Color.Green.copy(alpha = .8f), -// ) { -// // TODO choose audio or subtitle tracks? -// }, -// DialogItem( -// "Play Version", -// Icons.Default.PlayArrow, -// iconColor = Color.Green.copy(alpha = .8f), -// ) { -// // TODO only show for multiple files -// }, ), ) }, @@ -230,6 +289,16 @@ fun MovieDetails( waitToLoad = params.fromLongClick, ) } + chooseVersion?.let { params -> + DialogPopup( + showDialog = true, + title = params.title, + dialogItems = params.items, + onDismissRequest = { chooseVersion = null }, + dismissOnClick = true, + waitToLoad = params.fromLongClick, + ) + } } @Composable @@ -300,6 +369,7 @@ fun MovieDetailsContent( .padding(bottom = 56.dp), ) { MovieDetailsHeader( + preferences = preferences, movie = movie, chosenStreams = chosenStreams, bringIntoViewRequester = bringIntoViewRequester, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt index ffb6d154..1c0963ec 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt @@ -10,7 +10,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.relocation.BringIntoViewRequester import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha @@ -27,6 +26,8 @@ import androidx.tv.material3.Text import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.ChosenStreams import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.chooseStream +import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.components.DotSeparatedRow import com.github.damontecres.wholphin.ui.components.OverviewText import com.github.damontecres.wholphin.ui.components.StarRating @@ -43,6 +44,7 @@ import org.jellyfin.sdk.model.extensions.ticks @Composable fun MovieDetailsHeader( + preferences: UserPreferences, movie: BaseItem, chosenStreams: ChosenStreams?, bringIntoViewRequester: BringIntoViewRequester, @@ -141,15 +143,16 @@ fun MovieDetailsHeader( .fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(16.dp), ) { - dto.mediaStreams?.firstOrNull { it.type == MediaStreamType.VIDEO }?.displayTitle?.let { - TitleValueText( - stringResource(R.string.video), - it, - modifier = Modifier.widthIn(max = 200.dp), - ) - } - val audioDisplay = - remember(movie.id, chosenStreams) { getAudioDisplay(movie.data, chosenStreams) } + chooseStream(dto, chosenStreams?.itemPlayback, MediaStreamType.VIDEO, preferences) + ?.displayTitle + ?.let { + TitleValueText( + stringResource(R.string.video), + it, + modifier = Modifier.widthIn(max = 200.dp), + ) + } + val audioDisplay = getAudioDisplay(movie.data, chosenStreams, preferences) audioDisplay ?.let { TitleValueText( @@ -165,7 +168,7 @@ fun MovieDetailsHeader( TitleValueText( "Subtitles", it, - modifier = Modifier.widthIn(max = 120.dp), + modifier = Modifier.widthIn(max = 200.dp), ) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeFooter.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeFooter.kt index 178fca4d..999f9913 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeFooter.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeFooter.kt @@ -13,6 +13,8 @@ import androidx.compose.ui.unit.dp import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.ChosenStreams import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.chooseStream +import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.components.ExpandablePlayButtons import com.github.damontecres.wholphin.ui.components.TitleValueText import com.github.damontecres.wholphin.ui.isNotNullOrBlank @@ -24,6 +26,7 @@ import kotlin.time.Duration @Composable fun FocusedEpisodeFooter( + preferences: UserPreferences, ep: BaseItem, chosenStreams: ChosenStreams?, playOnClick: (Duration) -> Unit, @@ -52,21 +55,22 @@ fun FocusedEpisodeFooter( Row( horizontalArrangement = Arrangement.spacedBy(16.dp), ) { - dto.mediaStreams - ?.firstOrNull { it.type == MediaStreamType.VIDEO } + chooseStream(dto, chosenStreams?.itemPlayback, MediaStreamType.VIDEO, preferences) ?.displayTitle ?.let { TitleValueText( stringResource(R.string.video), it, + modifier = Modifier.widthIn(max = 160.dp), ) } - val audioDisplay = - remember(ep.id, chosenStreams) { getAudioDisplay(ep.data, chosenStreams) } + + val audioDisplay = getAudioDisplay(ep.data, chosenStreams, preferences) audioDisplay?.let { TitleValueText( stringResource(R.string.audio), it, + modifier = Modifier.widthIn(max = 200.dp), ) } @@ -77,7 +81,7 @@ fun FocusedEpisodeFooter( TitleValueText( "Subtitles", it, - modifier = Modifier.widthIn(max = 64.dp), + modifier = Modifier.widthIn(max = 160.dp), ) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt index e913b63f..ca936c3f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt @@ -1,8 +1,5 @@ package com.github.damontecres.wholphin.ui.detail.series -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowForward -import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -14,28 +11,31 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.graphics.Color import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.chooseSource import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect -import com.github.damontecres.wholphin.ui.components.DialogItem import com.github.damontecres.wholphin.ui.components.DialogParams import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.LoadingPage +import com.github.damontecres.wholphin.ui.components.chooseStream +import com.github.damontecres.wholphin.ui.components.chooseVersionParams import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo import com.github.damontecres.wholphin.ui.detail.EpisodeList import com.github.damontecres.wholphin.ui.detail.ItemListAndMapping import com.github.damontecres.wholphin.ui.detail.SeriesViewModel +import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItems import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.seasonEpisode import kotlinx.serialization.Serializable -import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.extensions.ticks +import org.jellyfin.sdk.model.serializer.toUUID import timber.log.Timber import kotlin.time.Duration @@ -101,6 +101,7 @@ fun SeriesOverview( var overviewDialog by remember { mutableStateOf(null) } var moreDialog by remember { mutableStateOf(null) } + var chooseVersion by remember { mutableStateOf(null) } LaunchedEffect(episodes) { episodes?.let { episodes -> @@ -137,7 +138,68 @@ fun SeriesOverview( LoadingState.Success -> { series?.let { series -> LaunchedEffect(Unit) { episodeRowFocusRequester.tryRequestFocus() } + + fun buildMoreForEpisode( + ep: BaseItem, + fromLongClick: Boolean, + ): DialogParams = + DialogParams( + fromLongClick = fromLongClick, + title = series.name + " - " + ep.data.seasonEpisode, + items = + buildMoreDialogItems( + ep, + watched = ep.data.userData?.played ?: false, + series = series, + sourceId = chosenStreams?.sourceId, + navigateTo = viewModel::navigateTo, + onClickWatch = { played -> + episodeList + ?.getOrNull(position.episodeRowIndex) + ?.let { + viewModel.setWatched( + it.id, + played, + position.episodeRowIndex, + ) + } + }, + onChooseVersion = { + chooseVersion = + chooseVersionParams(ep.data.mediaSources!!) { idx -> + val source = ep.data.mediaSources!![idx] + viewModel.savePlayVersion( + ep, + source.id!!.toUUID(), + ) + } + moreDialog = null + }, + onChooseTracks = { type -> + chooseSource( + ep.data, + chosenStreams?.itemPlayback, + )?.let { source -> + chooseVersion = + chooseStream( + streams = source.mediaStreams.orEmpty(), + type = type, + onClick = { trackIndex -> + viewModel.saveTrackSelection( + ep, + chosenStreams?.itemPlayback, + trackIndex, + type, + ) + }, + ) + } + }, + ), + ) + SeriesOverviewContent( + preferences = preferences, series = series, seasons = seasons.items, episodes = episodes, @@ -173,8 +235,8 @@ fun SeriesOverview( ), ) }, - onLongClick = { - // TODO + onLongClick = { ep -> + moreDialog = buildMoreForEpisode(ep, true) }, playOnClick = { resume -> episodeList?.getOrNull(position.episodeRowIndex)?.let { @@ -196,54 +258,7 @@ fun SeriesOverview( }, moreOnClick = { episodeList?.getOrNull(position.episodeRowIndex)?.let { ep -> - moreDialog = - DialogParams( - fromLongClick = false, - title = series.name + " - " + ep.data.seasonEpisode, - items = - listOf( - DialogItem( - "Play", - Icons.Default.PlayArrow, - iconColor = Color.Green.copy(alpha = .8f), - ) { - viewModel.navigateTo( - Destination.Playback( - ep.id, - ep.resumeMs ?: 0L, - ep, - ), - ) - }, - DialogItem( - "Go to series", - Icons.AutoMirrored.Filled.ArrowForward, -// iconColor = Color.Green.copy(alpha = .8f), - ) { - viewModel.navigateTo( - Destination.MediaItem( - series.id, - BaseItemKind.SERIES, - series, - ), - ) - }, -// DialogItem( -// "Playback Settings", -// Icons.Default.Settings, -// // iconColor = Color.Green.copy(alpha = .8f), -// ) { -// // TODO choose audio or subtitle tracks? -// }, -// DialogItem( -// "Play Version", -// Icons.Default.PlayArrow, -// iconColor = Color.Green.copy(alpha = .8f), -// ) { -// // TODO only show for multiple files -// }, - ), - ) + moreDialog = buildMoreForEpisode(ep, false) } }, overviewOnClick = { @@ -281,4 +296,14 @@ fun SeriesOverview( waitToLoad = params.fromLongClick, ) } + chooseVersion?.let { params -> + DialogPopup( + showDialog = true, + title = params.title, + dialogItems = params.items, + onDismissRequest = { chooseVersion = null }, + dismissOnClick = true, + waitToLoad = params.fromLongClick, + ) + } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt index c3f7b8f0..fa89553e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt @@ -45,6 +45,7 @@ import coil3.compose.AsyncImage import com.github.damontecres.wholphin.data.ChosenStreams import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.aspectRatioFloat +import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect import com.github.damontecres.wholphin.ui.cards.BannerCard import com.github.damontecres.wholphin.ui.components.ErrorMessage @@ -58,6 +59,7 @@ import kotlin.time.Duration @Composable fun SeriesOverviewContent( + preferences: UserPreferences, series: BaseItem, seasons: List, episodes: EpisodeList, @@ -282,6 +284,7 @@ fun SeriesOverviewContent( item { focusedEpisode?.let { ep -> FocusedEpisodeFooter( + preferences = preferences, ep = ep, chosenStreams = chosenStreams, playOnClick = playOnClick, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt index 330602e5..d8d338da 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt @@ -371,15 +371,25 @@ fun PlaybackOverlay( Modifier .align(Alignment.TopStart), ) { - currentPlayback?.tracks?.letNotEmpty { - PlaybackTrackInfo( - trackSupport = it, - modifier = - Modifier - .align(Alignment.TopStart) - .padding(16.dp) - .background(AppColors.TransparentBlack50), + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = + Modifier + .align(Alignment.TopStart) + .padding(16.dp) + .background(AppColors.TransparentBlack50), + ) { + Text( + text = "Play method: ${currentPlayback?.playMethod?.serialName}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(start = 8.dp), ) + currentPlayback?.tracks?.letNotEmpty { + PlaybackTrackInfo( + trackSupport = it, + ) + } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackTrackInfo.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackTrackInfo.kt index 122190c1..4bd59347 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackTrackInfo.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackTrackInfo.kt @@ -1,5 +1,6 @@ package com.github.damontecres.wholphin.ui.playback +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row @@ -15,6 +16,7 @@ import androidx.compose.ui.unit.dp import androidx.tv.material3.Icon import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text +import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.util.TrackSupport /** @@ -28,7 +30,7 @@ fun PlaybackTrackInfo( val selectedWeight = .5f val weights = listOf(.25f, .4f, .5f, 1f, 1f) val textStyle = - MaterialTheme.typography.bodySmall.copy(color = MaterialTheme.colorScheme.onBackground) + MaterialTheme.typography.bodySmall.copy(color = MaterialTheme.colorScheme.onSurface) LazyColumn( modifier = modifier, @@ -71,7 +73,11 @@ fun PlaybackTrackInfo( items(trackSupport) { track -> Row( horizontalArrangement = Arrangement.spacedBy(4.dp), - modifier = Modifier, + modifier = + Modifier.ifElse( + track.selected, + Modifier.background(MaterialTheme.colorScheme.border.copy(alpha = .25f)), + ), ) { val texts = listOf( 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 2138cb0f..42f99791 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 @@ -24,6 +24,8 @@ import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.data.model.Playlist import com.github.damontecres.wholphin.data.model.PlaylistCreator import com.github.damontecres.wholphin.data.model.TrackIndex +import com.github.damontecres.wholphin.data.model.chooseSource +import com.github.damontecres.wholphin.data.model.chooseStream import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.SkipSegmentBehavior import com.github.damontecres.wholphin.preferences.UserPreferences @@ -158,7 +160,7 @@ class PlaybackViewModel viewModelScope.launch( LoadingExceptionHandler( loading, - "Error preparing for plaback on ${destination.itemId}", + "Error preparing for playback for ${destination.itemId}", ) + Dispatchers.IO, ) { val queriedItem = item?.data ?: api.userLibraryApi.getItem(itemId).content @@ -212,37 +214,6 @@ class PlaybackViewModel return@withContext false } - val playbackConfig = - if (itemPlayback != null) { - itemPlayback - } else { - val user = serverRepository.currentUser!! - itemPlaybackDao.getItem(user, base.id)?.let { - Timber.v("Fetched itemPlayback from DB: %s", it) - if (it.sourceId == null) { - it.copy( - // TODO Better choice than first (which may not be the best resolution), using getPostedPlaybackInfo might be better? - sourceId = - base.mediaSources - ?.firstOrNull() - ?.id - ?.toUUIDOrNull(), - ) - } else { - it - } - } - ?: ItemPlayback( - userId = user.rowId, - itemId = base.id, - sourceId = - base.mediaSources - ?.firstOrNull() - ?.id - ?.toUUIDOrNull(), - ) - } - dto = base val title = if (base.type == BaseItemKind.EPISODE) { @@ -263,14 +234,24 @@ class PlaybackViewModel withContext(Dispatchers.Main) { this@PlaybackViewModel.title.value = title this@PlaybackViewModel.subtitle.value = subtitle - this@PlaybackViewModel.currentItemPlayback.value = playbackConfig } - val mediaSource = - if (playbackConfig.sourceId != null) { - base.mediaSources?.firstOrNull { it.id?.toUUIDOrNull() == playbackConfig.sourceId } + + val playbackConfig = + if (itemPlayback != null) { + itemPlayback } else { - base.mediaSources?.firstOrNull() + val user = serverRepository.currentUser!! + itemPlaybackDao.getItem(user, base.id)?.let { + Timber.v("Fetched itemPlayback from DB: %s", it) + if (it.sourceId != null) { + it + } else { + null + } + } } + val mediaSource = chooseSource(base, playbackConfig) + if (mediaSource == null) { showToast( context, @@ -314,31 +295,31 @@ class PlaybackViewModel }?.sortedWith(compareBy { it.language }.thenByDescending { it.channels }) .orEmpty() - // TODO audio selection based on channel layout or preferences or default - val audioLanguage = preferences.userConfig.audioLanguagePreference val audioIndex = - if (playbackConfig.audioIndexEnabled) { - playbackConfig.audioIndex - } else if (audioLanguage != null) { - audioStreams.firstOrNull { it.language == audioLanguage }?.index - ?: audioStreams.firstOrNull()?.index - } else { - audioStreams.firstOrNull()?.index - } + chooseStream(base, playbackConfig, MediaStreamType.AUDIO, preferences) + ?.index val subtitleIndex = - determineSubtitleIndex( - subtitleStreams = subtitleStreams, - subtitleMode = preferences.userConfig.subtitleMode, - subtitleLanguage = preferences.userConfig.subtitleLanguagePreference, - audioLanguage = audioLanguage, - playbackConfig = playbackConfig, - ) + chooseStream(base, playbackConfig, MediaStreamType.SUBTITLE, preferences) + ?.index // Timber.v("base.mediaStreams=${base.mediaStreams}") // Timber.v("subtitleTracks=$subtitleStreams") // Timber.v("audioStreams=$audioStreams") - Timber.d("Selected audioIndex=$audioIndex, subtitleIndex=$subtitleIndex") + Timber.d("Selected mediaSource=${mediaSource.id}, audioIndex=$audioIndex, subtitleIndex=$subtitleIndex") + + val itemPlaybackToUse = + playbackConfig ?: ItemPlayback( + rowId = -1, + userId = -1, + itemId = base.id, + sourceId = mediaSource.id?.toUUIDOrNull(), + audioIndex = audioIndex ?: TrackIndex.UNSPECIFIED, + subtitleIndex = subtitleIndex ?: TrackIndex.UNSPECIFIED, + ) + withContext(Dispatchers.Main) { + this@PlaybackViewModel.currentItemPlayback.value = itemPlaybackToUse + } withContext(Dispatchers.Main) { this@PlaybackViewModel.audioStreams.value = audioStreams @@ -346,7 +327,7 @@ class PlaybackViewModel changeStreams( base, - playbackConfig, + itemPlaybackToUse, audioIndex, subtitleIndex, if (positionMs > 0) positionMs else C.TIME_UNSET, @@ -434,6 +415,10 @@ class PlaybackViewModel val decision = StreamDecision(itemId, transcodeType, mediaUrl) Timber.v("Playback decision: $decision") + val externalSubtitleCount = + source.mediaStreams + ?.count { it.type == MediaStreamType.SUBTITLE && it.isExternal } ?: 0 + val externalSubtitle = source.mediaStreams ?.firstOrNull { it.type == MediaStreamType.SUBTITLE && it.index == subtitleIndex && it.isExternal } @@ -519,6 +504,7 @@ class PlaybackViewModel source, audioIndex, subtitleIndex, + externalSubtitleCount, ) player.removeListener(this) } @@ -731,7 +717,7 @@ data class CurrentPlayback( val playSessionId: String?, ) -private val Format.idAsInt: Int? +val Format.idAsInt: Int? @OptIn(UnstableApi::class) get() = id?.let { @@ -748,6 +734,7 @@ private fun applyTrackSelections( source: MediaSourceInfo, audioIndex: Int?, subtitleIndex: Int?, + externalSubtitleCount: Int, ) { if (source.supportsDirectPlay) { if (subtitleIndex != null && subtitleIndex >= 0) { @@ -786,7 +773,7 @@ private fun applyTrackSelections( (0..?): String? = ?.distinct() ?.joinToString(", ") { languageName(it) } +/** + * Gets the selected audio display title for the given item & chosen streams + */ fun getAudioDisplay( item: BaseItemDto, chosenStreams: ChosenStreams?, -) = ( - chosenStreams?.audioStream - ?: item.mediaStreams?.firstOrNull { it.type == MediaStreamType.AUDIO } -)?.displayTitle + preferences: UserPreferences, +) = getAudioDisplay(item, chosenStreams?.itemPlayback, preferences) + +/** + * Gets the selected audio display title for the given item & chosen streams + */ +fun getAudioDisplay( + item: BaseItemDto, + itemPlayback: ItemPlayback?, + preferences: UserPreferences, +) = chooseStream(item, itemPlayback, MediaStreamType.AUDIO, preferences) + ?.displayTitle ?.replace(" - Default", "") ?.ifBlank { null } +/** + * Gets the selected subtitle language for the given item & chosen streams + * + * If none are chosen, returns a concatenated list of languages available + */ @Composable fun getSubtitleDisplay( item: BaseItemDto, @@ -84,5 +104,7 @@ fun getSubtitleDisplay( } else if (chosenStreams?.subtitleStream != null) { languageName(chosenStreams.subtitleStream.language) } else { - formatSubtitleLang(item.mediaStreams) + chooseSource(item, chosenStreams?.itemPlayback)?.let { + formatSubtitleLang(it.mediaStreams) + } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/TrackSupport.kt b/app/src/main/java/com/github/damontecres/wholphin/util/TrackSupport.kt index 76eb118a..3b42b750 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/TrackSupport.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/TrackSupport.kt @@ -7,6 +7,7 @@ import androidx.media3.common.Format import androidx.media3.common.MimeTypes import androidx.media3.common.Tracks import androidx.media3.common.util.UnstableApi +import com.github.damontecres.wholphin.ui.playback.idAsInt import timber.log.Timber import java.util.Locale @@ -118,11 +119,20 @@ fun checkForSupport(tracks: Tracks): List = } else { it.value } + } + + if (type == TrackType.VIDEO) { + listOf("res=${format.width}x${format.height}") + } else if (type == TrackType.AUDIO) { + listOf("channels=${format.channelCount}", "lang=${format.language}") + } else if (type == TrackType.TEXT) { + listOf("lang=${format.language}") + } else { + listOf() } val reason = TrackSupportReason.fromInt(it.getTrackSupport(i)) add( TrackSupport( - format.id, + format.id + " (${format.idAsInt})", type, reason, it.isSelected,