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

View file

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

View file

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

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

View file

@ -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 = {

View file

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

View file

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

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
}