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.
This commit is contained in:
damontecres 2025-10-18 14:11:06 -04:00 committed by GitHub
parent e2db5ab4f3
commit f57fd25f8e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 771 additions and 196 deletions

View file

@ -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,6 +29,16 @@ class ItemPlaybackRepository
val itemPlayback = itemPlaybackDao.getItem(user = user, itemId = itemId)
if (itemPlayback != null) {
Timber.v("Got itemPlayback for %s", itemId)
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) {
@ -39,23 +54,68 @@ class ItemPlaybackRepository
} else {
null
}
ChosenStreams(
itemId,
return ChosenStreams(
itemPlayback,
item.id,
source.id?.toUUIDOrNull(),
audioStream,
subtitleStream,
itemPlayback.subtitleIndex == TrackIndex.DISABLED,
)
} else {
null
return null
}
} else {
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,

View file

@ -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<MediaSourceInfo>?) =
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<MediaStream>,
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<MediaStream> { 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<MediaStream>,
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

View file

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

View file

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

View file

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

View file

@ -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<ItemDetailsDialogInfo?>(null) }
var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
var chooseVersion by remember { mutableStateOf<DialogParams?>(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,

View file

@ -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 {
chooseStream(dto, chosenStreams?.itemPlayback, MediaStreamType.VIDEO, preferences)
?.displayTitle
?.let {
TitleValueText(
stringResource(R.string.video),
it,
modifier = Modifier.widthIn(max = 200.dp),
)
}
val audioDisplay =
remember(movie.id, chosenStreams) { getAudioDisplay(movie.data, chosenStreams) }
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),
)
}
}

View file

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

View file

@ -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<ItemDetailsDialogInfo?>(null) }
var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
var chooseVersion by remember { mutableStateOf<DialogParams?>(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,
)
}
}

View file

@ -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<BaseItem?>,
episodes: EpisodeList,
@ -282,6 +284,7 @@ fun SeriesOverviewContent(
item {
focusedEpisode?.let { ep ->
FocusedEpisodeFooter(
preferences = preferences,
ep = ep,
chosenStreams = chosenStreams,
playOnClick = playOnClick,

View file

@ -371,15 +371,25 @@ fun PlaybackOverlay(
Modifier
.align(Alignment.TopStart),
) {
currentPlayback?.tracks?.letNotEmpty {
PlaybackTrackInfo(
trackSupport = it,
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,
)
}
}
}
}

View file

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

View file

@ -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<AudioStream> { 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..<group.mediaTrackGroup.length)
.map {
group.getTrackFormat(it).idAsInt
}.contains(audioIndex + 1) // Indexes are 1 based
}.contains(audioIndex - externalSubtitleCount + 1) // Indexes are 1 based
}
Timber.v("Chosen audio track: $chosenTrack")
chosenTrack?.let {

View file

@ -5,6 +5,10 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.ChosenStreams
import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.data.model.chooseSource
import com.github.damontecres.wholphin.data.model.chooseStream
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.MediaStream
@ -65,16 +69,32 @@ fun formatSubtitleLang(mediaStreams: List<MediaStream>?): 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)
}
}

View file

@ -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<TrackSupport> =
} 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,