mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 23:51:21 +02:00
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:
parent
1fd497d18c
commit
4517dcd0cf
11 changed files with 643 additions and 600 deletions
|
|
@ -1,8 +1,10 @@
|
||||||
package com.github.damontecres.wholphin.data.model
|
package com.github.damontecres.wholphin.data.model
|
||||||
|
|
||||||
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
|
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.nav.Destination
|
||||||
import com.github.damontecres.wholphin.ui.seasonEpisode
|
import com.github.damontecres.wholphin.ui.seasonEpisode
|
||||||
|
import com.github.damontecres.wholphin.ui.seasonEpisodePadded
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.Transient
|
import kotlinx.serialization.Transient
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
|
@ -32,6 +34,18 @@ data class BaseItem(
|
||||||
get() =
|
get() =
|
||||||
if (type == BaseItemKind.EPISODE) data.seasonEpisode + " - " + name else data.productionYear?.toString()
|
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
|
@Transient
|
||||||
val indexNumber = data.indexNumber ?: dateAsIndex()
|
val indexNumber = data.indexNumber ?: dateAsIndex()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,9 @@ class PlaylistCreator
|
||||||
private val api: ApiClient,
|
private val api: ApiClient,
|
||||||
private val serverRepository: ServerRepository,
|
private val serverRepository: ServerRepository,
|
||||||
) {
|
) {
|
||||||
|
/**
|
||||||
|
* Creates a playlist of next up episodes for the given series starting with the given episode
|
||||||
|
*/
|
||||||
suspend fun createFromEpisode(
|
suspend fun createFromEpisode(
|
||||||
seriesId: UUID,
|
seriesId: UUID,
|
||||||
episodeId: UUID,
|
episodeId: UUID,
|
||||||
|
|
@ -47,13 +50,13 @@ class PlaylistCreator
|
||||||
seriesId = seriesId,
|
seriesId = seriesId,
|
||||||
fields = DefaultItemFields,
|
fields = DefaultItemFields,
|
||||||
startItemId = episodeId,
|
startItemId = episodeId,
|
||||||
limit = Playlist.Companion.MAX_SIZE,
|
limit = Playlist.MAX_SIZE,
|
||||||
)
|
)
|
||||||
val episodes = GetEpisodesRequestHandler.execute(api, request).content.items
|
val episodes = GetEpisodesRequestHandler.execute(api, request).content.items
|
||||||
val startIndex =
|
val startIndex =
|
||||||
episodes.indexOfFirstOrNull { it.id == episodeId }
|
episodes.indexOfFirstOrNull { it.id == episodeId }
|
||||||
?: throw IllegalStateException("Episode $episodeId was not returned")
|
?: 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(
|
suspend fun createFromPlaylistId(
|
||||||
|
|
@ -66,10 +69,10 @@ class PlaylistCreator
|
||||||
playlistId = playlistId,
|
playlistId = playlistId,
|
||||||
fields = DefaultItemFields,
|
fields = DefaultItemFields,
|
||||||
startIndex = startIndex,
|
startIndex = startIndex,
|
||||||
limit = Playlist.Companion.MAX_SIZE,
|
limit = Playlist.MAX_SIZE,
|
||||||
)
|
)
|
||||||
val items = GetPlaylistItemsRequestHandler.execute(api, request).content.items
|
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) {
|
if (shuffled) {
|
||||||
baseItems = baseItems.shuffled()
|
baseItems = baseItems.shuffled()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -390,3 +390,5 @@ fun logTab(
|
||||||
Timber.i("Current tab: $info")
|
Timber.i("Current tab: $info")
|
||||||
ACRA.errorReporter.putCustomData("tabInfo", info)
|
ACRA.errorReporter.putCustomData("tabInfo", info)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun <T> onMain(block: suspend CoroutineScope.() -> T) = withContext(Dispatchers.Main, block)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -2,6 +2,7 @@ package com.github.damontecres.wholphin.ui.playback
|
||||||
|
|
||||||
import com.github.damontecres.wholphin.R
|
import com.github.damontecres.wholphin.R
|
||||||
import com.github.damontecres.wholphin.WholphinApplication
|
import com.github.damontecres.wholphin.WholphinApplication
|
||||||
|
import org.jellyfin.sdk.model.api.MediaStream
|
||||||
|
|
||||||
data class SubtitleStream(
|
data class SubtitleStream(
|
||||||
val index: Int,
|
val index: Int,
|
||||||
|
|
@ -22,6 +23,21 @@ data class SubtitleStream(
|
||||||
codec,
|
codec,
|
||||||
).joinToString(" - ")
|
).joinToString(" - ")
|
||||||
.ifBlank { WholphinApplication.instance.getString(R.string.unknown) }
|
.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(
|
data class AudioStream(
|
||||||
|
|
@ -41,4 +57,17 @@ data class AudioStream(
|
||||||
codec,
|
codec,
|
||||||
channelLayout?.ifBlank { null } ?: channels?.let { "$it ch" },
|
channelLayout?.ifBlank { null } ?: channels?.let { "$it ch" },
|
||||||
).joinToString(" - ").ifBlank { "Unknown" }
|
).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,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -80,7 +80,6 @@ private val subtitleTextSize = 18.sp
|
||||||
@Composable
|
@Composable
|
||||||
fun PlaybackOverlay(
|
fun PlaybackOverlay(
|
||||||
item: BaseItem?,
|
item: BaseItem?,
|
||||||
title: String?,
|
|
||||||
subtitleStreams: List<SubtitleStream>,
|
subtitleStreams: List<SubtitleStream>,
|
||||||
chapters: List<Chapter>,
|
chapters: List<Chapter>,
|
||||||
playerControls: Player,
|
playerControls: Player,
|
||||||
|
|
@ -104,7 +103,6 @@ fun PlaybackOverlay(
|
||||||
audioStreams: List<AudioStream>,
|
audioStreams: List<AudioStream>,
|
||||||
currentSegment: MediaSegmentDto?,
|
currentSegment: MediaSegmentDto?,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
subtitle: String? = null,
|
|
||||||
trickplayInfo: TrickplayInfo? = null,
|
trickplayInfo: TrickplayInfo? = null,
|
||||||
trickplayUrlFor: (Int) -> String? = { null },
|
trickplayUrlFor: (Int) -> String? = { null },
|
||||||
playlist: Playlist = Playlist(listOf(), 0),
|
playlist: Playlist = Playlist(listOf(), 0),
|
||||||
|
|
@ -123,11 +121,11 @@ fun PlaybackOverlay(
|
||||||
|
|
||||||
val titleHeight =
|
val titleHeight =
|
||||||
remember {
|
remember {
|
||||||
if (title.isNotNullOrBlank()) with(density) { titleTextSize.toDp() } else 0.dp
|
if (item?.title.isNotNullOrBlank()) with(density) { titleTextSize.toDp() } else 0.dp
|
||||||
}
|
}
|
||||||
val subtitleHeight =
|
val subtitleHeight =
|
||||||
remember {
|
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
|
// This will be calculated after composition
|
||||||
|
|
@ -152,7 +150,8 @@ fun PlaybackOverlay(
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
Controller(
|
Controller(
|
||||||
title = title,
|
title = item?.title,
|
||||||
|
subtitle = item?.subtitleLong,
|
||||||
subtitleStreams = subtitleStreams,
|
subtitleStreams = subtitleStreams,
|
||||||
playerControls = playerControls,
|
playerControls = playerControls,
|
||||||
controllerViewState = controllerViewState,
|
controllerViewState = controllerViewState,
|
||||||
|
|
@ -176,7 +175,6 @@ fun PlaybackOverlay(
|
||||||
currentPlayback = currentPlayback,
|
currentPlayback = currentPlayback,
|
||||||
currentItemPlayback = currentItemPlayback,
|
currentItemPlayback = currentItemPlayback,
|
||||||
audioStreams = audioStreams,
|
audioStreams = audioStreams,
|
||||||
subtitle = subtitle,
|
|
||||||
seekBarInteractionSource = seekBarInteractionSource,
|
seekBarInteractionSource = seekBarInteractionSource,
|
||||||
nextState = nextState,
|
nextState = nextState,
|
||||||
onNextStateFocus = {
|
onNextStateFocus = {
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,6 @@ import androidx.compose.foundation.layout.systemBars
|
||||||
import androidx.compose.foundation.layout.widthIn
|
import androidx.compose.foundation.layout.widthIn
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.livedata.observeAsState
|
import androidx.compose.runtime.livedata.observeAsState
|
||||||
|
|
@ -49,9 +48,6 @@ import androidx.compose.ui.window.Dialog
|
||||||
import androidx.compose.ui.window.DialogProperties
|
import androidx.compose.ui.window.DialogProperties
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.LifecycleStartEffect
|
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.common.util.UnstableApi
|
||||||
import androidx.media3.ui.SubtitleView
|
import androidx.media3.ui.SubtitleView
|
||||||
import androidx.media3.ui.compose.PlayerSurface
|
import androidx.media3.ui.compose.PlayerSurface
|
||||||
|
|
@ -124,13 +120,8 @@ fun PlaybackPage(
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
|
|
||||||
val player = viewModel.player
|
val player = viewModel.player
|
||||||
val title by viewModel.title.observeAsState(null)
|
val mediaInfo by viewModel.currentMediaInfo.observeAsState()
|
||||||
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 currentPlayback by viewModel.currentPlayback.observeAsState(null)
|
val currentPlayback by viewModel.currentPlayback.observeAsState(null)
|
||||||
val currentItemPlayback by viewModel.currentItemPlayback.observeAsState(
|
val currentItemPlayback by viewModel.currentItemPlayback.observeAsState(
|
||||||
ItemPlayback(
|
ItemPlayback(
|
||||||
|
|
@ -141,7 +132,7 @@ fun PlaybackPage(
|
||||||
val currentSegment by viewModel.currentSegment.observeAsState(null)
|
val currentSegment by viewModel.currentSegment.observeAsState(null)
|
||||||
var segmentCancelled by remember(currentSegment?.id) { mutableStateOf(false) }
|
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) }
|
var showDebugInfo by remember { mutableStateOf(prefs.showDebugInfo) }
|
||||||
|
|
||||||
val nextUp by viewModel.nextUp.observeAsState(null)
|
val nextUp by viewModel.nextUp.observeAsState(null)
|
||||||
|
|
@ -150,18 +141,7 @@ fun PlaybackPage(
|
||||||
val subtitleSearch by viewModel.subtitleSearch.observeAsState(null)
|
val subtitleSearch by viewModel.subtitleSearch.observeAsState(null)
|
||||||
val subtitleSearchLanguage by viewModel.subtitleSearchLanguage.observeAsState(Locale.current.language)
|
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 {
|
OneTimeLaunchedEffect {
|
||||||
player.addListener(cueListener)
|
|
||||||
if (prefs.playerBackend == PlayerBackend.MPV) {
|
if (prefs.playerBackend == PlayerBackend.MPV) {
|
||||||
scope.launch(Dispatchers.Main + ExceptionHandler()) {
|
scope.launch(Dispatchers.Main + ExceptionHandler()) {
|
||||||
preferences.appPreferences.interfacePreferences.subtitlesPreferences.applyToMpv(
|
preferences.appPreferences.interfacePreferences.subtitlesPreferences.applyToMpv(
|
||||||
|
|
@ -170,9 +150,6 @@ fun PlaybackPage(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
DisposableEffect(Unit) {
|
|
||||||
onDispose { player.removeListener(cueListener) }
|
|
||||||
}
|
|
||||||
AmbientPlayerListener(player)
|
AmbientPlayerListener(player)
|
||||||
var contentScale by remember { mutableStateOf(prefs.globalContentScale.scale) }
|
var contentScale by remember { mutableStateOf(prefs.globalContentScale.scale) }
|
||||||
var playbackSpeed by remember { mutableFloatStateOf(1.0f) }
|
var playbackSpeed by remember { mutableFloatStateOf(1.0f) }
|
||||||
|
|
@ -280,20 +257,16 @@ fun PlaybackPage(
|
||||||
// Show a small progress bar along the bottom of the screen
|
// Show a small progress bar along the bottom of the screen
|
||||||
val showSkipProgress = true // TODO get from preferences
|
val showSkipProgress = true // TODO get from preferences
|
||||||
if (showSkipProgress) {
|
if (showSkipProgress) {
|
||||||
duration?.let {
|
val percent = skipPosition.toFloat() / player.duration.toFloat()
|
||||||
val percent = (skipPosition.milliseconds / it).toFloat()
|
Box(
|
||||||
Box(
|
modifier =
|
||||||
modifier =
|
Modifier
|
||||||
Modifier
|
.align(Alignment.BottomStart)
|
||||||
.align(Alignment.BottomStart)
|
.background(MaterialTheme.colorScheme.border)
|
||||||
.background(MaterialTheme.colorScheme.border)
|
.clip(RectangleShape)
|
||||||
.clip(RectangleShape)
|
.height(3.dp)
|
||||||
.height(3.dp)
|
.fillMaxWidth(percent),
|
||||||
.fillMaxWidth(percent),
|
)
|
||||||
) {
|
|
||||||
// No-op
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -337,9 +310,6 @@ fun PlaybackPage(
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(Color.Transparent),
|
.background(Color.Transparent),
|
||||||
item = currentPlayback?.item,
|
item = currentPlayback?.item,
|
||||||
title = title,
|
|
||||||
subtitle = subtitle,
|
|
||||||
subtitleStreams = subtitleStreams,
|
|
||||||
playerControls = player,
|
playerControls = player,
|
||||||
controllerViewState = controllerViewState,
|
controllerViewState = controllerViewState,
|
||||||
showPlay = playPauseState.showPlay,
|
showPlay = playPauseState.showPlay,
|
||||||
|
|
@ -400,10 +370,11 @@ fun PlaybackPage(
|
||||||
moreButtonOptions = MoreButtonOptions(mapOf()),
|
moreButtonOptions = MoreButtonOptions(mapOf()),
|
||||||
currentPlayback = currentPlayback,
|
currentPlayback = currentPlayback,
|
||||||
currentItemPlayback = currentItemPlayback,
|
currentItemPlayback = currentItemPlayback,
|
||||||
audioStreams = audioStreams,
|
audioStreams = mediaInfo?.audioStreams ?: listOf(),
|
||||||
trickplayInfo = trickplay,
|
subtitleStreams = mediaInfo?.subtitleStreams ?: listOf(),
|
||||||
|
chapters = mediaInfo?.chapters ?: listOf(),
|
||||||
|
trickplayInfo = mediaInfo?.trickPlayInfo,
|
||||||
trickplayUrlFor = viewModel::getTrickplayUrl,
|
trickplayUrlFor = viewModel::getTrickplayUrl,
|
||||||
chapters = chapters,
|
|
||||||
playlist = playlist,
|
playlist = playlist,
|
||||||
onClickPlaylist = {
|
onClickPlaylist = {
|
||||||
viewModel.playItemInPlaylist(it)
|
viewModel.playItemInPlaylist(it)
|
||||||
|
|
|
||||||
|
|
@ -5,19 +5,17 @@ import android.widget.Toast
|
||||||
import androidx.annotation.OptIn
|
import androidx.annotation.OptIn
|
||||||
import androidx.compose.ui.text.intl.Locale
|
import androidx.compose.ui.text.intl.Locale
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import androidx.datastore.core.DataStore
|
|
||||||
import androidx.lifecycle.MutableLiveData
|
import androidx.lifecycle.MutableLiveData
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import androidx.media3.common.C
|
import androidx.media3.common.C
|
||||||
import androidx.media3.common.Format
|
|
||||||
import androidx.media3.common.MediaItem
|
import androidx.media3.common.MediaItem
|
||||||
import androidx.media3.common.PlaybackException
|
import androidx.media3.common.PlaybackException
|
||||||
import androidx.media3.common.Player
|
import androidx.media3.common.Player
|
||||||
import androidx.media3.common.TrackSelectionOverride
|
|
||||||
import androidx.media3.common.Tracks
|
import androidx.media3.common.Tracks
|
||||||
|
import androidx.media3.common.text.Cue
|
||||||
|
import androidx.media3.common.text.CueGroup
|
||||||
import androidx.media3.common.util.UnstableApi
|
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.ItemPlaybackDao
|
||||||
import com.github.damontecres.wholphin.data.ItemPlaybackRepository
|
import com.github.damontecres.wholphin.data.ItemPlaybackRepository
|
||||||
import com.github.damontecres.wholphin.data.ServerRepository
|
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.chooseSource
|
||||||
import com.github.damontecres.wholphin.data.model.chooseStream
|
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.AppPreferences
|
|
||||||
import com.github.damontecres.wholphin.preferences.PlayerBackend
|
import com.github.damontecres.wholphin.preferences.PlayerBackend
|
||||||
import com.github.damontecres.wholphin.preferences.ShowNextUpWhen
|
import com.github.damontecres.wholphin.preferences.ShowNextUpWhen
|
||||||
import com.github.damontecres.wholphin.preferences.SkipSegmentBehavior
|
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.NavigationManager
|
||||||
import com.github.damontecres.wholphin.services.PlayerFactory
|
import com.github.damontecres.wholphin.services.PlayerFactory
|
||||||
import com.github.damontecres.wholphin.services.PlaylistCreator
|
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.launchIO
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
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.seekBack
|
||||||
import com.github.damontecres.wholphin.ui.seekForward
|
import com.github.damontecres.wholphin.ui.seekForward
|
||||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
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.LoadingExceptionHandler
|
||||||
import com.github.damontecres.wholphin.util.LoadingState
|
import com.github.damontecres.wholphin.util.LoadingState
|
||||||
import com.github.damontecres.wholphin.util.TrackActivityPlaybackListener
|
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.checkForSupport
|
||||||
import com.github.damontecres.wholphin.util.mpv.mpvDeviceProfile
|
import com.github.damontecres.wholphin.util.mpv.mpvDeviceProfile
|
||||||
import com.github.damontecres.wholphin.util.subtitleMimeTypes
|
import com.github.damontecres.wholphin.util.subtitleMimeTypes
|
||||||
import com.github.damontecres.wholphin.util.supportItemKinds
|
import com.github.damontecres.wholphin.util.supportItemKinds
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
|
@ -67,11 +61,12 @@ import kotlinx.coroutines.flow.launchIn
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
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.mediaInfoApi
|
import org.jellyfin.sdk.api.client.extensions.mediaInfoApi
|
||||||
import org.jellyfin.sdk.api.client.extensions.mediaSegmentsApi
|
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.trickplayApi
|
||||||
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||||
import org.jellyfin.sdk.api.client.extensions.videosApi
|
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.DeviceProfile
|
||||||
import org.jellyfin.sdk.model.api.MediaSegmentDto
|
import org.jellyfin.sdk.model.api.MediaSegmentDto
|
||||||
import org.jellyfin.sdk.model.api.MediaSegmentType
|
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.MediaStreamType
|
||||||
import org.jellyfin.sdk.model.api.PlayMethod
|
import org.jellyfin.sdk.model.api.PlayMethod
|
||||||
import org.jellyfin.sdk.model.api.PlaybackInfoDto
|
import org.jellyfin.sdk.model.api.PlaybackInfoDto
|
||||||
import org.jellyfin.sdk.model.api.PlaystateCommand
|
import org.jellyfin.sdk.model.api.PlaystateCommand
|
||||||
import org.jellyfin.sdk.model.api.PlaystateMessage
|
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.inWholeTicks
|
||||||
import org.jellyfin.sdk.model.extensions.ticks
|
import org.jellyfin.sdk.model.extensions.ticks
|
||||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||||
|
|
@ -96,28 +87,23 @@ import timber.log.Timber
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlin.time.Duration
|
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
data class StreamDecision(
|
/**
|
||||||
val itemId: UUID,
|
* This [ViewModel] is responsible for playing media including moving through playlists (including next up episodes)
|
||||||
val type: PlayMethod,
|
*/
|
||||||
val url: String,
|
|
||||||
)
|
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
@OptIn(markerClass = [UnstableApi::class])
|
@OptIn(markerClass = [UnstableApi::class])
|
||||||
class PlaybackViewModel
|
class PlaybackViewModel
|
||||||
@Inject
|
@Inject
|
||||||
constructor(
|
constructor(
|
||||||
@param:ApplicationContext val context: Context,
|
@param:ApplicationContext internal val context: Context,
|
||||||
val api: ApiClient,
|
internal val api: ApiClient,
|
||||||
val playlistCreator: PlaylistCreator,
|
|
||||||
val navigationManager: NavigationManager,
|
val navigationManager: NavigationManager,
|
||||||
val itemPlaybackDao: ItemPlaybackDao,
|
private val playlistCreator: PlaylistCreator,
|
||||||
val serverRepository: ServerRepository,
|
private val itemPlaybackDao: ItemPlaybackDao,
|
||||||
val itemPlaybackRepository: ItemPlaybackRepository,
|
private val serverRepository: ServerRepository,
|
||||||
val appPreferences: DataStore<AppPreferences>,
|
private val itemPlaybackRepository: ItemPlaybackRepository,
|
||||||
private val playerFactory: PlayerFactory,
|
private val playerFactory: PlayerFactory,
|
||||||
private val datePlayedService: DatePlayedService,
|
private val datePlayedService: DatePlayedService,
|
||||||
) : ViewModel(),
|
) : ViewModel(),
|
||||||
|
|
@ -125,31 +111,30 @@ class PlaybackViewModel
|
||||||
val player by lazy {
|
val player by lazy {
|
||||||
playerFactory.createVideoPlayer()
|
playerFactory.createVideoPlayer()
|
||||||
}
|
}
|
||||||
|
internal val mutex = Mutex()
|
||||||
|
|
||||||
val loading = MutableLiveData<LoadingState>(LoadingState.Loading)
|
val loading = MutableLiveData<LoadingState>(LoadingState.Loading)
|
||||||
|
|
||||||
val title = MutableLiveData<String?>(null)
|
val currentMediaInfo = MutableLiveData<CurrentMediaInfo>(CurrentMediaInfo.EMPTY)
|
||||||
val subtitle = MutableLiveData<String?>(null)
|
|
||||||
val duration = MutableLiveData<Duration?>(null)
|
|
||||||
val audioStreams = MutableLiveData<List<AudioStream>>(listOf())
|
|
||||||
val subtitleStreams = MutableLiveData<List<SubtitleStream>>(listOf())
|
|
||||||
val currentPlayback = MutableLiveData<CurrentPlayback?>(null)
|
val currentPlayback = MutableLiveData<CurrentPlayback?>(null)
|
||||||
val currentItemPlayback = MutableLiveData<ItemPlayback>()
|
val currentItemPlayback = MutableLiveData<ItemPlayback>()
|
||||||
val trickplay = MutableLiveData<TrickplayInfo?>(null)
|
|
||||||
val chapters = MutableLiveData<List<Chapter>>(listOf())
|
|
||||||
val currentSegment = EqualityMutableLiveData<MediaSegmentDto?>(null)
|
val currentSegment = EqualityMutableLiveData<MediaSegmentDto?>(null)
|
||||||
private val autoSkippedSegments = mutableSetOf<UUID>()
|
private val autoSkippedSegments = mutableSetOf<UUID>()
|
||||||
|
|
||||||
|
val subtitleCues = MutableLiveData<List<Cue>>(listOf())
|
||||||
|
|
||||||
private lateinit var preferences: UserPreferences
|
private lateinit var preferences: UserPreferences
|
||||||
private lateinit var deviceProfile: DeviceProfile
|
private lateinit var deviceProfile: DeviceProfile
|
||||||
private lateinit var itemId: UUID
|
internal lateinit var itemId: UUID
|
||||||
private lateinit var item: BaseItem
|
internal lateinit var item: BaseItem
|
||||||
private var activityListener: TrackActivityPlaybackListener? = null
|
private var activityListener: TrackActivityPlaybackListener? = null
|
||||||
|
|
||||||
val nextUp = MutableLiveData<BaseItem?>()
|
val nextUp = MutableLiveData<BaseItem?>()
|
||||||
private var isPlaylist = false
|
private var isPlaylist = false
|
||||||
|
|
||||||
val playlist = MutableLiveData<Playlist>(Playlist(listOf()))
|
val playlist = MutableLiveData<Playlist>(Playlist(listOf()))
|
||||||
|
val subtitleSearch = MutableLiveData<SubtitleSearch?>(null)
|
||||||
|
val subtitleSearchLanguage = MutableLiveData<String>(Locale.current.language)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
player.addListener(this)
|
player.addListener(this)
|
||||||
|
|
@ -164,6 +149,9 @@ class PlaybackViewModel
|
||||||
subscribe()
|
subscribe()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize from the UI to start playback
|
||||||
|
*/
|
||||||
fun init(
|
fun init(
|
||||||
destination: Destination.Playback,
|
destination: Destination.Playback,
|
||||||
deviceProfile: DeviceProfile,
|
deviceProfile: DeviceProfile,
|
||||||
|
|
@ -225,10 +213,17 @@ class PlaybackViewModel
|
||||||
this@PlaybackViewModel.playlist.value = playlist
|
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(
|
private suspend fun play(
|
||||||
item: BaseItem,
|
item: BaseItem,
|
||||||
positionMs: Long,
|
positionMs: Long,
|
||||||
|
|
@ -237,8 +232,13 @@ class PlaybackViewModel
|
||||||
): Boolean =
|
): Boolean =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
Timber.i("Playing ${item.id}")
|
Timber.i("Playing ${item.id}")
|
||||||
|
|
||||||
|
// Starting playback, so want to invalidate the last played timestamp for this item
|
||||||
datePlayedService.invalidate(item)
|
datePlayedService.invalidate(item)
|
||||||
|
// New item, so we can clear the media segment tracker & subtitle cues
|
||||||
autoSkippedSegments.clear()
|
autoSkippedSegments.clear()
|
||||||
|
this@PlaybackViewModel.subtitleCues.setValueOnMain(listOf())
|
||||||
|
|
||||||
if (item.type !in supportItemKinds) {
|
if (item.type !in supportItemKinds) {
|
||||||
showToast(
|
showToast(
|
||||||
context,
|
context,
|
||||||
|
|
@ -252,32 +252,11 @@ class PlaybackViewModel
|
||||||
|
|
||||||
val isLiveTv = item.type == BaseItemKind.TV_CHANNEL
|
val isLiveTv = item.type == BaseItemKind.TV_CHANNEL
|
||||||
val base = item.data
|
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 =
|
val playbackConfig =
|
||||||
if (itemPlayback != null) {
|
itemPlayback
|
||||||
itemPlayback
|
?: serverRepository.currentUser.value?.let { user ->
|
||||||
} else {
|
|
||||||
serverRepository.currentUser.value?.let { user ->
|
|
||||||
itemPlaybackDao.getItem(user, base.id)?.let {
|
itemPlaybackDao.getItem(user, base.id)?.let {
|
||||||
Timber.v("Fetched itemPlayback from DB: %s", it)
|
Timber.v("Fetched itemPlayback from DB: %s", it)
|
||||||
if (it.sourceId != null) {
|
if (it.sourceId != null) {
|
||||||
|
|
@ -287,7 +266,6 @@ class PlaybackViewModel
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
val mediaSource = chooseSource(base, playbackConfig)
|
val mediaSource = chooseSource(base, playbackConfig)
|
||||||
|
|
||||||
if (mediaSource == null) {
|
if (mediaSource == null) {
|
||||||
|
|
@ -299,39 +277,16 @@ class PlaybackViewModel
|
||||||
return@withContext false
|
return@withContext false
|
||||||
}
|
}
|
||||||
|
|
||||||
// mediaSource.mediaStreams
|
|
||||||
// ?.filter { it.type == MediaStreamType.VIDEO }
|
|
||||||
// ?.forEach { Timber.v("${it.videoRangeType}, ${it.videoRange}") }
|
|
||||||
val subtitleStreams =
|
val subtitleStreams =
|
||||||
mediaSource.mediaStreams
|
mediaSource.mediaStreams
|
||||||
?.filter { it.type == MediaStreamType.SUBTITLE }
|
?.filter { it.type == MediaStreamType.SUBTITLE }
|
||||||
?.map {
|
?.map(SubtitleStream::from)
|
||||||
SubtitleStream(
|
.orEmpty()
|
||||||
it.index,
|
|
||||||
it.language,
|
|
||||||
it.title,
|
|
||||||
it.codec,
|
|
||||||
it.codecTag,
|
|
||||||
it.isExternal,
|
|
||||||
it.isForced,
|
|
||||||
it.isDefault,
|
|
||||||
it.displayTitle,
|
|
||||||
)
|
|
||||||
}.orEmpty()
|
|
||||||
val audioStreams =
|
val audioStreams =
|
||||||
mediaSource.mediaStreams
|
mediaSource.mediaStreams
|
||||||
?.filter { it.type == MediaStreamType.AUDIO }
|
?.filter { it.type == MediaStreamType.AUDIO }
|
||||||
?.map {
|
?.map(AudioStream::from)
|
||||||
AudioStream(
|
?.sortedWith(compareBy<AudioStream> { it.language }.thenByDescending { it.channels })
|
||||||
it.index,
|
|
||||||
it.language,
|
|
||||||
it.title,
|
|
||||||
it.codec,
|
|
||||||
it.codecTag,
|
|
||||||
it.channels,
|
|
||||||
it.channelLayout,
|
|
||||||
)
|
|
||||||
}?.sortedWith(compareBy<AudioStream> { it.language }.thenByDescending { it.channels })
|
|
||||||
.orEmpty()
|
.orEmpty()
|
||||||
|
|
||||||
val audioIndex =
|
val audioIndex =
|
||||||
|
|
@ -342,9 +297,6 @@ class PlaybackViewModel
|
||||||
chooseStream(base, playbackConfig, MediaStreamType.SUBTITLE, preferences)
|
chooseStream(base, playbackConfig, MediaStreamType.SUBTITLE, preferences)
|
||||||
?.index
|
?.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")
|
Timber.d("Selected mediaSource=${mediaSource.id}, audioIndex=$audioIndex, subtitleIndex=$subtitleIndex")
|
||||||
|
|
||||||
val itemPlaybackToUse =
|
val itemPlaybackToUse =
|
||||||
|
|
@ -356,11 +308,23 @@ class PlaybackViewModel
|
||||||
audioIndex = audioIndex ?: TrackIndex.UNSPECIFIED,
|
audioIndex = audioIndex ?: TrackIndex.UNSPECIFIED,
|
||||||
subtitleIndex = subtitleIndex ?: 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) {
|
withContext(Dispatchers.Main) {
|
||||||
this@PlaybackViewModel.currentItemPlayback.value = itemPlaybackToUse
|
this@PlaybackViewModel.currentItemPlayback.value = itemPlaybackToUse
|
||||||
this@PlaybackViewModel.audioStreams.value = audioStreams
|
updateCurrentMedia {
|
||||||
this@PlaybackViewModel.subtitleStreams.value = subtitleStreams
|
CurrentMediaInfo(
|
||||||
|
audioStreams = audioStreams,
|
||||||
|
subtitleStreams = subtitleStreams,
|
||||||
|
chapters = chapters,
|
||||||
|
trickPlayInfo = trickPlayInfo,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
changeStreams(
|
changeStreams(
|
||||||
item,
|
item,
|
||||||
|
|
@ -374,16 +338,16 @@ class PlaybackViewModel
|
||||||
)
|
)
|
||||||
player.prepare()
|
player.prepare()
|
||||||
player.play()
|
player.play()
|
||||||
|
|
||||||
this@PlaybackViewModel.chapters.value = Chapter.fromDto(base, api)
|
|
||||||
Timber.v("chapters=${this@PlaybackViewModel.chapters.value?.size}")
|
|
||||||
}
|
}
|
||||||
listenForSegments()
|
listenForSegments()
|
||||||
return@withContext true
|
return@withContext true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change which streams (ie audio or subtitle) are active
|
||||||
|
*/
|
||||||
@OptIn(UnstableApi::class)
|
@OptIn(UnstableApi::class)
|
||||||
private suspend fun changeStreams(
|
internal suspend fun changeStreams(
|
||||||
item: BaseItem,
|
item: BaseItem,
|
||||||
currentItemPlayback: ItemPlayback = this@PlaybackViewModel.currentItemPlayback.value!!,
|
currentItemPlayback: ItemPlayback = this@PlaybackViewModel.currentItemPlayback.value!!,
|
||||||
audioIndex: Int?,
|
audioIndex: Int?,
|
||||||
|
|
@ -409,7 +373,7 @@ class PlaybackViewModel
|
||||||
if (externalSubtitle == null) {
|
if (externalSubtitle == null) {
|
||||||
val result =
|
val result =
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
applyTrackSelections(
|
TrackSelectionUtils.applyTrackSelections(
|
||||||
player,
|
player,
|
||||||
playerBackend,
|
playerBackend,
|
||||||
true,
|
true,
|
||||||
|
|
@ -519,8 +483,7 @@ class PlaybackViewModel
|
||||||
source.supportsTranscoding -> PlayMethod.TRANSCODE
|
source.supportsTranscoding -> PlayMethod.TRANSCODE
|
||||||
else -> throw Exception("No supported playback method")
|
else -> throw Exception("No supported playback method")
|
||||||
}
|
}
|
||||||
val decision = StreamDecision(itemId, transcodeType, mediaUrl)
|
Timber.v("Playback decision: $transcodeType")
|
||||||
Timber.v("Playback decision: $decision")
|
|
||||||
|
|
||||||
val externalSubtitleCount = source.externalSubtitlesCount
|
val externalSubtitleCount = source.externalSubtitlesCount
|
||||||
|
|
||||||
|
|
@ -595,7 +558,6 @@ class PlaybackViewModel
|
||||||
player.addListener(activityListener)
|
player.addListener(activityListener)
|
||||||
this@PlaybackViewModel.activityListener = activityListener
|
this@PlaybackViewModel.activityListener = activityListener
|
||||||
|
|
||||||
duration.value = source.runTimeTicks?.ticks
|
|
||||||
loading.value = LoadingState.Success
|
loading.value = LoadingState.Success
|
||||||
this@PlaybackViewModel.currentPlayback.value = playback
|
this@PlaybackViewModel.currentPlayback.value = playback
|
||||||
this@PlaybackViewModel.currentItemPlayback.value = itemPlayback
|
this@PlaybackViewModel.currentItemPlayback.value = itemPlayback
|
||||||
|
|
@ -610,7 +572,7 @@ class PlaybackViewModel
|
||||||
Timber.v("onTracksChanged: $tracks")
|
Timber.v("onTracksChanged: $tracks")
|
||||||
if (tracks.groups.isNotEmpty()) {
|
if (tracks.groups.isNotEmpty()) {
|
||||||
val result =
|
val result =
|
||||||
applyTrackSelections(
|
TrackSelectionUtils.applyTrackSelections(
|
||||||
player,
|
player,
|
||||||
playerBackend,
|
playerBackend,
|
||||||
source.supportsDirectPlay,
|
source.supportsDirectPlay,
|
||||||
|
|
@ -627,15 +589,6 @@ class PlaybackViewModel
|
||||||
player.addListener(onTracksChangedListener)
|
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? {
|
fun getTrickplayUrl(index: Int): String? {
|
||||||
val itemId = item.id
|
val itemId = item.id
|
||||||
val mediaSourceId = currentItemPlayback.value?.sourceId
|
val mediaSourceId = currentItemPlayback.value?.sourceId
|
||||||
val trickPlayInfo = trickplay.value ?: return null
|
val trickPlayInfo = currentMediaInfo.value?.trickPlayInfo ?: return null
|
||||||
return api.trickplayApi.getTrickplayTileImageUrl(
|
return api.trickplayApi.getTrickplayTileImageUrl(
|
||||||
itemId,
|
itemId,
|
||||||
trickPlayInfo.width,
|
trickPlayInfo.width,
|
||||||
|
|
@ -676,32 +629,23 @@ class PlaybackViewModel
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun maybeSetupPlaylistListener() {
|
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||||
playlist.value?.let { playlist ->
|
if (playbackState == Player.STATE_ENDED) {
|
||||||
if (playlist.hasNext()) {
|
viewModelScope.launchIO {
|
||||||
Timber.v("Adding lister for playlist with ${playlist.items.size} items")
|
val nextItem = playlist.value?.peek()
|
||||||
val listener =
|
Timber.v("Setting next up to ${nextItem?.id}")
|
||||||
object : Player.Listener {
|
withContext(Dispatchers.Main) {
|
||||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
nextUp.value = nextItem
|
||||||
if (playbackState == Player.STATE_ENDED) {
|
}
|
||||||
viewModelScope.launchIO {
|
|
||||||
val nextItem = playlist.peek()
|
|
||||||
Timber.v("Setting next up to ${nextItem?.id}")
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
nextUp.value = nextItem
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
player.addListener(listener)
|
|
||||||
addCloseable { player.removeListener(listener) }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var segmentJob: Job? = null
|
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() {
|
private fun listenForSegments() {
|
||||||
segmentJob?.cancel()
|
segmentJob?.cancel()
|
||||||
segmentJob =
|
segmentJob =
|
||||||
|
|
@ -776,6 +720,9 @@ class PlaybackViewModel
|
||||||
|
|
||||||
private var lastInteractionDate: Date = Date()
|
private var lastInteractionDate: Date = Date()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tracks interactions with the UI for passout protection
|
||||||
|
*/
|
||||||
fun reportInteraction() {
|
fun reportInteraction() {
|
||||||
// Timber.v("reportInteraction")
|
// Timber.v("reportInteraction")
|
||||||
lastInteractionDate = Date()
|
lastInteractionDate = Date()
|
||||||
|
|
@ -848,6 +795,10 @@ class PlaybackViewModel
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onCues(cueGroup: CueGroup) {
|
||||||
|
subtitleCues.value = cueGroup.cues
|
||||||
|
}
|
||||||
|
|
||||||
override fun onPlayerError(error: PlaybackException) {
|
override fun onPlayerError(error: PlaybackException) {
|
||||||
Timber.e(error, "Playback error")
|
Timber.e(error, "Playback error")
|
||||||
viewModelScope.launch(Dispatchers.Main + ExceptionHandler()) {
|
viewModelScope.launch(Dispatchers.Main + ExceptionHandler()) {
|
||||||
|
|
@ -893,422 +844,49 @@ class PlaybackViewModel
|
||||||
.subscribe<PlaystateMessage>()
|
.subscribe<PlaystateMessage>()
|
||||||
.onEach { message ->
|
.onEach { message ->
|
||||||
message.data?.let {
|
message.data?.let {
|
||||||
when (it.command) {
|
withContext(Dispatchers.Main) {
|
||||||
PlaystateCommand.STOP -> {
|
when (it.command) {
|
||||||
release()
|
PlaystateCommand.STOP -> {
|
||||||
navigationManager.goBack()
|
release()
|
||||||
|
navigationManager.goBack()
|
||||||
|
}
|
||||||
|
|
||||||
|
PlaystateCommand.PAUSE -> player.pause()
|
||||||
|
PlaystateCommand.UNPAUSE -> player.play()
|
||||||
|
PlaystateCommand.NEXT_TRACK -> playNextUp()
|
||||||
|
PlaystateCommand.PREVIOUS_TRACK -> playPrevious()
|
||||||
|
PlaystateCommand.SEEK ->
|
||||||
|
it.seekPositionTicks?.ticks?.let {
|
||||||
|
player.seekTo(
|
||||||
|
it.inWholeMilliseconds,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
PlaystateCommand.REWIND ->
|
||||||
|
player.seekBack(
|
||||||
|
preferences.appPreferences.playbackPreferences.skipBackMs.milliseconds,
|
||||||
|
)
|
||||||
|
|
||||||
|
PlaystateCommand.FAST_FORWARD ->
|
||||||
|
player.seekForward(
|
||||||
|
preferences.appPreferences.playbackPreferences.skipForwardMs.milliseconds,
|
||||||
|
)
|
||||||
|
|
||||||
|
PlaystateCommand.PLAY_PAUSE -> if (player.isPlaying) player.pause() else player.play()
|
||||||
}
|
}
|
||||||
|
|
||||||
PlaystateCommand.PAUSE -> player.pause()
|
|
||||||
PlaystateCommand.UNPAUSE -> player.play()
|
|
||||||
PlaystateCommand.NEXT_TRACK -> playNextUp()
|
|
||||||
PlaystateCommand.PREVIOUS_TRACK -> playPrevious()
|
|
||||||
PlaystateCommand.SEEK -> it.seekPositionTicks?.ticks?.let { player.seekTo(it.inWholeMilliseconds) }
|
|
||||||
PlaystateCommand.REWIND ->
|
|
||||||
player.seekBack(
|
|
||||||
preferences.appPreferences.playbackPreferences.skipBackMs.milliseconds,
|
|
||||||
)
|
|
||||||
|
|
||||||
PlaystateCommand.FAST_FORWARD ->
|
|
||||||
player.seekForward(
|
|
||||||
preferences.appPreferences.playbackPreferences.skipForwardMs.milliseconds,
|
|
||||||
)
|
|
||||||
|
|
||||||
PlaystateCommand.PLAY_PAUSE -> if (player.isPlaying) player.pause() else player.play()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}.launchIn(viewModelScope)
|
}.launchIn(viewModelScope)
|
||||||
}
|
}
|
||||||
|
|
||||||
val subtitleSearch = MutableLiveData<SubtitleSearch?>(null)
|
/**
|
||||||
val subtitleSearchLanguage = MutableLiveData<String>(Locale.current.language)
|
* Atomically update [currentMediaInfo]
|
||||||
|
*/
|
||||||
fun searchForSubtitles(language: String = Locale.current.language) {
|
internal suspend fun updateCurrentMedia(block: (CurrentMediaInfo) -> CurrentMediaInfo) =
|
||||||
subtitleSearch.value = SubtitleSearch.Searching
|
withContext(Dispatchers.IO) {
|
||||||
subtitleSearchLanguage.value = language
|
mutex.withLock {
|
||||||
viewModelScope.launchIO {
|
val newMediaInfo = block.invoke(currentMediaInfo.value!!)
|
||||||
try {
|
currentMediaInfo.setValueOnMain(newMediaInfo)
|
||||||
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
|
|
||||||
*/
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue