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 package com.github.damontecres.wholphin.data
import com.github.damontecres.wholphin.data.model.BaseItem 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.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.MediaStream
import org.jellyfin.sdk.model.api.MediaStreamType
import org.jellyfin.sdk.model.serializer.toUUIDOrNull import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber import timber.log.Timber
import java.util.UUID import java.util.UUID
@ -24,6 +29,16 @@ class ItemPlaybackRepository
val itemPlayback = itemPlaybackDao.getItem(user = user, itemId = itemId) val itemPlayback = itemPlaybackDao.getItem(user = user, itemId = itemId)
if (itemPlayback != null) { if (itemPlayback != null) {
Timber.v("Got itemPlayback for %s", itemId) Timber.v("Got itemPlayback for %s", itemId)
getChosenItemFromPlayback(item, itemPlayback)
} else {
null
}
}
fun getChosenItemFromPlayback(
item: BaseItem,
itemPlayback: ItemPlayback,
): ChosenStreams? {
val source = val source =
item.data.mediaSources?.firstOrNull { it.id?.toUUIDOrNull() == itemPlayback.sourceId } item.data.mediaSources?.firstOrNull { it.id?.toUUIDOrNull() == itemPlayback.sourceId }
if (source != null) { if (source != null) {
@ -39,23 +54,68 @@ class ItemPlaybackRepository
} else { } else {
null null
} }
ChosenStreams( return ChosenStreams(
itemId, itemPlayback,
item.id,
source.id?.toUUIDOrNull(),
audioStream, audioStream,
subtitleStream, subtitleStream,
itemPlayback.subtitleIndex == TrackIndex.DISABLED, itemPlayback.subtitleIndex == TrackIndex.DISABLED,
) )
} else { } 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( data class ChosenStreams(
val itemPlayback: ItemPlayback,
val itemId: UUID, val itemId: UUID,
val sourceId: UUID?,
val audioStream: MediaStream?, val audioStream: MediaStream?,
val subtitleStream: MediaStream?, val subtitleStream: MediaStream?,
val subtitlesDisabled: Boolean, val subtitlesDisabled: Boolean,

View file

@ -7,9 +7,18 @@ import androidx.room.ForeignKey
import androidx.room.Index import androidx.room.Index
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import com.github.damontecres.wholphin.data.JellyfinUser 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 com.github.damontecres.wholphin.util.UuidSerializer
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.UseSerializers 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 import java.util.UUID
@Entity( @Entity(
@ -39,6 +48,119 @@ data class ItemPlayback(
@Transient val subtitleIndexEnabled = subtitleIndex >= 0 @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 { object TrackIndex {
/** /**
* The user has not explicitly specified a track to use * 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.Text
import androidx.tv.material3.surfaceColorAtElevation import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.wholphin.R 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.FontAwesome
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch 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] * Parameters for rendering a [DialogPopup]
@ -79,6 +84,25 @@ data class DialogItem(
val trailingContent: @Composable (() -> Unit)? = null, val trailingContent: @Composable (() -> Unit)? = null,
val enabled: Boolean = true, val enabled: Boolean = true,
) : DialogItemEntry { ) : 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( constructor(
text: String, text: String,
@StringRes iconStringRes: Int, @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.ChosenStreams
import com.github.damontecres.wholphin.data.ItemPlaybackRepository import com.github.damontecres.wholphin.data.ItemPlaybackRepository
import com.github.damontecres.wholphin.data.model.BaseItem 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.data.model.Person
import com.github.damontecres.wholphin.hilt.AuthOkHttpClient import com.github.damontecres.wholphin.hilt.AuthOkHttpClient
import com.github.damontecres.wholphin.preferences.ThemeSongVolume 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.BaseItemKind
import org.jellyfin.sdk.model.api.ItemFields import org.jellyfin.sdk.model.api.ItemFields
import org.jellyfin.sdk.model.api.ItemSortBy 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.SortOrder
import org.jellyfin.sdk.model.api.request.GetEpisodesRequest import org.jellyfin.sdk.model.api.request.GetEpisodesRequest
import org.jellyfin.sdk.model.api.request.GetItemsRequest 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( 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.lazy.LazyColumn
import androidx.compose.foundation.relocation.BringIntoViewRequester import androidx.compose.foundation.relocation.BringIntoViewRequester
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.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue 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.ItemPlaybackRepository
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.Chapter 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.Person
import com.github.damontecres.wholphin.data.model.chooseSource
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.cards.ChapterRow import com.github.damontecres.wholphin.ui.cards.ChapterRow
import com.github.damontecres.wholphin.ui.cards.PersonRow 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.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.ExpandablePlayButtons import com.github.damontecres.wholphin.ui.components.ExpandablePlayButtons
import com.github.damontecres.wholphin.ui.components.LoadingPage 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.ItemDetailsDialog
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
import com.github.damontecres.wholphin.ui.detail.LoadingItemViewModel 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.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.letNotEmpty import com.github.damontecres.wholphin.ui.letNotEmpty
@ -68,7 +70,9 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.playStateApi 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.extensions.ticks
import org.jellyfin.sdk.model.serializer.toUUID
import java.util.UUID import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
import kotlin.time.Duration import kotlin.time.Duration
@ -119,6 +123,46 @@ class MovieViewModel
} }
init(itemId, null) 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 @Composable
@ -139,6 +183,7 @@ fun MovieDetails(
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) } var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
var moreDialog by remember { mutableStateOf<DialogParams?>(null) } var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
var chooseVersion by remember { mutableStateOf<DialogParams?>(null) }
when (val state = loading) { when (val state = loading) {
is LoadingState.Error -> ErrorMessage(state) is LoadingState.Error -> ErrorMessage(state)
@ -179,30 +224,44 @@ fun MovieDetails(
fromLongClick = false, fromLongClick = false,
title = movie.name + " (${movie.data.productionYear ?: ""})", title = movie.name + " (${movie.data.productionYear ?: ""})",
items = items =
listOf( buildMoreDialogItems(
DialogItem( item = movie,
"Play", watched = movie.data.userData?.played ?: false,
Icons.Default.PlayArrow, series = null,
iconColor = Color.Green.copy(alpha = .8f), sourceId = chosenStreams?.sourceId,
) { navigateTo = viewModel.navigationManager::navigateTo,
viewModel.navigationManager.navigateTo( onClickWatch = viewModel::setWatched,
Destination.Playback(movie), 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, waitToLoad = params.fromLongClick,
) )
} }
chooseVersion?.let { params ->
DialogPopup(
showDialog = true,
title = params.title,
dialogItems = params.items,
onDismissRequest = { chooseVersion = null },
dismissOnClick = true,
waitToLoad = params.fromLongClick,
)
}
} }
@Composable @Composable
@ -300,6 +369,7 @@ fun MovieDetailsContent(
.padding(bottom = 56.dp), .padding(bottom = 56.dp),
) { ) {
MovieDetailsHeader( MovieDetailsHeader(
preferences = preferences,
movie = movie, movie = movie,
chosenStreams = chosenStreams, chosenStreams = chosenStreams,
bringIntoViewRequester = bringIntoViewRequester, bringIntoViewRequester = bringIntoViewRequester,

View file

@ -10,7 +10,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.relocation.BringIntoViewRequester import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha 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.R
import com.github.damontecres.wholphin.data.ChosenStreams import com.github.damontecres.wholphin.data.ChosenStreams
import com.github.damontecres.wholphin.data.model.BaseItem 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.DotSeparatedRow
import com.github.damontecres.wholphin.ui.components.OverviewText import com.github.damontecres.wholphin.ui.components.OverviewText
import com.github.damontecres.wholphin.ui.components.StarRating import com.github.damontecres.wholphin.ui.components.StarRating
@ -43,6 +44,7 @@ import org.jellyfin.sdk.model.extensions.ticks
@Composable @Composable
fun MovieDetailsHeader( fun MovieDetailsHeader(
preferences: UserPreferences,
movie: BaseItem, movie: BaseItem,
chosenStreams: ChosenStreams?, chosenStreams: ChosenStreams?,
bringIntoViewRequester: BringIntoViewRequester, bringIntoViewRequester: BringIntoViewRequester,
@ -141,15 +143,16 @@ fun MovieDetailsHeader(
.fillMaxWidth(), .fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp),
) { ) {
dto.mediaStreams?.firstOrNull { it.type == MediaStreamType.VIDEO }?.displayTitle?.let { chooseStream(dto, chosenStreams?.itemPlayback, MediaStreamType.VIDEO, preferences)
?.displayTitle
?.let {
TitleValueText( TitleValueText(
stringResource(R.string.video), stringResource(R.string.video),
it, it,
modifier = Modifier.widthIn(max = 200.dp), modifier = Modifier.widthIn(max = 200.dp),
) )
} }
val audioDisplay = val audioDisplay = getAudioDisplay(movie.data, chosenStreams, preferences)
remember(movie.id, chosenStreams) { getAudioDisplay(movie.data, chosenStreams) }
audioDisplay audioDisplay
?.let { ?.let {
TitleValueText( TitleValueText(
@ -165,7 +168,7 @@ fun MovieDetailsHeader(
TitleValueText( TitleValueText(
"Subtitles", "Subtitles",
it, 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.R
import com.github.damontecres.wholphin.data.ChosenStreams import com.github.damontecres.wholphin.data.ChosenStreams
import com.github.damontecres.wholphin.data.model.BaseItem 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.ExpandablePlayButtons
import com.github.damontecres.wholphin.ui.components.TitleValueText import com.github.damontecres.wholphin.ui.components.TitleValueText
import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.isNotNullOrBlank
@ -24,6 +26,7 @@ import kotlin.time.Duration
@Composable @Composable
fun FocusedEpisodeFooter( fun FocusedEpisodeFooter(
preferences: UserPreferences,
ep: BaseItem, ep: BaseItem,
chosenStreams: ChosenStreams?, chosenStreams: ChosenStreams?,
playOnClick: (Duration) -> Unit, playOnClick: (Duration) -> Unit,
@ -52,21 +55,22 @@ fun FocusedEpisodeFooter(
Row( Row(
horizontalArrangement = Arrangement.spacedBy(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp),
) { ) {
dto.mediaStreams chooseStream(dto, chosenStreams?.itemPlayback, MediaStreamType.VIDEO, preferences)
?.firstOrNull { it.type == MediaStreamType.VIDEO }
?.displayTitle ?.displayTitle
?.let { ?.let {
TitleValueText( TitleValueText(
stringResource(R.string.video), stringResource(R.string.video),
it, 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 { audioDisplay?.let {
TitleValueText( TitleValueText(
stringResource(R.string.audio), stringResource(R.string.audio),
it, it,
modifier = Modifier.widthIn(max = 200.dp),
) )
} }
@ -77,7 +81,7 @@ fun FocusedEpisodeFooter(
TitleValueText( TitleValueText(
"Subtitles", "Subtitles",
it, 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 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.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -14,28 +11,31 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.graphics.Color
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel 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.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect 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.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.LoadingPage 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.ItemDetailsDialog
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
import com.github.damontecres.wholphin.ui.detail.EpisodeList import com.github.damontecres.wholphin.ui.detail.EpisodeList
import com.github.damontecres.wholphin.ui.detail.ItemListAndMapping import com.github.damontecres.wholphin.ui.detail.ItemListAndMapping
import com.github.damontecres.wholphin.ui.detail.SeriesViewModel 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.nav.Destination
import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.seasonEpisode import com.github.damontecres.wholphin.util.seasonEpisode
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.extensions.ticks
import org.jellyfin.sdk.model.serializer.toUUID
import timber.log.Timber import timber.log.Timber
import kotlin.time.Duration import kotlin.time.Duration
@ -101,6 +101,7 @@ fun SeriesOverview(
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) } var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
var moreDialog by remember { mutableStateOf<DialogParams?>(null) } var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
var chooseVersion by remember { mutableStateOf<DialogParams?>(null) }
LaunchedEffect(episodes) { LaunchedEffect(episodes) {
episodes?.let { episodes -> episodes?.let { episodes ->
@ -137,7 +138,68 @@ fun SeriesOverview(
LoadingState.Success -> { LoadingState.Success -> {
series?.let { series -> series?.let { series ->
LaunchedEffect(Unit) { episodeRowFocusRequester.tryRequestFocus() } 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( SeriesOverviewContent(
preferences = preferences,
series = series, series = series,
seasons = seasons.items, seasons = seasons.items,
episodes = episodes, episodes = episodes,
@ -173,8 +235,8 @@ fun SeriesOverview(
), ),
) )
}, },
onLongClick = { onLongClick = { ep ->
// TODO moreDialog = buildMoreForEpisode(ep, true)
}, },
playOnClick = { resume -> playOnClick = { resume ->
episodeList?.getOrNull(position.episodeRowIndex)?.let { episodeList?.getOrNull(position.episodeRowIndex)?.let {
@ -196,54 +258,7 @@ fun SeriesOverview(
}, },
moreOnClick = { moreOnClick = {
episodeList?.getOrNull(position.episodeRowIndex)?.let { ep -> episodeList?.getOrNull(position.episodeRowIndex)?.let { ep ->
moreDialog = moreDialog = buildMoreForEpisode(ep, false)
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
// },
),
)
} }
}, },
overviewOnClick = { overviewOnClick = {
@ -281,4 +296,14 @@ fun SeriesOverview(
waitToLoad = params.fromLongClick, 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.ChosenStreams
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.aspectRatioFloat 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.OneTimeLaunchedEffect
import com.github.damontecres.wholphin.ui.cards.BannerCard import com.github.damontecres.wholphin.ui.cards.BannerCard
import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.ErrorMessage
@ -58,6 +59,7 @@ import kotlin.time.Duration
@Composable @Composable
fun SeriesOverviewContent( fun SeriesOverviewContent(
preferences: UserPreferences,
series: BaseItem, series: BaseItem,
seasons: List<BaseItem?>, seasons: List<BaseItem?>,
episodes: EpisodeList, episodes: EpisodeList,
@ -282,6 +284,7 @@ fun SeriesOverviewContent(
item { item {
focusedEpisode?.let { ep -> focusedEpisode?.let { ep ->
FocusedEpisodeFooter( FocusedEpisodeFooter(
preferences = preferences,
ep = ep, ep = ep,
chosenStreams = chosenStreams, chosenStreams = chosenStreams,
playOnClick = playOnClick, playOnClick = playOnClick,

View file

@ -371,15 +371,25 @@ fun PlaybackOverlay(
Modifier Modifier
.align(Alignment.TopStart), .align(Alignment.TopStart),
) { ) {
currentPlayback?.tracks?.letNotEmpty { Column(
PlaybackTrackInfo( verticalArrangement = Arrangement.spacedBy(8.dp),
trackSupport = it,
modifier = modifier =
Modifier Modifier
.align(Alignment.TopStart) .align(Alignment.TopStart)
.padding(16.dp) .padding(16.dp)
.background(AppColors.TransparentBlack50), .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 package com.github.damontecres.wholphin.ui.playback
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row 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.Icon
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.util.TrackSupport import com.github.damontecres.wholphin.util.TrackSupport
/** /**
@ -28,7 +30,7 @@ fun PlaybackTrackInfo(
val selectedWeight = .5f val selectedWeight = .5f
val weights = listOf(.25f, .4f, .5f, 1f, 1f) val weights = listOf(.25f, .4f, .5f, 1f, 1f)
val textStyle = val textStyle =
MaterialTheme.typography.bodySmall.copy(color = MaterialTheme.colorScheme.onBackground) MaterialTheme.typography.bodySmall.copy(color = MaterialTheme.colorScheme.onSurface)
LazyColumn( LazyColumn(
modifier = modifier, modifier = modifier,
@ -71,7 +73,11 @@ fun PlaybackTrackInfo(
items(trackSupport) { track -> items(trackSupport) { track ->
Row( Row(
horizontalArrangement = Arrangement.spacedBy(4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier, modifier =
Modifier.ifElse(
track.selected,
Modifier.background(MaterialTheme.colorScheme.border.copy(alpha = .25f)),
),
) { ) {
val texts = val texts =
listOf( 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.Playlist
import com.github.damontecres.wholphin.data.model.PlaylistCreator import com.github.damontecres.wholphin.data.model.PlaylistCreator
import com.github.damontecres.wholphin.data.model.TrackIndex 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.AppPreference
import com.github.damontecres.wholphin.preferences.SkipSegmentBehavior import com.github.damontecres.wholphin.preferences.SkipSegmentBehavior
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
@ -158,7 +160,7 @@ class PlaybackViewModel
viewModelScope.launch( viewModelScope.launch(
LoadingExceptionHandler( LoadingExceptionHandler(
loading, loading,
"Error preparing for plaback on ${destination.itemId}", "Error preparing for playback for ${destination.itemId}",
) + Dispatchers.IO, ) + Dispatchers.IO,
) { ) {
val queriedItem = item?.data ?: api.userLibraryApi.getItem(itemId).content val queriedItem = item?.data ?: api.userLibraryApi.getItem(itemId).content
@ -212,37 +214,6 @@ class PlaybackViewModel
return@withContext false 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 dto = base
val title = val title =
if (base.type == BaseItemKind.EPISODE) { if (base.type == BaseItemKind.EPISODE) {
@ -263,14 +234,24 @@ class PlaybackViewModel
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
this@PlaybackViewModel.title.value = title this@PlaybackViewModel.title.value = title
this@PlaybackViewModel.subtitle.value = subtitle this@PlaybackViewModel.subtitle.value = subtitle
this@PlaybackViewModel.currentItemPlayback.value = playbackConfig
} }
val mediaSource =
if (playbackConfig.sourceId != null) { val playbackConfig =
base.mediaSources?.firstOrNull { it.id?.toUUIDOrNull() == playbackConfig.sourceId } if (itemPlayback != null) {
itemPlayback
} else { } 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) { if (mediaSource == null) {
showToast( showToast(
context, context,
@ -314,31 +295,31 @@ class PlaybackViewModel
}?.sortedWith(compareBy<AudioStream> { it.language }.thenByDescending { it.channels }) }?.sortedWith(compareBy<AudioStream> { it.language }.thenByDescending { it.channels })
.orEmpty() .orEmpty()
// TODO audio selection based on channel layout or preferences or default
val audioLanguage = preferences.userConfig.audioLanguagePreference
val audioIndex = val audioIndex =
if (playbackConfig.audioIndexEnabled) { chooseStream(base, playbackConfig, MediaStreamType.AUDIO, preferences)
playbackConfig.audioIndex ?.index
} else if (audioLanguage != null) {
audioStreams.firstOrNull { it.language == audioLanguage }?.index
?: audioStreams.firstOrNull()?.index
} else {
audioStreams.firstOrNull()?.index
}
val subtitleIndex = val subtitleIndex =
determineSubtitleIndex( chooseStream(base, playbackConfig, MediaStreamType.SUBTITLE, preferences)
subtitleStreams = subtitleStreams, ?.index
subtitleMode = preferences.userConfig.subtitleMode,
subtitleLanguage = preferences.userConfig.subtitleLanguagePreference,
audioLanguage = audioLanguage,
playbackConfig = playbackConfig,
)
// Timber.v("base.mediaStreams=${base.mediaStreams}") // Timber.v("base.mediaStreams=${base.mediaStreams}")
// Timber.v("subtitleTracks=$subtitleStreams") // Timber.v("subtitleTracks=$subtitleStreams")
// Timber.v("audioStreams=$audioStreams") // 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) { withContext(Dispatchers.Main) {
this@PlaybackViewModel.audioStreams.value = audioStreams this@PlaybackViewModel.audioStreams.value = audioStreams
@ -346,7 +327,7 @@ class PlaybackViewModel
changeStreams( changeStreams(
base, base,
playbackConfig, itemPlaybackToUse,
audioIndex, audioIndex,
subtitleIndex, subtitleIndex,
if (positionMs > 0) positionMs else C.TIME_UNSET, if (positionMs > 0) positionMs else C.TIME_UNSET,
@ -434,6 +415,10 @@ class PlaybackViewModel
val decision = StreamDecision(itemId, transcodeType, mediaUrl) val decision = StreamDecision(itemId, transcodeType, mediaUrl)
Timber.v("Playback decision: $decision") Timber.v("Playback decision: $decision")
val externalSubtitleCount =
source.mediaStreams
?.count { it.type == MediaStreamType.SUBTITLE && it.isExternal } ?: 0
val externalSubtitle = val externalSubtitle =
source.mediaStreams source.mediaStreams
?.firstOrNull { it.type == MediaStreamType.SUBTITLE && it.index == subtitleIndex && it.isExternal } ?.firstOrNull { it.type == MediaStreamType.SUBTITLE && it.index == subtitleIndex && it.isExternal }
@ -519,6 +504,7 @@ class PlaybackViewModel
source, source,
audioIndex, audioIndex,
subtitleIndex, subtitleIndex,
externalSubtitleCount,
) )
player.removeListener(this) player.removeListener(this)
} }
@ -731,7 +717,7 @@ data class CurrentPlayback(
val playSessionId: String?, val playSessionId: String?,
) )
private val Format.idAsInt: Int? val Format.idAsInt: Int?
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
get() = get() =
id?.let { id?.let {
@ -748,6 +734,7 @@ private fun applyTrackSelections(
source: MediaSourceInfo, source: MediaSourceInfo,
audioIndex: Int?, audioIndex: Int?,
subtitleIndex: Int?, subtitleIndex: Int?,
externalSubtitleCount: Int,
) { ) {
if (source.supportsDirectPlay) { if (source.supportsDirectPlay) {
if (subtitleIndex != null && subtitleIndex >= 0) { if (subtitleIndex != null && subtitleIndex >= 0) {
@ -786,7 +773,7 @@ private fun applyTrackSelections(
(0..<group.mediaTrackGroup.length) (0..<group.mediaTrackGroup.length)
.map { .map {
group.getTrackFormat(it).idAsInt 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") Timber.v("Chosen audio track: $chosenTrack")
chosenTrack?.let { chosenTrack?.let {

View file

@ -5,6 +5,10 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.ChosenStreams 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 com.github.damontecres.wholphin.ui.isNotNullOrBlank
import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.MediaStream import org.jellyfin.sdk.model.api.MediaStream
@ -65,16 +69,32 @@ fun formatSubtitleLang(mediaStreams: List<MediaStream>?): String? =
?.distinct() ?.distinct()
?.joinToString(", ") { languageName(it) } ?.joinToString(", ") { languageName(it) }
/**
* Gets the selected audio display title for the given item & chosen streams
*/
fun getAudioDisplay( fun getAudioDisplay(
item: BaseItemDto, item: BaseItemDto,
chosenStreams: ChosenStreams?, chosenStreams: ChosenStreams?,
) = ( preferences: UserPreferences,
chosenStreams?.audioStream ) = getAudioDisplay(item, chosenStreams?.itemPlayback, preferences)
?: item.mediaStreams?.firstOrNull { it.type == MediaStreamType.AUDIO }
)?.displayTitle /**
* 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", "") ?.replace(" - Default", "")
?.ifBlank { null } ?.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 @Composable
fun getSubtitleDisplay( fun getSubtitleDisplay(
item: BaseItemDto, item: BaseItemDto,
@ -84,5 +104,7 @@ fun getSubtitleDisplay(
} else if (chosenStreams?.subtitleStream != null) { } else if (chosenStreams?.subtitleStream != null) {
languageName(chosenStreams.subtitleStream.language) languageName(chosenStreams.subtitleStream.language)
} else { } 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.MimeTypes
import androidx.media3.common.Tracks import androidx.media3.common.Tracks
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import com.github.damontecres.wholphin.ui.playback.idAsInt
import timber.log.Timber import timber.log.Timber
import java.util.Locale import java.util.Locale
@ -118,11 +119,20 @@ fun checkForSupport(tracks: Tracks): List<TrackSupport> =
} else { } else {
it.value 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)) val reason = TrackSupportReason.fromInt(it.getTrackSupport(i))
add( add(
TrackSupport( TrackSupport(
format.id, format.id + " (${format.idAsInt})",
type, type,
reason, reason,
it.isSelected, it.isSelected,