Remember playback choices & fix some track selection issues (#26)

Remembers changes in playback tracks across playbacks. So, if you change
the audio track, quit the app, and return to the same id, the app will
play the audio track previously chosen.

Additionally, this PR fixes a UI inconsistency when there are multiple
versions of a item. Previously sometimes the audio/subtitle tracks for
wrong version would be shown during playback.
This commit is contained in:
damontecres 2025-10-17 17:19:44 -04:00 committed by GitHub
parent f220aeee44
commit 6194eba8c5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 1152 additions and 154 deletions

View file

@ -40,6 +40,7 @@ import kotlinx.coroutines.withContext
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.extensions.ticks
import timber.log.Timber
import java.util.UUID
import kotlin.contracts.ExperimentalContracts
import kotlin.contracts.InvocationKind
import kotlin.contracts.contract
@ -351,3 +352,11 @@ fun CoroutineScope.launchIO(
start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> Unit,
): Job = launch(context = Dispatchers.IO + context, start = start, block = block)
/**
* Converts a UUID to the format used server-side (ie without hyphens).
*
* This is the inverse of [org.jellyfin.sdk.model.serializer.toUUID]
*
*/
fun UUID.toServerString() = this.toString().replace("-", "")

View file

@ -11,11 +11,14 @@ import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.okhttp.OkHttpDataSource
import androidx.media3.exoplayer.ExoPlayer
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.Person
import com.github.damontecres.wholphin.hilt.AuthOkHttpClient
import com.github.damontecres.wholphin.preferences.ThemeSongVolume
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.letNotEmpty
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.nav.NavigationManager
@ -29,6 +32,7 @@ import com.github.damontecres.wholphin.util.LoadingState
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@ -57,6 +61,7 @@ class SeriesViewModel
@param:ApplicationContext val context: Context,
@param:AuthOkHttpClient private val okHttpClient: OkHttpClient,
private val navigationManager: NavigationManager,
private val itemPlaybackRepository: ItemPlaybackRepository,
) : ItemViewModel(api) {
private var player: Player? = null
private lateinit var seriesId: UUID
@ -306,6 +311,23 @@ class SeriesViewModel
release()
navigationManager.navigateTo(destination)
}
val chosenStreams = MutableLiveData<ChosenStreams?>(null)
private var chosenStreamsJob: Job? = null
fun lookUpChosenTracks(
itemId: UUID,
item: BaseItem,
) {
chosenStreamsJob?.cancel()
chosenStreamsJob =
viewModelScope.launchIO {
val result = itemPlaybackRepository.getSelectedTracks(itemId, item)
withContext(Dispatchers.Main) {
chosenStreams.value = result
}
}
}
}
data class ItemListAndMapping(

View file

@ -36,6 +36,8 @@ import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import androidx.tv.material3.MaterialTheme
import coil3.compose.AsyncImage
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.Person
@ -52,6 +54,7 @@ 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.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.letNotEmpty
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.nav.NavigationManager
@ -62,6 +65,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
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.extensions.ticks
@ -75,10 +79,12 @@ class MovieViewModel
constructor(
api: ApiClient,
val navigationManager: NavigationManager,
val itemPlaybackRepository: ItemPlaybackRepository,
) : LoadingItemViewModel(api) {
private lateinit var itemId: UUID
val people = MutableLiveData<List<Person>>(listOf())
val chapters = MutableLiveData<List<Chapter>>(listOf())
val chosenStreams = MutableLiveData<ChosenStreams?>(null)
override fun init(
itemId: UUID,
@ -88,6 +94,12 @@ class MovieViewModel
return viewModelScope.launch(ExceptionHandler()) {
super.init(itemId, potential)?.join()
item.value?.let { item ->
viewModelScope.launchIO {
val result = itemPlaybackRepository.getSelectedTracks(item.id, item)
withContext(Dispatchers.Main) {
chosenStreams.value = result
}
}
people.value =
item.data.people
?.letNotEmpty { people ->
@ -123,6 +135,7 @@ fun MovieDetails(
val people by viewModel.people.observeAsState(listOf())
val chapters by viewModel.chapters.observeAsState(listOf())
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
val chosenStreams by viewModel.chosenStreams.observeAsState(null)
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
@ -137,6 +150,7 @@ fun MovieDetails(
MovieDetailsContent(
preferences = preferences,
movie = movie,
chosenStreams = chosenStreams,
people = people,
chapters = chapters,
playOnClick = {
@ -222,6 +236,7 @@ fun MovieDetails(
fun MovieDetailsContent(
preferences: UserPreferences,
movie: BaseItem,
chosenStreams: ChosenStreams?,
people: List<Person>,
chapters: List<Chapter>,
playOnClick: (Duration) -> Unit,
@ -286,6 +301,7 @@ fun MovieDetailsContent(
) {
MovieDetailsHeader(
movie = movie,
chosenStreams = chosenStreams,
bringIntoViewRequester = bringIntoViewRequester,
overviewOnClick = overviewOnClick,
Modifier

View file

@ -10,6 +10,7 @@ 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
@ -24,6 +25,7 @@ import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme
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.ui.components.DotSeparatedRow
import com.github.damontecres.wholphin.ui.components.OverviewText
@ -33,7 +35,8 @@ import com.github.damontecres.wholphin.ui.components.TitleValueText
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.roundMinutes
import com.github.damontecres.wholphin.ui.timeRemaining
import com.github.damontecres.wholphin.util.formatSubtitleLang
import com.github.damontecres.wholphin.util.getAudioDisplay
import com.github.damontecres.wholphin.util.getSubtitleDisplay
import org.jellyfin.sdk.model.api.MediaStreamType
import org.jellyfin.sdk.model.api.PersonKind
import org.jellyfin.sdk.model.extensions.ticks
@ -41,6 +44,7 @@ import org.jellyfin.sdk.model.extensions.ticks
@Composable
fun MovieDetailsHeader(
movie: BaseItem,
chosenStreams: ChosenStreams?,
bringIntoViewRequester: BringIntoViewRequester,
overviewOnClick: () -> Unit,
modifier: Modifier = Modifier,
@ -144,24 +148,18 @@ fun MovieDetailsHeader(
modifier = Modifier.widthIn(max = 200.dp),
)
}
dto.mediaStreams
?.firstOrNull { it.type == MediaStreamType.AUDIO }
?.displayTitle
val audioDisplay =
remember(movie.id, chosenStreams) { getAudioDisplay(movie.data, chosenStreams) }
audioDisplay
?.let {
// TODO probably a cleaner way to do this
// Removes part of "5.1 Surround - English - AAC - Default"
it
.replace(" - Default", "")
.ifBlank { null }
?.let {
TitleValueText(
stringResource(R.string.audio),
it,
modifier = Modifier.widthIn(max = 200.dp),
)
}
TitleValueText(
stringResource(R.string.audio),
it,
modifier = Modifier.widthIn(max = 200.dp),
)
}
formatSubtitleLang(dto.mediaStreams)
getSubtitleDisplay(movie.data, chosenStreams)
?.let {
if (it.isNotNullOrBlank()) {
TitleValueText(

View file

@ -11,11 +11,13 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.res.stringResource
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.ui.components.ExpandablePlayButtons
import com.github.damontecres.wholphin.ui.components.TitleValueText
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.util.formatSubtitleLang
import com.github.damontecres.wholphin.util.getAudioDisplay
import com.github.damontecres.wholphin.util.getSubtitleDisplay
import org.jellyfin.sdk.model.api.MediaStreamType
import org.jellyfin.sdk.model.extensions.ticks
import kotlin.time.Duration
@ -23,6 +25,7 @@ import kotlin.time.Duration
@Composable
fun FocusedEpisodeFooter(
ep: BaseItem,
chosenStreams: ChosenStreams?,
playOnClick: (Duration) -> Unit,
moreOnClick: () -> Unit,
watchOnClick: () -> Unit,
@ -58,25 +61,17 @@ fun FocusedEpisodeFooter(
it,
)
}
val audioDisplay =
remember(ep.id, chosenStreams) { getAudioDisplay(ep.data, chosenStreams) }
audioDisplay?.let {
TitleValueText(
stringResource(R.string.audio),
it,
)
}
dto.mediaStreams
?.firstOrNull { it.type == MediaStreamType.AUDIO }
?.displayTitle
?.let {
// TODO probably a cleaner way to do this
// Removes part of "5.1 Surround - English - AAC - Default"
it
.replace(" - Default", "")
.ifBlank { null }
?.let {
TitleValueText(
stringResource(R.string.audio),
it,
)
}
}
formatSubtitleLang(dto.mediaStreams)
val subtitleText = getSubtitleDisplay(ep.data, chosenStreams)
subtitleText
?.let {
if (it.isNotNullOrBlank()) {
TitleValueText(

View file

@ -116,6 +116,17 @@ fun SeriesOverview(
}
}
LaunchedEffect(position) {
(episodes as? EpisodeList.Success)
?.episodes
?.items
?.getOrNull(position.episodeRowIndex)
?.let {
viewModel.lookUpChosenTracks(it.id, it)
}
}
val chosenStreams by viewModel.chosenStreams.observeAsState(null)
when (val state = loading) {
is LoadingState.Error -> ErrorMessage(state)
@ -130,6 +141,7 @@ fun SeriesOverview(
series = series,
seasons = seasons.items,
episodes = episodes,
chosenStreams = chosenStreams,
position = position,
backdropImageUrl =
remember {

View file

@ -42,6 +42,7 @@ import androidx.tv.material3.TabRow
import androidx.tv.material3.TabRowDefaults
import androidx.tv.material3.Text
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.ui.OneTimeLaunchedEffect
@ -60,6 +61,7 @@ fun SeriesOverviewContent(
series: BaseItem,
seasons: List<BaseItem?>,
episodes: EpisodeList,
chosenStreams: ChosenStreams?,
position: SeriesOverviewPosition,
backdropImageUrl: String?,
firstItemFocusRequester: FocusRequester,
@ -281,6 +283,7 @@ fun SeriesOverviewContent(
focusedEpisode?.let { ep ->
FocusedEpisodeFooter(
ep = ep,
chosenStreams = chosenStreams,
playOnClick = playOnClick,
moreOnClick = moreOnClick,
watchOnClick = watchOnClick,

View file

@ -5,6 +5,7 @@ package com.github.damontecres.wholphin.ui.nav
import androidx.navigation3.runtime.NavKey
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.GetItemsFilter
import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisode
import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption
import com.github.damontecres.wholphin.util.UuidSerializer
@ -70,6 +71,7 @@ sealed class Destination(
@Transient val item: BaseItem? = null,
val startIndex: Int? = null,
val shuffle: Boolean = false,
val itemPlayback: ItemPlayback? = null,
) : Destination(true) {
override fun toString(): String = "Playback(itemId=$itemId, positionMs=$positionMs)"

View file

@ -56,6 +56,7 @@ import androidx.tv.material3.ListItem
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.TrackIndex
import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull
import com.github.damontecres.wholphin.ui.seekBack
@ -398,7 +399,7 @@ fun RightPlaybackButtons(
onSelectChoice = { index, _ ->
val send =
if (index == 0) {
-1
TrackIndex.DISABLED
} else {
subtitleStreams[index - 1].index
}

View file

@ -45,6 +45,7 @@ import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
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.Playlist
import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.cards.ChapterCard
@ -84,6 +85,7 @@ fun PlaybackOverlay(
playbackSpeed: Float,
moreButtonOptions: MoreButtonOptions,
currentPlayback: CurrentPlayback?,
currentItemPlayback: ItemPlayback,
audioStreams: List<AudioStream>,
currentSegment: MediaSegmentDto?,
modifier: Modifier = Modifier,
@ -156,6 +158,7 @@ fun PlaybackOverlay(
playbackSpeed = playbackSpeed,
moreButtonOptions = moreButtonOptions,
currentPlayback = currentPlayback,
currentItemPlayback = currentItemPlayback,
audioStreams = audioStreams,
subtitle = subtitle,
seekBarInteractionSource = seekBarInteractionSource,
@ -414,6 +417,7 @@ fun Controller(
playbackSpeed: Float,
moreButtonOptions: MoreButtonOptions,
currentPlayback: CurrentPlayback?,
currentItemPlayback: ItemPlayback,
audioStreams: List<AudioStream>,
nextState: OverlayViewState?,
currentSegment: MediaSegmentDto?,
@ -461,8 +465,8 @@ fun Controller(
seekEnabled = seekEnabled,
seekBarInteractionSource = seekBarInteractionSource,
moreButtonOptions = moreButtonOptions,
subtitleIndex = currentPlayback?.subtitleIndex,
audioIndex = currentPlayback?.audioIndex,
subtitleIndex = currentItemPlayback.subtitleIndex,
audioIndex = currentItemPlayback.audioIndex,
audioStreams = audioStreams,
playbackSpeed = playbackSpeed,
scale = scale,

View file

@ -56,6 +56,7 @@ import androidx.tv.material3.Button
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.data.model.Playlist
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.preferences.skipBackOnResume
@ -67,6 +68,7 @@ import com.github.damontecres.wholphin.util.seasonEpisode
import kotlinx.coroutines.delay
import org.jellyfin.sdk.model.api.DeviceProfile
import org.jellyfin.sdk.model.extensions.ticks
import java.util.UUID
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
@ -99,6 +101,12 @@ fun PlaybackPage(
val trickplay by viewModel.trickplay.observeAsState(null)
val chapters by viewModel.chapters.observeAsState(listOf())
val currentPlayback by viewModel.currentPlayback.observeAsState(null)
val currentItemPlayback by viewModel.currentItemPlayback.observeAsState(
ItemPlayback(
userId = -1,
itemId = UUID.randomUUID(),
),
)
val currentSegment by viewModel.currentSegment.observeAsState(null)
var segmentCancelled by remember(currentSegment?.id) { mutableStateOf(false) }
@ -318,6 +326,7 @@ fun PlaybackPage(
playbackSpeed = playbackSpeed,
moreButtonOptions = MoreButtonOptions(mapOf()),
currentPlayback = currentPlayback,
currentItemPlayback = currentItemPlayback,
audioStreams = audioStreams,
trickplayInfo = trickplay,
trickplayUrlFor = viewModel::getTrickplayUrl,
@ -331,7 +340,7 @@ fun PlaybackPage(
}
// Subtitles
if (!controllerViewState.controlsVisible && skipIndicatorDuration == 0L && currentPlayback?.subtitleIndex != null) {
if (!controllerViewState.controlsVisible && skipIndicatorDuration == 0L && currentItemPlayback.subtitleIndexEnabled) {
AndroidView(
factory = { context ->
SubtitleView(context).apply {

View file

@ -15,16 +15,21 @@ import androidx.media3.common.TrackSelectionOverride
import androidx.media3.common.Tracks
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import com.github.damontecres.wholphin.data.ItemPlaybackDao
import com.github.damontecres.wholphin.data.ServerRepository
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.Playlist
import com.github.damontecres.wholphin.data.model.PlaylistCreator
import com.github.damontecres.wholphin.data.model.TrackIndex
import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.SkipSegmentBehavior
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.nav.NavigationManager
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.util.EqualityMutableLiveData
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.TrackActivityPlaybackListener
@ -88,7 +93,10 @@ class PlaybackViewModel
val api: ApiClient,
val playlistCreator: PlaylistCreator,
val navigationManager: NavigationManager,
) : ViewModel() {
val itemPlaybackDao: ItemPlaybackDao,
val serverRepository: ServerRepository,
) : ViewModel(),
Player.Listener {
val player: ExoPlayer =
ExoPlayer
.Builder(context)
@ -105,6 +113,7 @@ class PlaybackViewModel
val audioStreams = MutableLiveData<List<AudioStream>>(listOf())
val subtitleStreams = MutableLiveData<List<SubtitleStream>>(listOf())
val currentPlayback = MutableLiveData<CurrentPlayback?>(null)
val currentItemPlayback = MutableLiveData<ItemPlayback>()
val trickplay = MutableLiveData<TrickplayInfo?>(null)
val chapters = MutableLiveData<List<Chapter>>(listOf())
val currentSegment = EqualityMutableLiveData<MediaSegmentDto?>(null)
@ -121,7 +130,14 @@ class PlaybackViewModel
val playlist = MutableLiveData<Playlist>(Playlist(listOf()))
init {
addCloseable { this@PlaybackViewModel.activityListener?.release() }
player.addListener(this)
addCloseable { player.removeListener(this@PlaybackViewModel) }
addCloseable {
this@PlaybackViewModel.activityListener?.let {
it.release()
player.removeListener(it)
}
}
addCloseable { player.release() }
}
@ -156,7 +172,7 @@ class PlaybackViewModel
queriedItem
}
val played = play(base, destination.positionMs)
val played = play(base, destination.positionMs, destination.itemPlayback)
if (!played) {
playUpNextUp()
}
@ -175,6 +191,7 @@ class PlaybackViewModel
private suspend fun play(
base: BaseItemDto,
positionMs: Long,
itemPlayback: ItemPlayback? = null,
): Boolean =
withContext(Dispatchers.IO) {
Timber.i("Playing ${base.id}")
@ -186,6 +203,38 @@ 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) {
@ -206,12 +255,28 @@ class PlaybackViewModel
withContext(Dispatchers.Main) {
this@PlaybackViewModel.title.value = title
this@PlaybackViewModel.subtitle.value = subtitle
this@PlaybackViewModel.currentItemPlayback.value = playbackConfig
}
base.mediaStreams
?.filter { it.type == MediaStreamType.VIDEO }
?.forEach { Timber.v("${it.videoRangeType}, ${it.videoRange}") }
val mediaSource =
if (playbackConfig.sourceId != null) {
base.mediaSources?.firstOrNull { it.id?.toUUIDOrNull() == playbackConfig.sourceId }
} else {
base.mediaSources?.firstOrNull()
}
if (mediaSource == null) {
showToast(
context,
"Item has no media sources, skipping...",
Toast.LENGTH_SHORT,
)
return@withContext false
}
// mediaSource.mediaStreams
// ?.filter { it.type == MediaStreamType.VIDEO }
// ?.forEach { Timber.v("${it.videoRangeType}, ${it.videoRange}") }
val subtitleStreams =
base.mediaStreams
mediaSource.mediaStreams
?.filter { it.type == MediaStreamType.SUBTITLE }
?.map {
SubtitleStream(
@ -226,7 +291,7 @@ class PlaybackViewModel
)
}.orEmpty()
val audioStreams =
base.mediaStreams
mediaSource.mediaStreams
?.filter { it.type == MediaStreamType.AUDIO }
?.map {
AudioStream(
@ -244,50 +309,23 @@ class PlaybackViewModel
// TODO audio selection based on channel layout or preferences or default
val audioLanguage = preferences.userConfig.audioLanguagePreference
val audioIndex =
if (audioLanguage != null) {
if (playbackConfig.audioIndexEnabled) {
playbackConfig.audioIndex
} else if (audioLanguage != null) {
audioStreams.firstOrNull { it.language == audioLanguage }?.index
?: audioStreams.firstOrNull()?.index
} else {
audioStreams.firstOrNull()?.index
}
val subtitleMode = preferences.userConfig.subtitleMode
val subtitleLanguage = preferences.userConfig.subtitleLanguagePreference
val subtitleIndex =
when (subtitleMode) {
SubtitlePlaybackMode.ALWAYS -> {
if (subtitleLanguage != null) {
subtitleStreams.firstOrNull { it.language == subtitleLanguage }?.index
} else {
subtitleStreams.firstOrNull()?.index
}
}
SubtitlePlaybackMode.ONLY_FORCED ->
if (subtitleLanguage != null) {
subtitleStreams.firstOrNull { it.language == subtitleLanguage && it.forced }?.index
} else {
subtitleStreams.firstOrNull { it.forced }?.index
}
SubtitlePlaybackMode.SMART -> {
if (audioLanguage != null && subtitleLanguage != null && audioLanguage != subtitleLanguage) {
subtitleStreams.firstOrNull { it.language == subtitleLanguage }?.index
} else {
null
}
}
SubtitlePlaybackMode.DEFAULT -> {
// TODO check for language?
(
subtitleStreams.firstOrNull { it.default && it.forced }
?: subtitleStreams.firstOrNull { it.default }
?: subtitleStreams.firstOrNull { it.forced }
)?.index
}
SubtitlePlaybackMode.NONE -> null
}
determineSubtitleIndex(
subtitleStreams = subtitleStreams,
subtitleMode = preferences.userConfig.subtitleMode,
subtitleLanguage = preferences.userConfig.subtitleLanguagePreference,
audioLanguage = audioLanguage,
playbackConfig = playbackConfig,
)
// Timber.v("base.mediaStreams=${base.mediaStreams}")
// Timber.v("subtitleTracks=$subtitleStreams")
@ -300,9 +338,11 @@ class PlaybackViewModel
changeStreams(
base,
playbackConfig,
audioIndex,
subtitleIndex,
if (positionMs > 0) positionMs else C.TIME_UNSET,
itemPlayback != null, // If it was passed in, then it was not queried from the database
)
player.prepare()
@ -316,20 +356,26 @@ class PlaybackViewModel
@OptIn(UnstableApi::class)
private suspend fun changeStreams(
item: BaseItemDto,
currentItemPlayback: ItemPlayback = this@PlaybackViewModel.currentItemPlayback.value!!,
audioIndex: Int?,
subtitleIndex: Int?,
positionMs: Long = C.TIME_UNSET,
) {
userInitiated: Boolean,
) = withContext(Dispatchers.IO) {
val itemId = item.id
if (currentPlayback.value?.let {
it.itemId == itemId &&
it.audioIndex == audioIndex &&
it.subtitleIndex == subtitleIndex
} == true
) {
Timber.i("No change in playback for changeStreams")
return
}
// TODO
// if (currentItemPlayback.let {
// it.itemId == itemId &&
// it.audioIndex == audioIndex &&
// it.subtitleIndex == subtitleIndex
// } == true
// ) {
// Timber.i("No change in playback for changeStreams")
// return@withContext
// }
Timber.d("changeStreams: userInitiated=$userInitiated, audioIndex=$audioIndex, subtitleIndex=$subtitleIndex")
// TODO if the new audio or subtitle index is already in the streams (eg direct play), should toggle in the player instead
val maxBitrate =
preferences.appPreferences.playbackPreferences.maxBitrate
@ -348,8 +394,8 @@ class PlaybackViewModel
subtitleStreamIndex = subtitleIndex,
allowVideoStreamCopy = true,
allowAudioStreamCopy = true,
autoOpenLiveStream = true,
mediaSourceId = null,
autoOpenLiveStream = false,
mediaSourceId = currentItemPlayback.sourceId?.toServerString(),
alwaysBurnInSubtitleWhenTranscoding = null,
maxStreamingBitrate = maxBitrate.toInt(),
),
@ -409,14 +455,26 @@ class PlaybackViewModel
val playback =
CurrentPlayback(
itemId,
audioIndex,
subtitleIndex,
source.id?.toUUIDOrNull(),
listOf(),
playMethod = transcodeType,
playSessionId = response.playSessionId,
)
val itemPlayback =
currentItemPlayback.copy(
sourceId = source.id?.toUUIDOrNull(),
audioIndex = audioIndex ?: TrackIndex.UNSPECIFIED,
subtitleIndex = subtitleIndex ?: TrackIndex.DISABLED,
)
if (userInitiated) {
viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) {
Timber.v("Saving user initiated item playback: %s", itemPlayback)
val rowId = itemPlaybackDao.saveItem(itemPlayback)
withContext(Dispatchers.Main) {
this@PlaybackViewModel.currentItemPlayback.value =
itemPlayback.copy(rowId = rowId)
}
}
}
withContext(Dispatchers.Main) {
// TODO, don't need to release & recreate when switching streams
@ -429,6 +487,7 @@ class PlaybackViewModel
api = api,
player = player,
playback = playback,
itemPlayback = itemPlayback,
)
player.addListener(activityListener)
this@PlaybackViewModel.activityListener = activityListener
@ -436,6 +495,7 @@ class PlaybackViewModel
duration.value = source.runTimeTicks?.ticks
stream.value = decision
currentPlayback.value = playback
this@PlaybackViewModel.currentItemPlayback.value = itemPlayback
player.setMediaItem(
mediaItem,
positionMs,
@ -452,10 +512,6 @@ class PlaybackViewModel
audioIndex,
subtitleIndex,
)
currentPlayback.value =
currentPlayback.value?.copy(
tracks = checkForSupport(tracks),
)
player.removeListener(this)
}
}
@ -476,32 +532,34 @@ class PlaybackViewModel
}
fun changeAudioStream(index: Int) {
val itemId = currentPlayback.value?.itemId ?: return
viewModelScope.launch(ExceptionHandler()) {
changeStreams(
dto,
currentItemPlayback.value!!,
index,
currentPlayback.value?.subtitleIndex,
currentItemPlayback.value?.subtitleIndex,
player.currentPosition,
true,
)
}
}
fun changeSubtitleStream(index: Int?) {
val itemId = currentPlayback.value?.itemId ?: return
viewModelScope.launch(ExceptionHandler()) {
changeStreams(
dto,
currentPlayback.value?.audioIndex,
currentItemPlayback.value!!,
currentItemPlayback.value?.audioIndex,
index,
player.currentPosition,
true,
)
}
}
fun getTrickplayUrl(index: Int): String? {
val itemId = dto.id
val mediaSourceId = currentPlayback.value?.mediaSourceId
val mediaSourceId = currentItemPlayback.value?.sourceId
val trickPlayInfo = trickplay.value ?: return null
return api.trickplayApi.getTrickplayTileImageUrl(
itemId,
@ -643,13 +701,16 @@ class PlaybackViewModel
}
}
}
override fun onTracksChanged(tracks: Tracks) {
currentPlayback.value =
currentPlayback.value?.copy(
tracks = checkForSupport(tracks),
)
}
}
data class CurrentPlayback(
val itemId: UUID,
val audioIndex: Int?,
val subtitleIndex: Int?,
val mediaSourceId: UUID?,
val tracks: List<TrackSupport>,
val playMethod: PlayMethod,
val playSessionId: String?,
@ -728,3 +789,53 @@ private fun applyTrackSelections(
}
}
}
private fun determineSubtitleIndex(
subtitleStreams: List<SubtitleStream>,
subtitleMode: SubtitlePlaybackMode,
audioLanguage: String?,
subtitleLanguage: String?,
playbackConfig: ItemPlayback,
): Int? {
if (playbackConfig.subtitleIndex == TrackIndex.DISABLED) {
return null
} else if (playbackConfig.subtitleIndexEnabled) {
return playbackConfig.subtitleIndex
} else {
return when (subtitleMode) {
SubtitlePlaybackMode.ALWAYS -> {
if (subtitleLanguage != null) {
subtitleStreams.firstOrNull { it.language == subtitleLanguage }?.index
} else {
subtitleStreams.firstOrNull()?.index
}
}
SubtitlePlaybackMode.ONLY_FORCED ->
if (subtitleLanguage != null) {
subtitleStreams.firstOrNull { it.language == subtitleLanguage && it.forced }?.index
} else {
subtitleStreams.firstOrNull { it.forced }?.index
}
SubtitlePlaybackMode.SMART -> {
if (audioLanguage != null && subtitleLanguage != null && audioLanguage != subtitleLanguage) {
subtitleStreams.firstOrNull { it.language == subtitleLanguage }?.index
} else {
null
}
}
SubtitlePlaybackMode.DEFAULT -> {
// TODO check for language?
(
subtitleStreams.firstOrNull { it.default && it.forced }
?: subtitleStreams.firstOrNull { it.default }
?: subtitleStreams.firstOrNull { it.forced }
)?.index
}
SubtitlePlaybackMode.NONE -> null
}
}
}

View file

@ -28,6 +28,7 @@ import com.github.damontecres.wholphin.ui.components.CircularProgress
import com.github.damontecres.wholphin.ui.components.DialogItem
import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import java.util.UUID
sealed interface ServerConnectionStatus {
object Success : ServerConnectionStatus
@ -45,7 +46,7 @@ sealed interface ServerConnectionStatus {
@Composable
fun ServerList(
servers: List<JellyfinServer>,
connectionStatus: Map<String, ServerConnectionStatus>,
connectionStatus: Map<UUID, ServerConnectionStatus>,
onSwitchServer: (JellyfinServer) -> Unit,
onAddServer: () -> Unit,
onRemoveServer: (JellyfinServer) -> Unit,

View file

@ -25,7 +25,10 @@ import org.jellyfin.sdk.api.client.extensions.systemApi
import org.jellyfin.sdk.api.client.extensions.userApi
import org.jellyfin.sdk.model.api.QuickConnectDto
import org.jellyfin.sdk.model.api.QuickConnectResult
import org.jellyfin.sdk.model.serializer.toUUID
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber
import java.util.UUID
import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds
@ -39,8 +42,8 @@ class SwitchUserViewModel
val navigationManager: NavigationManager,
) : ViewModel() {
val servers = MutableLiveData<List<JellyfinServer>>(listOf())
val serverStatus = MutableLiveData<Map<String, ServerConnectionStatus>>(mapOf())
val serverQuickConnect = MutableLiveData<Map<String, Boolean>>(mapOf())
val serverStatus = MutableLiveData<Map<UUID, ServerConnectionStatus>>(mapOf())
val serverQuickConnect = MutableLiveData<Map<UUID, Boolean>>(mapOf())
val users = MutableLiveData<List<JellyfinUser>>(listOf())
val quickConnectState = MutableLiveData<QuickConnectResult?>(null)
@ -245,7 +248,7 @@ class SwitchUserViewModel
.createApi(serverUrl)
.systemApi
.getPublicSystemInfo()
val id = serverInfo.id
val id = serverInfo.id?.toUUIDOrNull()
if (id != null && serverInfo.startupWizardCompleted == true) {
serverRepository.addAndChangeServer(
JellyfinServer(
@ -310,7 +313,15 @@ class SwitchUserViewModel
val newServerList =
discoveredServers.value!!
.toMutableList()
.apply { add(JellyfinServer(server.id, server.name, server.address)) }
.apply {
add(
JellyfinServer(
server.id.toUUID(),
server.name,
server.address,
),
)
}
withContext(Dispatchers.Main) {
discoveredServers.value = newServerList
}

View file

@ -46,7 +46,7 @@ fun UserList(
ListItem(
enabled = true,
selected = user == currentUser,
headlineContent = { Text(text = user.name ?: user.id) },
headlineContent = { Text(text = user.name ?: user.id.toString()) },
leadingContent = {
if (user.id == currentUser?.id) {
Icon(
@ -99,7 +99,7 @@ fun UserList(
showDeleteDialog?.let { user ->
DialogPopup(
showDialog = true,
title = user.name ?: user.id,
title = user.name ?: user.id.toString(),
dialogItems =
listOf(
DialogItem("Switch", R.string.fa_arrow_left_arrow_right) {