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,