Simplify PlaybackViewModel (#252)

`PlaybackViewModel` was becoming very unwieldy at over 1,300 LOC. This
PR breaks it up and simplifies some fields so it's easier to work with.

There are no user facing changes
This commit is contained in:
damontecres 2025-11-18 19:18:38 -05:00 committed by GitHub
parent 1fd497d18c
commit 4517dcd0cf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 643 additions and 600 deletions

View file

@ -1,8 +1,10 @@
package com.github.damontecres.wholphin.data.model
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
import com.github.damontecres.wholphin.ui.formatDateTime
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.seasonEpisode
import com.github.damontecres.wholphin.ui.seasonEpisodePadded
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import org.jellyfin.sdk.api.client.ApiClient
@ -32,6 +34,18 @@ data class BaseItem(
get() =
if (type == BaseItemKind.EPISODE) data.seasonEpisode + " - " + name else data.productionYear?.toString()
val subtitleLong: String? by lazy {
if (type == BaseItemKind.EPISODE) {
buildList {
add(data.seasonEpisodePadded)
add(data.name)
add(data.premiereDate?.let { formatDateTime(it) })
}.filterNotNull().joinToString(" - ")
} else {
data.productionYear?.toString()
}
}
@Transient
val indexNumber = data.indexNumber ?: dateAsIndex()

View file

@ -38,6 +38,9 @@ class PlaylistCreator
private val api: ApiClient,
private val serverRepository: ServerRepository,
) {
/**
* Creates a playlist of next up episodes for the given series starting with the given episode
*/
suspend fun createFromEpisode(
seriesId: UUID,
episodeId: UUID,
@ -47,13 +50,13 @@ class PlaylistCreator
seriesId = seriesId,
fields = DefaultItemFields,
startItemId = episodeId,
limit = Playlist.Companion.MAX_SIZE,
limit = Playlist.MAX_SIZE,
)
val episodes = GetEpisodesRequestHandler.execute(api, request).content.items
val startIndex =
episodes.indexOfFirstOrNull { it.id == episodeId }
?: throw IllegalStateException("Episode $episodeId was not returned")
return Playlist(episodes.map { BaseItem.Companion.from(it, api) }, startIndex)
return Playlist(episodes.map { BaseItem.from(it, api) }, startIndex)
}
suspend fun createFromPlaylistId(
@ -66,10 +69,10 @@ class PlaylistCreator
playlistId = playlistId,
fields = DefaultItemFields,
startIndex = startIndex,
limit = Playlist.Companion.MAX_SIZE,
limit = Playlist.MAX_SIZE,
)
val items = GetPlaylistItemsRequestHandler.execute(api, request).content.items
var baseItems = items.map { BaseItem.Companion.from(it, api) }
var baseItems = items.map { BaseItem.from(it, api) }
if (shuffled) {
baseItems = baseItems.shuffled()
}

View file

@ -390,3 +390,5 @@ fun logTab(
Timber.i("Current tab: $info")
ACRA.errorReporter.putCustomData("tabInfo", info)
}
suspend fun <T> onMain(block: suspend CoroutineScope.() -> T) = withContext(Dispatchers.Main, block)

View file

@ -0,0 +1,15 @@
package com.github.damontecres.wholphin.ui.playback
import com.github.damontecres.wholphin.data.model.Chapter
import org.jellyfin.sdk.model.api.TrickplayInfo
data class CurrentMediaInfo(
val audioStreams: List<AudioStream>,
val subtitleStreams: List<SubtitleStream>,
val chapters: List<Chapter>,
val trickPlayInfo: TrickplayInfo?,
) {
companion object {
val EMPTY = CurrentMediaInfo(listOf(), listOf(), listOf(), null)
}
}

View file

@ -0,0 +1,17 @@
package com.github.damontecres.wholphin.ui.playback
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.PlayerBackend
import com.github.damontecres.wholphin.util.TrackSupport
import org.jellyfin.sdk.model.api.MediaSourceInfo
import org.jellyfin.sdk.model.api.PlayMethod
data class CurrentPlayback(
val item: BaseItem,
val tracks: List<TrackSupport>,
val backend: PlayerBackend,
val playMethod: PlayMethod,
val playSessionId: String?,
val liveStreamId: String?,
val mediaSourceInfo: MediaSourceInfo,
)

View file

@ -2,6 +2,7 @@ package com.github.damontecres.wholphin.ui.playback
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.WholphinApplication
import org.jellyfin.sdk.model.api.MediaStream
data class SubtitleStream(
val index: Int,
@ -22,6 +23,21 @@ data class SubtitleStream(
codec,
).joinToString(" - ")
.ifBlank { WholphinApplication.instance.getString(R.string.unknown) }
companion object {
fun from(it: MediaStream): SubtitleStream =
SubtitleStream(
it.index,
it.language,
it.title,
it.codec,
it.codecTag,
it.isExternal,
it.isForced,
it.isDefault,
it.displayTitle,
)
}
}
data class AudioStream(
@ -41,4 +57,17 @@ data class AudioStream(
codec,
channelLayout?.ifBlank { null } ?: channels?.let { "$it ch" },
).joinToString(" - ").ifBlank { "Unknown" }
companion object {
fun from(it: MediaStream): AudioStream =
AudioStream(
it.index,
it.language,
it.title,
it.codec,
it.codecTag,
it.channels,
it.channelLayout,
)
}
}

View file

@ -80,7 +80,6 @@ private val subtitleTextSize = 18.sp
@Composable
fun PlaybackOverlay(
item: BaseItem?,
title: String?,
subtitleStreams: List<SubtitleStream>,
chapters: List<Chapter>,
playerControls: Player,
@ -104,7 +103,6 @@ fun PlaybackOverlay(
audioStreams: List<AudioStream>,
currentSegment: MediaSegmentDto?,
modifier: Modifier = Modifier,
subtitle: String? = null,
trickplayInfo: TrickplayInfo? = null,
trickplayUrlFor: (Int) -> String? = { null },
playlist: Playlist = Playlist(listOf(), 0),
@ -123,11 +121,11 @@ fun PlaybackOverlay(
val titleHeight =
remember {
if (title.isNotNullOrBlank()) with(density) { titleTextSize.toDp() } else 0.dp
if (item?.title.isNotNullOrBlank()) with(density) { titleTextSize.toDp() } else 0.dp
}
val subtitleHeight =
remember {
if (subtitle.isNotNullOrBlank()) with(density) { subtitleTextSize.toDp() } else 0.dp
if (item?.subtitleLong.isNotNullOrBlank()) with(density) { subtitleTextSize.toDp() } else 0.dp
}
// This will be calculated after composition
@ -152,7 +150,8 @@ fun PlaybackOverlay(
null
}
Controller(
title = title,
title = item?.title,
subtitle = item?.subtitleLong,
subtitleStreams = subtitleStreams,
playerControls = playerControls,
controllerViewState = controllerViewState,
@ -176,7 +175,6 @@ fun PlaybackOverlay(
currentPlayback = currentPlayback,
currentItemPlayback = currentItemPlayback,
audioStreams = audioStreams,
subtitle = subtitle,
seekBarInteractionSource = seekBarInteractionSource,
nextState = nextState,
onNextStateFocus = {

View file

@ -22,7 +22,6 @@ import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
@ -49,9 +48,6 @@ import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.LifecycleStartEffect
import androidx.media3.common.Player
import androidx.media3.common.text.Cue
import androidx.media3.common.text.CueGroup
import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.SubtitleView
import androidx.media3.ui.compose.PlayerSurface
@ -124,13 +120,8 @@ fun PlaybackPage(
val density = LocalDensity.current
val player = viewModel.player
val title by viewModel.title.observeAsState(null)
val subtitle by viewModel.subtitle.observeAsState(null)
val duration by viewModel.duration.observeAsState(null)
val audioStreams by viewModel.audioStreams.observeAsState(listOf())
val subtitleStreams by viewModel.subtitleStreams.observeAsState(listOf())
val trickplay by viewModel.trickplay.observeAsState(null)
val chapters by viewModel.chapters.observeAsState(listOf())
val mediaInfo by viewModel.currentMediaInfo.observeAsState()
val currentPlayback by viewModel.currentPlayback.observeAsState(null)
val currentItemPlayback by viewModel.currentItemPlayback.observeAsState(
ItemPlayback(
@ -141,7 +132,7 @@ fun PlaybackPage(
val currentSegment by viewModel.currentSegment.observeAsState(null)
var segmentCancelled by remember(currentSegment?.id) { mutableStateOf(false) }
var cues by remember { mutableStateOf<List<Cue>>(listOf()) }
val cues by viewModel.subtitleCues.observeAsState(listOf())
var showDebugInfo by remember { mutableStateOf(prefs.showDebugInfo) }
val nextUp by viewModel.nextUp.observeAsState(null)
@ -150,18 +141,7 @@ fun PlaybackPage(
val subtitleSearch by viewModel.subtitleSearch.observeAsState(null)
val subtitleSearchLanguage by viewModel.subtitleSearchLanguage.observeAsState(Locale.current.language)
// TODO move to viewmodel?
val cueListener =
remember {
object : Player.Listener {
override fun onCues(cueGroup: CueGroup) {
cues = cueGroup.cues
}
}
}
OneTimeLaunchedEffect {
player.addListener(cueListener)
if (prefs.playerBackend == PlayerBackend.MPV) {
scope.launch(Dispatchers.Main + ExceptionHandler()) {
preferences.appPreferences.interfacePreferences.subtitlesPreferences.applyToMpv(
@ -170,9 +150,6 @@ fun PlaybackPage(
}
}
}
DisposableEffect(Unit) {
onDispose { player.removeListener(cueListener) }
}
AmbientPlayerListener(player)
var contentScale by remember { mutableStateOf(prefs.globalContentScale.scale) }
var playbackSpeed by remember { mutableFloatStateOf(1.0f) }
@ -280,8 +257,7 @@ fun PlaybackPage(
// Show a small progress bar along the bottom of the screen
val showSkipProgress = true // TODO get from preferences
if (showSkipProgress) {
duration?.let {
val percent = (skipPosition.milliseconds / it).toFloat()
val percent = skipPosition.toFloat() / player.duration.toFloat()
Box(
modifier =
Modifier
@ -290,10 +266,7 @@ fun PlaybackPage(
.clip(RectangleShape)
.height(3.dp)
.fillMaxWidth(percent),
) {
// No-op
}
}
)
}
}
@ -337,9 +310,6 @@ fun PlaybackPage(
.fillMaxSize()
.background(Color.Transparent),
item = currentPlayback?.item,
title = title,
subtitle = subtitle,
subtitleStreams = subtitleStreams,
playerControls = player,
controllerViewState = controllerViewState,
showPlay = playPauseState.showPlay,
@ -400,10 +370,11 @@ fun PlaybackPage(
moreButtonOptions = MoreButtonOptions(mapOf()),
currentPlayback = currentPlayback,
currentItemPlayback = currentItemPlayback,
audioStreams = audioStreams,
trickplayInfo = trickplay,
audioStreams = mediaInfo?.audioStreams ?: listOf(),
subtitleStreams = mediaInfo?.subtitleStreams ?: listOf(),
chapters = mediaInfo?.chapters ?: listOf(),
trickplayInfo = mediaInfo?.trickPlayInfo,
trickplayUrlFor = viewModel::getTrickplayUrl,
chapters = chapters,
playlist = playlist,
onClickPlaylist = {
viewModel.playItemInPlaylist(it)

View file

@ -5,19 +5,17 @@ import android.widget.Toast
import androidx.annotation.OptIn
import androidx.compose.ui.text.intl.Locale
import androidx.core.net.toUri
import androidx.datastore.core.DataStore
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.media3.common.C
import androidx.media3.common.Format
import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.common.TrackSelectionOverride
import androidx.media3.common.Tracks
import androidx.media3.common.text.Cue
import androidx.media3.common.text.CueGroup
import androidx.media3.common.util.UnstableApi
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.ItemPlaybackDao
import com.github.damontecres.wholphin.data.ItemPlaybackRepository
import com.github.damontecres.wholphin.data.ServerRepository
@ -29,7 +27,6 @@ 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.AppPreferences
import com.github.damontecres.wholphin.preferences.PlayerBackend
import com.github.damontecres.wholphin.preferences.ShowNextUpWhen
import com.github.damontecres.wholphin.preferences.SkipSegmentBehavior
@ -38,10 +35,9 @@ import com.github.damontecres.wholphin.services.DatePlayedService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.PlayerFactory
import com.github.damontecres.wholphin.services.PlaylistCreator
import com.github.damontecres.wholphin.ui.formatDateTime
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.seasonEpisodePadded
import com.github.damontecres.wholphin.ui.onMain
import com.github.damontecres.wholphin.ui.seekBack
import com.github.damontecres.wholphin.ui.seekForward
import com.github.damontecres.wholphin.ui.setValueOnMain
@ -52,14 +48,12 @@ import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.TrackActivityPlaybackListener
import com.github.damontecres.wholphin.util.TrackSupport
import com.github.damontecres.wholphin.util.checkForSupport
import com.github.damontecres.wholphin.util.mpv.mpvDeviceProfile
import com.github.damontecres.wholphin.util.subtitleMimeTypes
import com.github.damontecres.wholphin.util.supportItemKinds
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
@ -67,11 +61,12 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.mediaInfoApi
import org.jellyfin.sdk.api.client.extensions.mediaSegmentsApi
import org.jellyfin.sdk.api.client.extensions.subtitleApi
import org.jellyfin.sdk.api.client.extensions.trickplayApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.api.client.extensions.videosApi
@ -80,15 +75,11 @@ import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.DeviceProfile
import org.jellyfin.sdk.model.api.MediaSegmentDto
import org.jellyfin.sdk.model.api.MediaSegmentType
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.PlayMethod
import org.jellyfin.sdk.model.api.PlaybackInfoDto
import org.jellyfin.sdk.model.api.PlaystateCommand
import org.jellyfin.sdk.model.api.PlaystateMessage
import org.jellyfin.sdk.model.api.RemoteSubtitleInfo
import org.jellyfin.sdk.model.api.TrickplayInfo
import org.jellyfin.sdk.model.extensions.inWholeTicks
import org.jellyfin.sdk.model.extensions.ticks
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
@ -96,28 +87,23 @@ import timber.log.Timber
import java.util.Date
import java.util.UUID
import javax.inject.Inject
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
data class StreamDecision(
val itemId: UUID,
val type: PlayMethod,
val url: String,
)
/**
* This [ViewModel] is responsible for playing media including moving through playlists (including next up episodes)
*/
@HiltViewModel
@OptIn(markerClass = [UnstableApi::class])
class PlaybackViewModel
@Inject
constructor(
@param:ApplicationContext val context: Context,
val api: ApiClient,
val playlistCreator: PlaylistCreator,
@param:ApplicationContext internal val context: Context,
internal val api: ApiClient,
val navigationManager: NavigationManager,
val itemPlaybackDao: ItemPlaybackDao,
val serverRepository: ServerRepository,
val itemPlaybackRepository: ItemPlaybackRepository,
val appPreferences: DataStore<AppPreferences>,
private val playlistCreator: PlaylistCreator,
private val itemPlaybackDao: ItemPlaybackDao,
private val serverRepository: ServerRepository,
private val itemPlaybackRepository: ItemPlaybackRepository,
private val playerFactory: PlayerFactory,
private val datePlayedService: DatePlayedService,
) : ViewModel(),
@ -125,31 +111,30 @@ class PlaybackViewModel
val player by lazy {
playerFactory.createVideoPlayer()
}
internal val mutex = Mutex()
val loading = MutableLiveData<LoadingState>(LoadingState.Loading)
val title = MutableLiveData<String?>(null)
val subtitle = MutableLiveData<String?>(null)
val duration = MutableLiveData<Duration?>(null)
val audioStreams = MutableLiveData<List<AudioStream>>(listOf())
val subtitleStreams = MutableLiveData<List<SubtitleStream>>(listOf())
val currentMediaInfo = MutableLiveData<CurrentMediaInfo>(CurrentMediaInfo.EMPTY)
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)
private val autoSkippedSegments = mutableSetOf<UUID>()
val subtitleCues = MutableLiveData<List<Cue>>(listOf())
private lateinit var preferences: UserPreferences
private lateinit var deviceProfile: DeviceProfile
private lateinit var itemId: UUID
private lateinit var item: BaseItem
internal lateinit var itemId: UUID
internal lateinit var item: BaseItem
private var activityListener: TrackActivityPlaybackListener? = null
val nextUp = MutableLiveData<BaseItem?>()
private var isPlaylist = false
val playlist = MutableLiveData<Playlist>(Playlist(listOf()))
val subtitleSearch = MutableLiveData<SubtitleSearch?>(null)
val subtitleSearchLanguage = MutableLiveData<String>(Locale.current.language)
init {
player.addListener(this)
@ -164,6 +149,9 @@ class PlaybackViewModel
subscribe()
}
/**
* Initialize from the UI to start playback
*/
fun init(
destination: Destination.Playback,
deviceProfile: DeviceProfile,
@ -225,10 +213,17 @@ class PlaybackViewModel
this@PlaybackViewModel.playlist.value = playlist
}
}
maybeSetupPlaylistListener()
}
}
/**
* Play an item
*
* @param item the item to play
* @param positionMs the starting playback position in milliseconds
* @param itemPlayback the parameters for playback such chosen subtitle or audio streams
* @param forceTranscoding whether the user has requested to force playback via transcoding
*/
private suspend fun play(
item: BaseItem,
positionMs: Long,
@ -237,8 +232,13 @@ class PlaybackViewModel
): Boolean =
withContext(Dispatchers.IO) {
Timber.i("Playing ${item.id}")
// Starting playback, so want to invalidate the last played timestamp for this item
datePlayedService.invalidate(item)
// New item, so we can clear the media segment tracker & subtitle cues
autoSkippedSegments.clear()
this@PlaybackViewModel.subtitleCues.setValueOnMain(listOf())
if (item.type !in supportItemKinds) {
showToast(
context,
@ -252,32 +252,11 @@ class PlaybackViewModel
val isLiveTv = item.type == BaseItemKind.TV_CHANNEL
val base = item.data
val title =
if (base.type == BaseItemKind.EPISODE) {
base.seriesName
} else {
base.name
}
val subtitle =
if (base.type == BaseItemKind.EPISODE) {
buildList {
add(base.seasonEpisodePadded)
add(base.name)
add(base.premiereDate?.let { formatDateTime(it) })
}.filterNotNull().joinToString(" - ")
} else {
base.productionYear?.toString()
}
withContext(Dispatchers.Main) {
this@PlaybackViewModel.title.value = title
this@PlaybackViewModel.subtitle.value = subtitle
}
// Use the provided playback parameters or else check if the database has some
val playbackConfig =
if (itemPlayback != null) {
itemPlayback
} else {
serverRepository.currentUser.value?.let { user ->
?: serverRepository.currentUser.value?.let { user ->
itemPlaybackDao.getItem(user, base.id)?.let {
Timber.v("Fetched itemPlayback from DB: %s", it)
if (it.sourceId != null) {
@ -287,7 +266,6 @@ class PlaybackViewModel
}
}
}
}
val mediaSource = chooseSource(base, playbackConfig)
if (mediaSource == null) {
@ -299,39 +277,16 @@ class PlaybackViewModel
return@withContext false
}
// mediaSource.mediaStreams
// ?.filter { it.type == MediaStreamType.VIDEO }
// ?.forEach { Timber.v("${it.videoRangeType}, ${it.videoRange}") }
val subtitleStreams =
mediaSource.mediaStreams
?.filter { it.type == MediaStreamType.SUBTITLE }
?.map {
SubtitleStream(
it.index,
it.language,
it.title,
it.codec,
it.codecTag,
it.isExternal,
it.isForced,
it.isDefault,
it.displayTitle,
)
}.orEmpty()
?.map(SubtitleStream::from)
.orEmpty()
val audioStreams =
mediaSource.mediaStreams
?.filter { it.type == MediaStreamType.AUDIO }
?.map {
AudioStream(
it.index,
it.language,
it.title,
it.codec,
it.codecTag,
it.channels,
it.channelLayout,
)
}?.sortedWith(compareBy<AudioStream> { it.language }.thenByDescending { it.channels })
?.map(AudioStream::from)
?.sortedWith(compareBy<AudioStream> { it.language }.thenByDescending { it.channels })
.orEmpty()
val audioIndex =
@ -342,9 +297,6 @@ class PlaybackViewModel
chooseStream(base, playbackConfig, MediaStreamType.SUBTITLE, preferences)
?.index
// Timber.v("base.mediaStreams=${base.mediaStreams}")
// Timber.v("subtitleTracks=$subtitleStreams")
// Timber.v("audioStreams=$audioStreams")
Timber.d("Selected mediaSource=${mediaSource.id}, audioIndex=$audioIndex, subtitleIndex=$subtitleIndex")
val itemPlaybackToUse =
@ -356,11 +308,23 @@ class PlaybackViewModel
audioIndex = audioIndex ?: TrackIndex.UNSPECIFIED,
subtitleIndex = subtitleIndex ?: TrackIndex.UNSPECIFIED,
)
val trickPlayInfo =
item.data.trickplay
?.get(mediaSource.id)
?.values
?.firstOrNull()
val chapters = Chapter.fromDto(base, api)
withContext(Dispatchers.Main) {
this@PlaybackViewModel.currentItemPlayback.value = itemPlaybackToUse
this@PlaybackViewModel.audioStreams.value = audioStreams
this@PlaybackViewModel.subtitleStreams.value = subtitleStreams
updateCurrentMedia {
CurrentMediaInfo(
audioStreams = audioStreams,
subtitleStreams = subtitleStreams,
chapters = chapters,
trickPlayInfo = trickPlayInfo,
)
}
changeStreams(
item,
@ -374,16 +338,16 @@ class PlaybackViewModel
)
player.prepare()
player.play()
this@PlaybackViewModel.chapters.value = Chapter.fromDto(base, api)
Timber.v("chapters=${this@PlaybackViewModel.chapters.value?.size}")
}
listenForSegments()
return@withContext true
}
/**
* Change which streams (ie audio or subtitle) are active
*/
@OptIn(UnstableApi::class)
private suspend fun changeStreams(
internal suspend fun changeStreams(
item: BaseItem,
currentItemPlayback: ItemPlayback = this@PlaybackViewModel.currentItemPlayback.value!!,
audioIndex: Int?,
@ -409,7 +373,7 @@ class PlaybackViewModel
if (externalSubtitle == null) {
val result =
withContext(Dispatchers.Main) {
applyTrackSelections(
TrackSelectionUtils.applyTrackSelections(
player,
playerBackend,
true,
@ -519,8 +483,7 @@ class PlaybackViewModel
source.supportsTranscoding -> PlayMethod.TRANSCODE
else -> throw Exception("No supported playback method")
}
val decision = StreamDecision(itemId, transcodeType, mediaUrl)
Timber.v("Playback decision: $decision")
Timber.v("Playback decision: $transcodeType")
val externalSubtitleCount = source.externalSubtitlesCount
@ -595,7 +558,6 @@ class PlaybackViewModel
player.addListener(activityListener)
this@PlaybackViewModel.activityListener = activityListener
duration.value = source.runTimeTicks?.ticks
loading.value = LoadingState.Success
this@PlaybackViewModel.currentPlayback.value = playback
this@PlaybackViewModel.currentItemPlayback.value = itemPlayback
@ -610,7 +572,7 @@ class PlaybackViewModel
Timber.v("onTracksChanged: $tracks")
if (tracks.groups.isNotEmpty()) {
val result =
applyTrackSelections(
TrackSelectionUtils.applyTrackSelections(
player,
playerBackend,
source.supportsDirectPlay,
@ -627,15 +589,6 @@ class PlaybackViewModel
player.addListener(onTracksChangedListener)
}
}
val trickPlayInfo =
item.data.trickplay
?.get(source.id)
?.values
?.firstOrNull()
// Timber.v("Trickplay info: $trickPlayInfo")
withContext(Dispatchers.Main) {
trickplay.value = trickPlayInfo
}
}
}
@ -667,7 +620,7 @@ class PlaybackViewModel
fun getTrickplayUrl(index: Int): String? {
val itemId = item.id
val mediaSourceId = currentItemPlayback.value?.sourceId
val trickPlayInfo = trickplay.value ?: return null
val trickPlayInfo = currentMediaInfo.value?.trickPlayInfo ?: return null
return api.trickplayApi.getTrickplayTileImageUrl(
itemId,
trickPlayInfo.width,
@ -676,16 +629,10 @@ class PlaybackViewModel
)
}
private fun maybeSetupPlaylistListener() {
playlist.value?.let { playlist ->
if (playlist.hasNext()) {
Timber.v("Adding lister for playlist with ${playlist.items.size} items")
val listener =
object : Player.Listener {
override fun onPlaybackStateChanged(playbackState: Int) {
if (playbackState == Player.STATE_ENDED) {
viewModelScope.launchIO {
val nextItem = playlist.peek()
val nextItem = playlist.value?.peek()
Timber.v("Setting next up to ${nextItem?.id}")
withContext(Dispatchers.Main) {
nextUp.value = nextItem
@ -693,15 +640,12 @@ class PlaybackViewModel
}
}
}
}
player.addListener(listener)
addCloseable { player.removeListener(listener) }
}
}
}
private var segmentJob: Job? = null
/**
* This sets up a coroutine to periodically check whether the current playback progress is within a media segment (intro, outro, etc)
*/
private fun listenForSegments() {
segmentJob?.cancel()
segmentJob =
@ -776,6 +720,9 @@ class PlaybackViewModel
private var lastInteractionDate: Date = Date()
/**
* Tracks interactions with the UI for passout protection
*/
fun reportInteraction() {
// Timber.v("reportInteraction")
lastInteractionDate = Date()
@ -848,6 +795,10 @@ class PlaybackViewModel
)
}
override fun onCues(cueGroup: CueGroup) {
subtitleCues.value = cueGroup.cues
}
override fun onPlayerError(error: PlaybackException) {
Timber.e(error, "Playback error")
viewModelScope.launch(Dispatchers.Main + ExceptionHandler()) {
@ -893,6 +844,7 @@ class PlaybackViewModel
.subscribe<PlaystateMessage>()
.onEach { message ->
message.data?.let {
withContext(Dispatchers.Main) {
when (it.command) {
PlaystateCommand.STOP -> {
release()
@ -903,7 +855,13 @@ class PlaybackViewModel
PlaystateCommand.UNPAUSE -> player.play()
PlaystateCommand.NEXT_TRACK -> playNextUp()
PlaystateCommand.PREVIOUS_TRACK -> playPrevious()
PlaystateCommand.SEEK -> it.seekPositionTicks?.ticks?.let { player.seekTo(it.inWholeMilliseconds) }
PlaystateCommand.SEEK ->
it.seekPositionTicks?.ticks?.let {
player.seekTo(
it.inWholeMilliseconds,
)
}
PlaystateCommand.REWIND ->
player.seekBack(
preferences.appPreferences.playbackPreferences.skipBackMs.milliseconds,
@ -917,398 +875,18 @@ class PlaybackViewModel
PlaystateCommand.PLAY_PAUSE -> if (player.isPlaying) player.pause() else player.play()
}
}
}
}.launchIn(viewModelScope)
}
val subtitleSearch = MutableLiveData<SubtitleSearch?>(null)
val subtitleSearchLanguage = MutableLiveData<String>(Locale.current.language)
fun searchForSubtitles(language: String = Locale.current.language) {
subtitleSearch.value = SubtitleSearch.Searching
subtitleSearchLanguage.value = language
viewModelScope.launchIO {
try {
currentItemPlayback.value?.itemId?.let {
Timber.v("Searching for remote subtitles for %s", it)
val results =
api.subtitleApi
.searchRemoteSubtitles(
itemId = it,
language = language,
).content
.sortedWith(
compareByDescending<RemoteSubtitleInfo> { it.communityRating }
.thenByDescending { it.downloadCount },
)
subtitleSearch.setValueOnMain(SubtitleSearch.Success(results))
}
} catch (ex: Exception) {
Timber.e(ex, "Exception while searching for subtitles")
subtitleSearch.setValueOnMain(SubtitleSearch.Error(null, ex))
}
}
}
fun downloadAndSwitchSubtitles(
subtitleId: String?,
wasPlaying: Boolean,
) {
if (subtitleId == null) {
subtitleSearch.value = SubtitleSearch.Error("Subtitle has no ID", null)
} else {
subtitleSearch.value = SubtitleSearch.Downloading
viewModelScope.launchIO {
try {
currentItemPlayback.value?.let {
Timber.v(
"Downloading remote subtitles for itemId=%s, sourceId=%s: %s",
it.itemId,
it.sourceId,
subtitleId,
)
api.subtitleApi.downloadRemoteSubtitles(
itemId = it.sourceId ?: it.itemId,
subtitleId = subtitleId,
)
val currentSubtitleStreams =
this@PlaybackViewModel
.subtitleStreams.value
.orEmpty()
val subtitleCount = currentSubtitleStreams.size
var newCount = subtitleCount
var maxAttempts = 4
var newStreams: List<SubtitleStream> = listOf()
// The server triggers a refresh in the background, so query periodically for the item until its updated
while (maxAttempts > 0 && subtitleCount == newCount) {
maxAttempts--
delay(1500)
item =
BaseItem.from(
api.userLibraryApi.getItem(itemId = it.itemId).content,
api,
)
val mediaSource = chooseSource(item.data, it)
if (mediaSource == null) {
// This shouldn't happen, but just in case
showToast(
context,
"Item is no longer playable...",
Toast.LENGTH_SHORT,
)
return@launchIO
}
val subtitleStreams =
mediaSource.mediaStreams
?.filter { it.type == MediaStreamType.SUBTITLE }
.orEmpty()
newCount = subtitleStreams.size
if (subtitleCount != newCount) {
newStreams =
subtitleStreams.map {
SubtitleStream(
it.index,
it.language,
it.title,
it.codec,
it.codecTag,
it.isExternal,
it.isForced,
it.isDefault,
it.displayTitle,
)
}
this@PlaybackViewModel.subtitleStreams.setValueOnMain(newStreams)
}
}
if (maxAttempts == 0) {
showToast(
context,
context.getString(R.string.subtitle_download_too_long),
)
} else {
// Find the new subtitle stream
val newStream =
newStreams
.toMutableList()
.apply {
removeAll(currentSubtitleStreams)
}.firstOrNull { it.external }
if (newStream != null) {
var audioIndex = currentItemPlayback.value?.audioIndex
if (audioIndex != null && audioIndex != TrackIndex.UNSPECIFIED) {
// User has picked a specific audio track
// Since, now adding a new external subtitle track, need to adjust the audio index as well
Timber.v("New external subtitle, audioIndex=$audioIndex, adding 1")
audioIndex += 1
}
changeStreams(
item,
currentItemPlayback.value!!,
audioIndex,
newStream.index,
onMain { player.currentPosition },
true,
)
}
}
subtitleSearch.setValueOnMain(null)
withContext(Dispatchers.Main) {
if (wasPlaying) {
player.play()
}
}
}
} catch (ex: Exception) {
Timber.e(ex, "Exception while downloading subtitles: $subtitleId")
subtitleSearch.setValueOnMain(SubtitleSearch.Error(null, ex))
}
}
}
}
fun cancelSubtitleSearch() {
subtitleSearch.value = null
}
}
data class CurrentPlayback(
val item: BaseItem,
val tracks: List<TrackSupport>,
val backend: PlayerBackend,
val playMethod: PlayMethod,
val playSessionId: String?,
val liveStreamId: String?,
val mediaSourceInfo: MediaSourceInfo,
)
sealed interface SubtitleSearch {
data object Searching : SubtitleSearch
data object Downloading : SubtitleSearch
data class Success(
val options: List<RemoteSubtitleInfo>,
) : SubtitleSearch
data class Error(
val message: String?,
val ex: Exception?,
) : SubtitleSearch
}
val Format.idAsInt: Int?
@OptIn(UnstableApi::class)
get() =
id?.let {
if (it.contains(":")) {
it.split(":").last().toIntOrNull()
} else {
it.toIntOrNull()
}
}
/**
* Returns the number of external subtitle streams there are
/**
* Atomically update [currentMediaInfo]
*/
val MediaSourceInfo.externalSubtitlesCount: Int
get() =
mediaStreams
?.count { it.type == MediaStreamType.SUBTITLE && it.isExternal } ?: 0
/**
* Returns the number of embedded subtitle streams there are
*/
val MediaSourceInfo.embeddedSubtitleCount: Int
get() =
mediaStreams
?.count { it.type == MediaStreamType.SUBTITLE && !it.isExternal } ?: 0
/**
* Returns the number of video streams there are
*/
val MediaSourceInfo.videoStreamCount: Int
get() =
mediaStreams
?.count { it.type == MediaStreamType.VIDEO } ?: 0
/**
* Returns the number of audio streams there are
*/
val MediaSourceInfo.audioStreamCount: Int
get() =
mediaStreams
?.count { it.type == MediaStreamType.AUDIO } ?: 0
/**
* Returns the [MediaStream] for the given subtitle index iff it is external
*/
fun MediaSourceInfo.findExternalSubtitle(subtitleIndex: Int?): MediaStream? =
subtitleIndex?.let {
mediaStreams
?.firstOrNull { it.type == MediaStreamType.SUBTITLE && it.isExternal && it.index == subtitleIndex }
}
suspend fun <T> onMain(block: suspend CoroutineScope.() -> T) = withContext(Dispatchers.Main, block)
data class TrackSelectionResult(
val audioSelected: Boolean,
val subtitleSelected: Boolean,
) {
val bothSelected: Boolean = audioSelected && subtitleSelected
}
@OptIn(UnstableApi::class)
private fun applyTrackSelections(
player: Player,
playerBackend: PlayerBackend,
supportsDirectPlay: Boolean,
audioIndex: Int?,
subtitleIndex: Int?,
source: MediaSourceInfo,
): TrackSelectionResult {
val videoStreamCount = source.videoStreamCount
val audioStreamCount = source.audioStreamCount
val embeddedSubtitleCount = source.embeddedSubtitleCount
val externalSubtitleCount = source.externalSubtitlesCount
val paramsBuilder = player.trackSelectionParameters.buildUpon()
val tracks = player.currentTracks.groups
val subtitleSelected =
if (subtitleIndex != null && subtitleIndex >= 0) {
val subtitleIsExternal = source.findExternalSubtitle(subtitleIndex) != null
if (subtitleIsExternal || supportsDirectPlay) {
val chosenTrack =
if (subtitleIsExternal && playerBackend == PlayerBackend.EXO_PLAYER) {
tracks.firstOrNull { group ->
group.type == C.TRACK_TYPE_TEXT && group.isSupported &&
(0..<group.mediaTrackGroup.length)
.mapNotNull {
group.getTrackFormat(it).id
}.any { it.endsWith("e:$subtitleIndex") }
}
} else {
val indexToFind =
calculateIndexToFind(
subtitleIndex,
MediaStreamType.SUBTITLE,
playerBackend,
videoStreamCount,
audioStreamCount,
embeddedSubtitleCount,
externalSubtitleCount,
subtitleIsExternal,
)
Timber.v("Chosen subtitle ($subtitleIndex/$indexToFind) track")
// subtitleIndex - externalSubtitleCount + 1
tracks.firstOrNull { group ->
group.type == C.TRACK_TYPE_TEXT && group.isSupported &&
(0..<group.mediaTrackGroup.length)
.map {
group.getTrackFormat(it).idAsInt
}.contains(indexToFind)
}
}
Timber.v("Chosen subtitle ($subtitleIndex) track: $chosenTrack")
chosenTrack?.let {
paramsBuilder
.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false)
.setOverrideForType(
TrackSelectionOverride(
chosenTrack.mediaTrackGroup,
0,
),
)
}
chosenTrack != null
} else {
false
}
} else {
paramsBuilder
.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, true)
true
}
val audioSelected =
if (audioIndex != null && supportsDirectPlay) {
val indexToFind =
calculateIndexToFind(
audioIndex,
MediaStreamType.AUDIO,
playerBackend,
videoStreamCount,
audioStreamCount,
embeddedSubtitleCount,
externalSubtitleCount,
false,
)
val chosenTrack =
tracks.firstOrNull { group ->
group.type == C.TRACK_TYPE_AUDIO && group.isSupported &&
(0..<group.mediaTrackGroup.length)
.map {
group.getTrackFormat(it).idAsInt
}.contains(indexToFind)
}
Timber.v("Chosen audio ($audioIndex/$indexToFind) track: $chosenTrack")
chosenTrack?.let {
paramsBuilder
.setTrackTypeDisabled(C.TRACK_TYPE_AUDIO, false)
.setOverrideForType(
TrackSelectionOverride(
chosenTrack.mediaTrackGroup,
0,
),
)
}
chosenTrack != null
} else {
audioIndex == null
}
if (audioSelected && subtitleSelected) {
player.trackSelectionParameters = paramsBuilder.build()
}
return TrackSelectionResult(audioSelected, subtitleSelected)
}
/**
* Maps the server provided index to the track index based on the [PlayerBackend] and other stream information
*/
private fun calculateIndexToFind(
serverIndex: Int,
type: MediaStreamType,
playerBackend: PlayerBackend,
videoStreamCount: Int,
audioStreamCount: Int,
embeddedSubtitleCount: Int,
externalSubtitleCount: Int,
subtitleIsExternal: Boolean,
): Int =
when (playerBackend) {
PlayerBackend.EXO_PLAYER,
PlayerBackend.UNRECOGNIZED,
-> {
serverIndex - externalSubtitleCount + 1
}
// TODO MPV could use literal indexes because they are stored in the track format ID
PlayerBackend.MPV -> {
when (type) {
MediaStreamType.VIDEO -> serverIndex - externalSubtitleCount + 1
MediaStreamType.AUDIO -> serverIndex - externalSubtitleCount - videoStreamCount + 1
MediaStreamType.SUBTITLE -> {
if (subtitleIsExternal) {
serverIndex + embeddedSubtitleCount + 1
} else {
serverIndex - externalSubtitleCount - videoStreamCount - audioStreamCount + 1
}
}
else -> throw UnsupportedOperationException("Cannot calculate index for $type")
internal suspend fun updateCurrentMedia(block: (CurrentMediaInfo) -> CurrentMediaInfo) =
withContext(Dispatchers.IO) {
mutex.withLock {
val newMediaInfo = block.invoke(currentMediaInfo.value!!)
currentMediaInfo.setValueOnMain(newMediaInfo)
}
}
}

View file

@ -0,0 +1,189 @@
package com.github.damontecres.wholphin.ui.playback
import android.widget.Toast
import androidx.compose.ui.text.intl.Locale
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.TrackIndex
import com.github.damontecres.wholphin.data.model.chooseSource
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.onMain
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.showToast
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.extensions.subtitleApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.model.api.MediaStreamType
import org.jellyfin.sdk.model.api.RemoteSubtitleInfo
import timber.log.Timber
sealed interface SubtitleSearch {
data object Searching : SubtitleSearch
data object Downloading : SubtitleSearch
data class Success(
val options: List<RemoteSubtitleInfo>,
) : SubtitleSearch
data class Error(
val message: String?,
val ex: Exception?,
) : SubtitleSearch
}
fun PlaybackViewModel.searchForSubtitles(language: String = Locale.current.language) {
subtitleSearch.value = SubtitleSearch.Searching
subtitleSearchLanguage.value = language
viewModelScope.launchIO {
try {
currentItemPlayback.value?.itemId?.let {
Timber.v("Searching for remote subtitles for %s", it)
val results =
api.subtitleApi
.searchRemoteSubtitles(
itemId = it,
language = language,
).content
.sortedWith(
compareByDescending<RemoteSubtitleInfo> { it.communityRating }
.thenByDescending { it.downloadCount },
)
subtitleSearch.setValueOnMain(SubtitleSearch.Success(results))
}
} catch (ex: Exception) {
Timber.e(ex, "Exception while searching for subtitles")
subtitleSearch.setValueOnMain(SubtitleSearch.Error(null, ex))
}
}
}
fun PlaybackViewModel.downloadAndSwitchSubtitles(
subtitleId: String?,
wasPlaying: Boolean,
) {
if (subtitleId == null) {
subtitleSearch.value = SubtitleSearch.Error("Subtitle has no ID", null)
} else {
subtitleSearch.value = SubtitleSearch.Downloading
viewModelScope.launchIO {
try {
currentItemPlayback.value?.let {
Timber.v(
"Downloading remote subtitles for itemId=%s, sourceId=%s: %s",
it.itemId,
it.sourceId,
subtitleId,
)
api.subtitleApi.downloadRemoteSubtitles(
itemId = it.sourceId ?: it.itemId,
subtitleId = subtitleId,
)
val currentSubtitleStreams =
this@downloadAndSwitchSubtitles
.currentMediaInfo.value
?.subtitleStreams
.orEmpty()
val subtitleCount = currentSubtitleStreams.size
var newCount = subtitleCount
var maxAttempts = 4
var newStreams: List<SubtitleStream> = listOf()
// The server triggers a refresh in the background, so query periodically for the item until its updated
while (maxAttempts > 0 && subtitleCount == newCount) {
maxAttempts--
delay(1500)
item =
BaseItem.from(
api.userLibraryApi.getItem(itemId = it.itemId).content,
api,
)
val mediaSource = chooseSource(item.data, it)
if (mediaSource == null) {
// This shouldn't happen, but just in case
showToast(
context,
"Item is no longer playable...",
Toast.LENGTH_SHORT,
)
return@launchIO
}
val subtitleStreams =
mediaSource.mediaStreams
?.filter { it.type == MediaStreamType.SUBTITLE }
.orEmpty()
newCount = subtitleStreams.size
if (subtitleCount != newCount) {
newStreams =
subtitleStreams.map {
SubtitleStream(
it.index,
it.language,
it.title,
it.codec,
it.codecTag,
it.isExternal,
it.isForced,
it.isDefault,
it.displayTitle,
)
}
updateCurrentMedia {
it.copy(subtitleStreams = newStreams)
}
}
}
if (maxAttempts == 0) {
showToast(
context,
context.getString(R.string.subtitle_download_too_long),
)
} else {
// Find the new subtitle stream
val newStream =
newStreams
.toMutableList()
.apply {
removeAll(currentSubtitleStreams)
}.firstOrNull { it.external }
if (newStream != null) {
var audioIndex = currentItemPlayback.value?.audioIndex
if (audioIndex != null && audioIndex != TrackIndex.UNSPECIFIED) {
// User has picked a specific audio track
// Since, now adding a new external subtitle track, need to adjust the audio index as well
Timber.v("New external subtitle, audioIndex=$audioIndex, adding 1")
audioIndex += 1
}
this@downloadAndSwitchSubtitles.changeStreams(
item,
currentItemPlayback.value!!,
audioIndex,
newStream.index,
onMain { player.currentPosition },
true,
)
}
}
subtitleSearch.setValueOnMain(null)
withContext(Dispatchers.Main) {
if (wasPlaying) {
player.play()
}
}
}
} catch (ex: Exception) {
Timber.e(ex, "Exception while downloading subtitles: $subtitleId")
subtitleSearch.setValueOnMain(SubtitleSearch.Error(null, ex))
}
}
}
}
fun PlaybackViewModel.cancelSubtitleSearch() {
subtitleSearch.value = null
}

View file

@ -0,0 +1,227 @@
package com.github.damontecres.wholphin.ui.playback
import androidx.annotation.OptIn
import androidx.media3.common.C
import androidx.media3.common.Format
import androidx.media3.common.Player
import androidx.media3.common.TrackSelectionOverride
import androidx.media3.common.util.UnstableApi
import com.github.damontecres.wholphin.preferences.PlayerBackend
import org.jellyfin.sdk.model.api.MediaSourceInfo
import org.jellyfin.sdk.model.api.MediaStream
import org.jellyfin.sdk.model.api.MediaStreamType
import timber.log.Timber
object TrackSelectionUtils {
@OptIn(UnstableApi::class)
fun applyTrackSelections(
player: Player,
playerBackend: PlayerBackend,
supportsDirectPlay: Boolean,
audioIndex: Int?,
subtitleIndex: Int?,
source: MediaSourceInfo,
): TrackSelectionResult {
val videoStreamCount = source.videoStreamCount
val audioStreamCount = source.audioStreamCount
val embeddedSubtitleCount = source.embeddedSubtitleCount
val externalSubtitleCount = source.externalSubtitlesCount
val paramsBuilder = player.trackSelectionParameters.buildUpon()
val tracks = player.currentTracks.groups
val subtitleSelected =
if (subtitleIndex != null && subtitleIndex >= 0) {
val subtitleIsExternal = source.findExternalSubtitle(subtitleIndex) != null
if (subtitleIsExternal || supportsDirectPlay) {
val chosenTrack =
if (subtitleIsExternal && playerBackend == PlayerBackend.EXO_PLAYER) {
tracks.firstOrNull { group ->
group.type == C.TRACK_TYPE_TEXT && group.isSupported &&
(0..<group.mediaTrackGroup.length)
.mapNotNull {
group.getTrackFormat(it).id
}.any { it.endsWith("e:$subtitleIndex") }
}
} else {
val indexToFind =
calculateIndexToFind(
subtitleIndex,
MediaStreamType.SUBTITLE,
playerBackend,
videoStreamCount,
audioStreamCount,
embeddedSubtitleCount,
externalSubtitleCount,
subtitleIsExternal,
)
Timber.v("Chosen subtitle ($subtitleIndex/$indexToFind) track")
// subtitleIndex - externalSubtitleCount + 1
tracks.firstOrNull { group ->
group.type == C.TRACK_TYPE_TEXT && group.isSupported &&
(0..<group.mediaTrackGroup.length)
.map {
group.getTrackFormat(it).idAsInt
}.contains(indexToFind)
}
}
Timber.v("Chosen subtitle ($subtitleIndex) track: $chosenTrack")
chosenTrack?.let {
paramsBuilder
.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false)
.setOverrideForType(
TrackSelectionOverride(
chosenTrack.mediaTrackGroup,
0,
),
)
}
chosenTrack != null
} else {
false
}
} else {
paramsBuilder
.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, true)
true
}
val audioSelected =
if (audioIndex != null && supportsDirectPlay) {
val indexToFind =
calculateIndexToFind(
audioIndex,
MediaStreamType.AUDIO,
playerBackend,
videoStreamCount,
audioStreamCount,
embeddedSubtitleCount,
externalSubtitleCount,
false,
)
val chosenTrack =
tracks.firstOrNull { group ->
group.type == C.TRACK_TYPE_AUDIO && group.isSupported &&
(0..<group.mediaTrackGroup.length)
.map {
group.getTrackFormat(it).idAsInt
}.contains(indexToFind)
}
Timber.v("Chosen audio ($audioIndex/$indexToFind) track: $chosenTrack")
chosenTrack?.let {
paramsBuilder
.setTrackTypeDisabled(C.TRACK_TYPE_AUDIO, false)
.setOverrideForType(
TrackSelectionOverride(
chosenTrack.mediaTrackGroup,
0,
),
)
}
chosenTrack != null
} else {
audioIndex == null
}
if (audioSelected && subtitleSelected) {
player.trackSelectionParameters = paramsBuilder.build()
}
return TrackSelectionResult(audioSelected, subtitleSelected)
}
/**
* Maps the server provided index to the track index based on the [PlayerBackend] and other stream information
*/
private fun calculateIndexToFind(
serverIndex: Int,
type: MediaStreamType,
playerBackend: PlayerBackend,
videoStreamCount: Int,
audioStreamCount: Int,
embeddedSubtitleCount: Int,
externalSubtitleCount: Int,
subtitleIsExternal: Boolean,
): Int =
when (playerBackend) {
PlayerBackend.EXO_PLAYER,
PlayerBackend.UNRECOGNIZED,
-> {
serverIndex - externalSubtitleCount + 1
}
// TODO MPV could use literal indexes because they are stored in the track format ID
PlayerBackend.MPV -> {
when (type) {
MediaStreamType.VIDEO -> serverIndex - externalSubtitleCount + 1
MediaStreamType.AUDIO -> serverIndex - externalSubtitleCount - videoStreamCount + 1
MediaStreamType.SUBTITLE -> {
if (subtitleIsExternal) {
serverIndex + embeddedSubtitleCount + 1
} else {
serverIndex - externalSubtitleCount - videoStreamCount - audioStreamCount + 1
}
}
else -> throw UnsupportedOperationException("Cannot calculate index for $type")
}
}
}
}
val Format.idAsInt: Int?
@OptIn(UnstableApi::class)
get() =
id?.let {
if (it.contains(":")) {
it.split(":").last().toIntOrNull()
} else {
it.toIntOrNull()
}
}
/**
* Returns the number of external subtitle streams there are
*/
val MediaSourceInfo.externalSubtitlesCount: Int
get() =
mediaStreams
?.count { it.type == MediaStreamType.SUBTITLE && it.isExternal } ?: 0
/**
* Returns the number of embedded subtitle streams there are
*/
val MediaSourceInfo.embeddedSubtitleCount: Int
get() =
mediaStreams
?.count { it.type == MediaStreamType.SUBTITLE && !it.isExternal } ?: 0
/**
* Returns the number of video streams there are
*/
val MediaSourceInfo.videoStreamCount: Int
get() =
mediaStreams
?.count { it.type == MediaStreamType.VIDEO } ?: 0
/**
* Returns the number of audio streams there are
*/
val MediaSourceInfo.audioStreamCount: Int
get() =
mediaStreams
?.count { it.type == MediaStreamType.AUDIO } ?: 0
/**
* Returns the [MediaStream] for the given subtitle index iff it is external
*/
fun MediaSourceInfo.findExternalSubtitle(subtitleIndex: Int?): MediaStream? =
subtitleIndex?.let {
mediaStreams
?.firstOrNull { it.type == MediaStreamType.SUBTITLE && it.isExternal && it.index == subtitleIndex }
}
data class TrackSelectionResult(
val audioSelected: Boolean,
val subtitleSelected: Boolean,
) {
val bothSelected: Boolean = audioSelected && subtitleSelected
}