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
<img width="1280" height="720" alt="choose_version_button_unfocused
Large"
src="https://github.com/user-attachments/assets/4f6051ef-b6ae-4fad-9d54-3840523350ed"
/>

<img width="1280" height="720" alt="choose_version_button Large"
src="https://github.com/user-attachments/assets/0e8412bc-2d18-4c6c-987a-2f067363ad2c"
/>

## AI or LLM usage
None
This commit is contained in:
Damontecres 2026-06-07 07:15:30 -04:00 • committed by GitHub
parent 99fab9a383
commit d1b8c09615
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 141 additions and 1 deletions

View file

@ -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?,

View file

@ -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) {

View file

@ -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) },
)

View file

@ -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<DialogParams?>(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,
)
}

View file

@ -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<MediaSourceInfo>,
val onChooseVersion: (MediaSourceInfo) -> Unit,
)

View file

@ -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()

View file

@ -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()

View file

@ -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(),
)
}

View file

@ -311,6 +311,7 @@ fun SeriesOverview(
},
canDelete = { viewModel.canDelete(it, preferences.appPreferences) },
onConfirmDelete = viewModel::deleteItem,
onChooseVersion = contextActions.onChooseVersion,
modifier = modifier,
)
}

View file

@ -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)