From d1b8c0961520d7fa8506b3b6bf17a9216df8dd5d Mon Sep 17 00:00:00 2001 From: Damontecres Date: Sun, 7 Jun 2026 07:15:30 -0400 Subject: [PATCH] Add a shortcut to chosing the version of an item (#1522) ## Description Based on feedback from users, many felt it was difficult to 1) tell if multiple version of an item existed and 2) choose the version they want So this PR adds a new expandable button next to Play/Resume/Restart to choose the version. It is a shortcut to the More->Choose Version dialog, but also serves as a more prominent indicator that there are multiple versions. The button is only shown if there are 2+ versions to choose from. Also adds displaying the bitrate in the choose version dialog just in case there are identically named versions with the same resolution. Dev note: I also added some docs for classes & functions related to choosing a version ### Related issues Related to #652 ### Testing Emulator ## Screenshots choose_version_button_unfocused
Large choose_version_button Large ## AI or LLM usage None --- .../wholphin/data/ItemPlaybackRepository.kt | 35 +++++++++++++++ .../wholphin/ui/components/ContextMenu.kt | 2 + .../wholphin/ui/components/Dialogs.kt | 10 ++++- .../wholphin/ui/components/PlayButtons.kt | 43 +++++++++++++++++++ .../wholphin/ui/data/ChooseVersionParams.kt | 13 ++++++ .../ui/detail/episode/EpisodeDetails.kt | 12 ++++++ .../wholphin/ui/detail/movie/MovieDetails.kt | 12 ++++++ .../ui/detail/series/FocusedEpisodeFooter.kt | 11 +++++ .../ui/detail/series/SeriesOverview.kt | 1 + .../ui/detail/series/SeriesOverviewContent.kt | 3 ++ 10 files changed, 141 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/data/ChooseVersionParams.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 80a2c45f..b2dedf26 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 @@ -19,6 +19,9 @@ import javax.inject.Inject import javax.inject.Singleton import kotlin.time.Duration +/** + * Wrapper for [ItemPlaybackDao] to save [ItemPlayback] objects and also convert them into [ChosenStreams] + */ @Singleton class ItemPlaybackRepository @Inject @@ -28,6 +31,13 @@ class ItemPlaybackRepository private val playbackLanguageChoiceDao: PlaybackLanguageChoiceDao, private val streamChoiceService: StreamChoiceService, ) { + /** + * Get the chosen source & stream for the given item + * + * This will be either the explicit choice from the user ([ItemPlayback]) or the default choices + * + * @see getChosenItemFromPlayback + */ suspend fun getSelectedTracks( itemId: UUID, item: BaseItem, @@ -40,6 +50,11 @@ class ItemPlaybackRepository return getChosenItemFromPlayback(item, itemPlayback, plc, prefs) } + /** + * Get the chosen source & stream for the given item + * + * This will be either the explicit choice from the provided [ItemPlayback] & [PlaybackLanguageChoice] or the default choices if they are null + */ fun getChosenItemFromPlayback( item: BaseItem, itemPlayback: ItemPlayback?, @@ -86,6 +101,9 @@ class ItemPlaybackRepository } } + /** + * Save the choice for a specific media source for an item + */ suspend fun savePlayVersion( itemId: UUID, sourceId: UUID, @@ -103,6 +121,11 @@ class ItemPlaybackRepository } } + /** + * Save the chosen track (index & type) for an item + * + * Additionally, if the item is part of a TV Series, store the language as the preferred one for this series + */ suspend fun saveTrackSelection( item: BaseItem, itemPlayback: ItemPlayback?, @@ -180,6 +203,9 @@ class ItemPlaybackRepository return toSave.copy(rowId = id) } + /** + * Wrapper for [ItemPlaybackDao.getTrackModifications] to get the [ItemTrackModification] for an item, if any + */ suspend fun getTrackModifications( itemId: UUID, trackIndex: Int, @@ -188,6 +214,9 @@ class ItemPlaybackRepository itemPlaybackDao.getTrackModifications(userId, itemId, trackIndex) } + /** + * Create and save a [ItemTrackModification] via [ItemPlaybackDao.saveItem] + */ suspend fun saveTrackModifications( itemId: UUID, trackIndex: Int, @@ -206,6 +235,9 @@ class ItemPlaybackRepository } } + /** + * Clear the user's source/stream choices for an item + */ suspend fun deleteChosenStreams(chosenStreams: ChosenStreams?) { Timber.d("deleteChosenStreams: %s", chosenStreams) chosenStreams?.plc?.let { @@ -219,6 +251,9 @@ class ItemPlaybackRepository } } +/** + * Info about which media source and streams the user/app has chosen for playback + */ data class ChosenStreams( val itemPlayback: ItemPlayback?, val plc: PlaybackLanguageChoice?, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ContextMenu.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ContextMenu.kt index c41c27f0..4e86ae21 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ContextMenu.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ContextMenu.kt @@ -274,6 +274,7 @@ fun ContextMenu( onDismissRequest = { chooseVersion = null }, dismissOnClick = true, waitToLoad = params.fromLongClick, + elevation = 3.dp, ) } } @@ -285,6 +286,7 @@ fun ContextMenu( actions.onDeleteItem.invoke(item) onDismissRequest.invoke() }, + elevation = 3.dp, ) } if (showPlayWithDialog) { 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 6bd3dfaa..2224a010 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 @@ -64,6 +64,7 @@ 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.formatBitrate import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.playback.SimpleMediaStream @@ -630,7 +631,14 @@ fun chooseVersionParams( SelectedLeadingContent(uuid != null && uuid == chosenSourceId) }, supportingContent = { - videoStream?.displayTitle?.let { Text(text = it) } + val text = + remember { + buildList { + videoStream?.displayTitle?.let(::add) + source.bitrate?.let(::formatBitrate)?.let(::add) + }.joinToString(", ") + } + Text(text) }, onClick = { onClick.invoke(index) }, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/PlayButtons.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/PlayButtons.kt index a0c427b9..eea3c9f9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/PlayButtons.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/PlayButtons.kt @@ -43,6 +43,7 @@ import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.isSpecified import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign @@ -56,6 +57,7 @@ import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.Trailer import com.github.damontecres.wholphin.ui.FontAwesome import com.github.damontecres.wholphin.ui.PreviewTvSpec +import com.github.damontecres.wholphin.ui.data.ChooseVersionParams import com.github.damontecres.wholphin.ui.data.SortAndDirection import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.theme.PreviewInteractionSource @@ -63,6 +65,7 @@ import com.github.damontecres.wholphin.ui.theme.WholphinTheme import com.github.damontecres.wholphin.ui.tryRequestFocus import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.SortOrder +import org.jellyfin.sdk.model.serializer.toUUIDOrNull import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds @@ -83,9 +86,11 @@ fun ExpandablePlayButtons( moreOnClick: () -> Unit, trailerOnClick: (Trailer) -> Unit, onConfirmDelete: () -> Unit, + chooseVersionParams: ChooseVersionParams?, buttonOnFocusChanged: (FocusState) -> Unit, modifier: Modifier = Modifier, ) { + val resources = LocalResources.current val firstFocus = remember { FocusRequester() } val restartInteractionSource = remember { MutableInteractionSource() } val restartButtonFocused by restartInteractionSource.collectIsFocusedAsState() @@ -95,6 +100,9 @@ fun ExpandablePlayButtons( firstFocus.tryRequestFocus() } } + + var chooseVersion by remember { mutableStateOf(null) } + LazyRow( horizontalArrangement = Arrangement.spacedBy(16.dp), contentPadding = PaddingValues(8.dp), @@ -129,6 +137,30 @@ fun ExpandablePlayButtons( } } + if (chooseVersionParams != null && chooseVersionParams.mediaSources.size > 1) { + item { + ExpandableFaButton( + title = R.string.version, + iconStringRes = R.string.fa_file_video, + onClick = { + chooseVersion = + chooseVersionParams( + resources, + chooseVersionParams.mediaSources.orEmpty(), + chooseVersionParams.chosenStreams + ?.source + ?.id + ?.toUUIDOrNull(), + ) { idx -> + val source = chooseVersionParams.mediaSources[idx] + chooseVersionParams.onChooseVersion.invoke(source) + } + }, + modifier = Modifier.onFocusChanged(buttonOnFocusChanged), + ) + } + } + // Watched button item("watched") { ExpandableFaButton( @@ -182,6 +214,16 @@ fun ExpandablePlayButtons( ) } } + chooseVersion?.let { params -> + DialogPopup( + showDialog = true, + title = params.title, + dialogItems = params.items, + onDismissRequest = { chooseVersion = null }, + dismissOnClick = true, + waitToLoad = params.fromLongClick, + ) + } } val MinButtonSize = 40.dp @@ -451,6 +493,7 @@ private fun ExpandablePlayButtonsPreview() { trailerOnClick = {}, canDelete = true, onConfirmDelete = {}, + chooseVersionParams = null, modifier = Modifier, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/data/ChooseVersionParams.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/data/ChooseVersionParams.kt new file mode 100644 index 00000000..397a2126 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/data/ChooseVersionParams.kt @@ -0,0 +1,13 @@ +package com.github.damontecres.wholphin.ui.data + +import com.github.damontecres.wholphin.data.ChosenStreams +import org.jellyfin.sdk.model.api.MediaSourceInfo + +/** + * Necessary details & action for choosing a version of some content + */ +data class ChooseVersionParams( + val chosenStreams: ChosenStreams?, + val mediaSources: List, + val onChooseVersion: (MediaSourceInfo) -> Unit, +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt index fa45058d..dd91e00b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt @@ -39,6 +39,7 @@ import com.github.damontecres.wholphin.ui.components.HeaderUtils import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.Optional import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel +import com.github.damontecres.wholphin.ui.data.ChooseVersionParams import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo import com.github.damontecres.wholphin.ui.detail.PlaylistDialog @@ -47,6 +48,7 @@ import com.github.damontecres.wholphin.ui.rememberInt import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.ExceptionHandler import kotlinx.coroutines.launch +import org.jellyfin.sdk.model.api.MediaSourceInfo import org.jellyfin.sdk.model.api.MediaType import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.serializer.toUUID @@ -169,6 +171,7 @@ fun EpisodeDetails( }, canDelete = canDelete, onConfirmDelete = { viewModel.deleteItem(ep) }, + onChooseVersion = { contextActions.onChooseVersion.invoke(ep, it) }, modifier = modifier, ) } @@ -224,6 +227,7 @@ fun EpisodeDetailsContent( moreOnClick: () -> Unit, canDelete: Boolean, onConfirmDelete: () -> Unit, + onChooseVersion: (MediaSourceInfo) -> Unit, modifier: Modifier = Modifier, ) { val context = LocalContext.current @@ -284,6 +288,14 @@ fun EpisodeDetailsContent( trailerOnClick = {}, canDelete = canDelete, onConfirmDelete = onConfirmDelete, + chooseVersionParams = + remember(chosenStreams, ep, onChooseVersion) { + ChooseVersionParams( + chosenStreams = chosenStreams, + mediaSources = ep.data.mediaSources.orEmpty(), + onChooseVersion = onChooseVersion, + ) + }, modifier = Modifier .fillMaxWidth() 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 785ca74e..42742c85 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 @@ -54,6 +54,7 @@ import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.Optional import com.github.damontecres.wholphin.ui.components.PersonContextActions import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel +import com.github.damontecres.wholphin.ui.data.ChooseVersionParams import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo import com.github.damontecres.wholphin.ui.detail.PlaylistDialog @@ -68,6 +69,7 @@ import com.github.damontecres.wholphin.util.DiscoverRequestType import com.github.damontecres.wholphin.util.ExceptionHandler import kotlinx.coroutines.launch import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.MediaSourceInfo import org.jellyfin.sdk.model.api.MediaType import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.serializer.toUUID @@ -236,6 +238,7 @@ fun MovieDetails( }, canDelete = state.canDelete, onConfirmDelete = { state.movie?.let { viewModel.deleteItem(it) } }, + onChooseVersion = { contextActions.onChooseVersion.invoke(movie, it) }, modifier = modifier, ) } @@ -304,6 +307,7 @@ fun MovieDetailsContent( onClickDiscover: (Int, DiscoverItem) -> Unit, canDelete: Boolean, onConfirmDelete: () -> Unit, + onChooseVersion: (MediaSourceInfo) -> Unit, modifier: Modifier = Modifier, ) { val context = LocalContext.current @@ -369,6 +373,14 @@ fun MovieDetailsContent( }, canDelete = canDelete, onConfirmDelete = onConfirmDelete, + chooseVersionParams = + remember(state.chosenStreams, movie, onChooseVersion) { + ChooseVersionParams( + chosenStreams = state.chosenStreams, + mediaSources = movie.data.mediaSources.orEmpty(), + onChooseVersion = onChooseVersion, + ) + }, modifier = Modifier .fillMaxWidth() 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 83987524..600dee28 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 com.github.damontecres.wholphin.data.ChosenStreams import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.components.ExpandablePlayButtons +import com.github.damontecres.wholphin.ui.data.ChooseVersionParams +import org.jellyfin.sdk.model.api.MediaSourceInfo import org.jellyfin.sdk.model.extensions.ticks import kotlin.time.Duration @@ -27,6 +29,7 @@ fun FocusedEpisodeFooter( favoriteOnClick: () -> Unit, canDelete: Boolean, onConfirmDelete: () -> Unit, + onChooseVersion: (MediaSourceInfo) -> Unit, modifier: Modifier = Modifier, buttonOnFocusChanged: (FocusState) -> Unit = {}, ) { @@ -52,6 +55,14 @@ fun FocusedEpisodeFooter( trailerOnClick = {}, canDelete = canDelete, onConfirmDelete = onConfirmDelete, + chooseVersionParams = + remember(chosenStreams, ep, onChooseVersion) { + ChooseVersionParams( + chosenStreams = chosenStreams, + mediaSources = ep.data.mediaSources.orEmpty(), + onChooseVersion = onChooseVersion, + ) + }, modifier = Modifier.fillMaxWidth(), ) } 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 7532be7a..fed2d342 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 @@ -311,6 +311,7 @@ fun SeriesOverview( }, canDelete = { viewModel.canDelete(it, preferences.appPreferences) }, onConfirmDelete = viewModel::deleteItem, + onChooseVersion = contextActions.onChooseVersion, modifier = modifier, ) } 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 999ed7c4..5d071387 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 @@ -66,6 +66,7 @@ import com.github.damontecres.wholphin.ui.rememberInt import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.util.rememberDelayedNestedScroll import kotlinx.coroutines.launch +import org.jellyfin.sdk.model.api.MediaSourceInfo import org.jellyfin.sdk.model.api.PersonKind import kotlin.time.Duration @@ -97,6 +98,7 @@ fun SeriesOverviewContent( canDelete: (BaseItem) -> Boolean, onConfirmDelete: (BaseItem) -> Unit, onClickExtra: (Int, ExtrasItem) -> Unit, + onChooseVersion: (BaseItem, MediaSourceInfo) -> Unit, modifier: Modifier = Modifier, ) { val scope = rememberCoroutineScope() @@ -323,6 +325,7 @@ fun SeriesOverviewContent( }, canDelete = canDelete.invoke(ep), onConfirmDelete = { onConfirmDelete.invoke(ep) }, + onChooseVersion = { onChooseVersion.invoke(ep, it) }, modifier = Modifier .padding(top = 4.dp)