Add setting to automatically switch between ExoPlayer & MPV (#736)

## Description
The MPV backend is no longer considered experimental. This PR adds a
setting for player backend "Prefer MPV" which uses MPV for all playback
unless the video is HDR which then uses ExoPlayer.

Switching occurs per video. So if there's a playlist with both SDR &
HDR, the player will switch back and forth as needed.

### Related issues
Closes #253
Related to #235
This commit is contained in:
Ray 2026-01-23 08:50:01 -05:00 committed by GitHub
parent 16ac02a3fd
commit 0639a7a1da
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 892 additions and 648 deletions

View file

@ -746,16 +746,29 @@ sealed interface AppPreference<Pref, T> {
val PlayerBackendPref = val PlayerBackendPref =
AppChoicePreference<AppPreferences, PlayerBackend>( AppChoicePreference<AppPreferences, PlayerBackend>(
title = R.string.player_backend, title = R.string.player_backend,
defaultValue = PlayerBackend.EXO_PLAYER, defaultValue = PlayerBackend.PREFER_MPV,
getter = { it.playbackPreferences.playerBackend }, getter = { it.playbackPreferences.playerBackend },
setter = { prefs, value -> setter = { prefs, value ->
prefs.updatePlaybackPreferences { playerBackend = value } prefs.updatePlaybackPreferences { playerBackend = value }
}, },
displayValues = R.array.player_backend_options, displayValues = R.array.player_backend_options,
subtitles = R.array.player_backend_options_subtitles,
indexToValue = { PlayerBackend.forNumber(it) }, indexToValue = { PlayerBackend.forNumber(it) },
valueToIndex = { it.number }, valueToIndex = { it.number },
) )
val ExoPlayerSettings =
AppDestinationPreference<AppPreferences>(
title = R.string.exoplayer_options,
destination = Destination.Settings(PreferenceScreenOption.EXO_PLAYER),
)
val MpvSettings =
AppDestinationPreference<AppPreferences>(
title = R.string.mpv_options,
destination = Destination.Settings(PreferenceScreenOption.MPV),
)
val MpvHardwareDecoding = val MpvHardwareDecoding =
AppSwitchPreference<AppPreferences>( AppSwitchPreference<AppPreferences>(
title = R.string.mpv_hardware_decoding, title = R.string.mpv_hardware_decoding,
@ -958,6 +971,40 @@ val basicPreferences =
val uiPreferences = listOf<PreferenceGroup>() val uiPreferences = listOf<PreferenceGroup>()
private val ExoPlayerSettings =
listOf(
AppPreference.FfmpegPreference,
AppPreference.DownMixStereo,
AppPreference.Ac3Supported,
AppPreference.DirectPlayAss,
AppPreference.DirectPlayPgs,
AppPreference.DirectPlayDoviProfile7,
AppPreference.DecodeAv1,
)
val ExoPlayerPreferences =
listOf(
PreferenceGroup(
title = R.string.exoplayer_options,
preferences = ExoPlayerSettings,
),
)
private val MpvSettings =
listOf(
AppPreference.MpvHardwareDecoding,
AppPreference.MpvGpuNext,
AppPreference.MpvConfFile,
)
val MpvPreferences =
listOf(
PreferenceGroup(
title = R.string.mpv_options,
preferences = MpvSettings,
),
)
val advancedPreferences = val advancedPreferences =
buildList { buildList {
add( add(
@ -1008,22 +1055,17 @@ val advancedPreferences =
listOf( listOf(
ConditionalPreferences( ConditionalPreferences(
{ it.playbackPreferences.playerBackend == PlayerBackend.EXO_PLAYER }, { it.playbackPreferences.playerBackend == PlayerBackend.EXO_PLAYER },
listOf( ExoPlayerSettings,
AppPreference.FfmpegPreference,
AppPreference.DownMixStereo,
AppPreference.Ac3Supported,
AppPreference.DirectPlayAss,
AppPreference.DirectPlayPgs,
AppPreference.DirectPlayDoviProfile7,
AppPreference.DecodeAv1,
),
), ),
ConditionalPreferences( ConditionalPreferences(
{ it.playbackPreferences.playerBackend == PlayerBackend.MPV }, { it.playbackPreferences.playerBackend == PlayerBackend.MPV },
MpvSettings,
),
ConditionalPreferences(
{ it.playbackPreferences.playerBackend == PlayerBackend.PREFER_MPV },
listOf( listOf(
AppPreference.MpvHardwareDecoding, AppPreference.ExoPlayerSettings,
AppPreference.MpvGpuNext, AppPreference.MpvSettings,
AppPreference.MpvConfFile,
), ),
), ),
), ),
@ -1108,6 +1150,7 @@ data class AppChoicePreference<Pref, T>(
override val getter: (prefs: Pref) -> T, override val getter: (prefs: Pref) -> T,
override val setter: (prefs: Pref, value: T) -> Pref, override val setter: (prefs: Pref, value: T) -> Pref,
@param:StringRes val summary: Int? = null, @param:StringRes val summary: Int? = null,
@param:ArrayRes val subtitles: Int? = null,
) : AppPreference<Pref, T> ) : AppPreference<Pref, T>
data class AppMultiChoicePreference<Pref, T>( data class AppMultiChoicePreference<Pref, T>(

View file

@ -2,21 +2,26 @@ package com.github.damontecres.wholphin.services
import android.content.Context import android.content.Context
import android.os.Build import android.os.Build
import android.widget.Toast
import androidx.core.content.edit import androidx.core.content.edit
import androidx.datastore.core.DataStore import androidx.datastore.core.DataStore
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.WholphinApplication import com.github.damontecres.wholphin.WholphinApplication
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.AppPreferences
import com.github.damontecres.wholphin.preferences.PlayerBackend
import com.github.damontecres.wholphin.preferences.update import com.github.damontecres.wholphin.preferences.update
import com.github.damontecres.wholphin.preferences.updateAdvancedPreferences import com.github.damontecres.wholphin.preferences.updateAdvancedPreferences
import com.github.damontecres.wholphin.preferences.updateInterfacePreferences import com.github.damontecres.wholphin.preferences.updateInterfacePreferences
import com.github.damontecres.wholphin.preferences.updateLiveTvPreferences import com.github.damontecres.wholphin.preferences.updateLiveTvPreferences
import com.github.damontecres.wholphin.preferences.updateMpvOptions import com.github.damontecres.wholphin.preferences.updateMpvOptions
import com.github.damontecres.wholphin.preferences.updatePlaybackOverrides import com.github.damontecres.wholphin.preferences.updatePlaybackOverrides
import com.github.damontecres.wholphin.preferences.updatePlaybackPreferences
import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences
import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.util.Version import com.github.damontecres.wholphin.util.Version
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import timber.log.Timber import timber.log.Timber
@ -197,4 +202,11 @@ suspend fun upgradeApp(
} }
} }
} }
if (previous.isEqualOrBefore(Version.fromString("0.4.0-1-g0"))) {
appPreferences.updateData {
it.updatePlaybackPreferences { playerBackend = PlayerBackend.PREFER_MPV }
}
showToast(context, context.getString(R.string.upgrade_mpv_toast), Toast.LENGTH_LONG)
}
} }

View file

@ -19,11 +19,14 @@ import androidx.media3.exoplayer.video.VideoRendererEventListener
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.AppPreferences
import com.github.damontecres.wholphin.preferences.MediaExtensionStatus import com.github.damontecres.wholphin.preferences.MediaExtensionStatus
import com.github.damontecres.wholphin.preferences.PlaybackPreferences
import com.github.damontecres.wholphin.preferences.PlayerBackend import com.github.damontecres.wholphin.preferences.PlayerBackend
import com.github.damontecres.wholphin.util.mpv.MpvPlayer import com.github.damontecres.wholphin.util.mpv.MpvPlayer
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import timber.log.Timber import timber.log.Timber
import java.lang.reflect.Constructor import java.lang.reflect.Constructor
import javax.inject.Inject import javax.inject.Inject
@ -53,7 +56,9 @@ class PlayerFactory
val backend = prefs?.playerBackend ?: AppPreference.PlayerBackendPref.defaultValue val backend = prefs?.playerBackend ?: AppPreference.PlayerBackendPref.defaultValue
val newPlayer = val newPlayer =
when (backend) { when (backend) {
PlayerBackend.MPV -> { PlayerBackend.PREFER_MPV,
PlayerBackend.MPV,
-> {
val enableHardwareDecoding = val enableHardwareDecoding =
prefs?.mpvOptions?.enableHardwareDecoding prefs?.mpvOptions?.enableHardwareDecoding
?: AppPreference.MpvHardwareDecoding.defaultValue ?: AppPreference.MpvHardwareDecoding.defaultValue
@ -94,6 +99,53 @@ class PlayerFactory
currentPlayer = newPlayer currentPlayer = newPlayer
return newPlayer return newPlayer
} }
suspend fun createVideoPlayer(
backend: PlayerBackend,
prefs: PlaybackPreferences,
): Player {
withContext(Dispatchers.Main) {
if (currentPlayer?.isReleased == false) {
Timber.w("Player was not released before trying to create a new one!")
currentPlayer?.release()
}
}
val newPlayer =
when (backend) {
PlayerBackend.PREFER_MPV,
PlayerBackend.MPV,
-> {
val enableHardwareDecoding = prefs.mpvOptions.enableHardwareDecoding
val useGpuNext = prefs.mpvOptions.useGpuNext
MpvPlayer(context, enableHardwareDecoding, useGpuNext)
}
PlayerBackend.EXO_PLAYER,
PlayerBackend.UNRECOGNIZED,
-> {
val extensions = prefs.overrides.mediaExtensionsEnabled
val decodeAv1 = prefs.overrides.decodeAv1
Timber.v("extensions=$extensions")
val rendererMode =
when (extensions) {
MediaExtensionStatus.MES_FALLBACK -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON
MediaExtensionStatus.MES_PREFERRED -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER
MediaExtensionStatus.MES_DISABLED -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF
else -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON
}
ExoPlayer
.Builder(context)
.setRenderersFactory(
WholphinRenderersFactory(context, decodeAv1)
.setEnableDecoderFallback(true)
.setExtensionRendererMode(rendererMode),
).build()
}
}
currentPlayer = newPlayer
return newPlayer
}
} }
val Player.isReleased: Boolean val Player.isReleased: Boolean

View file

@ -4,12 +4,14 @@ import com.github.damontecres.wholphin.data.model.Chapter
import org.jellyfin.sdk.model.api.TrickplayInfo import org.jellyfin.sdk.model.api.TrickplayInfo
data class CurrentMediaInfo( data class CurrentMediaInfo(
val sourceId: String?,
val videoStream: SimpleVideoStream?,
val audioStreams: List<SimpleMediaStream>, val audioStreams: List<SimpleMediaStream>,
val subtitleStreams: List<SimpleMediaStream>, val subtitleStreams: List<SimpleMediaStream>,
val chapters: List<Chapter>, val chapters: List<Chapter>,
val trickPlayInfo: TrickplayInfo?, val trickPlayInfo: TrickplayInfo?,
) { ) {
companion object { companion object {
val EMPTY = CurrentMediaInfo(listOf(), listOf(), listOf(), null) val EMPTY = CurrentMediaInfo(null, null, listOf(), listOf(), listOf(), null)
} }
} }

View file

@ -50,6 +50,7 @@ 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.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
@ -66,7 +67,6 @@ import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.preferences.skipBackOnResume import com.github.damontecres.wholphin.preferences.skipBackOnResume
import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.LocalImageUrlService import com.github.damontecres.wholphin.ui.LocalImageUrlService
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.components.TextButton import com.github.damontecres.wholphin.ui.components.TextButton
@ -99,16 +99,16 @@ fun PlaybackPage(
preferences: UserPreferences, preferences: UserPreferences,
destination: Destination, destination: Destination,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: PlaybackViewModel = hiltViewModel(), viewModel: PlaybackViewModel =
hiltViewModel<PlaybackViewModel, PlaybackViewModel.Factory>(
creationCallback = { it.create(destination) },
),
) { ) {
LifecycleStartEffect(destination) { LifecycleStartEffect(destination) {
onStopOrDispose { onStopOrDispose {
viewModel.release() viewModel.release()
} }
} }
LaunchedEffect(destination) {
viewModel.init(destination, preferences)
}
val loading by viewModel.loading.observeAsState(LoadingState.Loading) val loading by viewModel.loading.observeAsState(LoadingState.Loading)
when (val st = loading) { when (val st = loading) {
@ -123,467 +123,487 @@ fun PlaybackPage(
} }
LoadingState.Success -> { LoadingState.Success -> {
val prefs = preferences.appPreferences.playbackPreferences val playerState by viewModel.currentPlayer.collectAsState()
val scope = rememberCoroutineScope() PlaybackPageContent(
val configuration = LocalConfiguration.current player = playerState!!.player,
val density = LocalDensity.current playerBackend = playerState!!.backend,
preferences = preferences,
val player = viewModel.player destination = destination,
val mediaInfo by viewModel.currentMediaInfo.observeAsState() viewModel = viewModel,
val userDto by viewModel.currentUserDto.observeAsState() modifier = modifier,
val currentPlayback by viewModel.currentPlayback.collectAsState()
val currentItemPlayback by viewModel.currentItemPlayback.observeAsState(
ItemPlayback(
userId = -1,
itemId = UUID.randomUUID(),
),
) )
val currentSegment by viewModel.currentSegment.observeAsState(null) }
var segmentCancelled by remember(currentSegment?.id) { mutableStateOf(false) } }
}
val cues by viewModel.subtitleCues.observeAsState(listOf()) @OptIn(UnstableApi::class)
var showDebugInfo by remember { mutableStateOf(prefs.showDebugInfo) } @Composable
fun PlaybackPageContent(
player: Player,
playerBackend: PlayerBackend,
preferences: UserPreferences,
destination: Destination,
modifier: Modifier = Modifier,
viewModel: PlaybackViewModel,
) {
val prefs = preferences.appPreferences.playbackPreferences
val scope = rememberCoroutineScope()
val configuration = LocalConfiguration.current
val density = LocalDensity.current
val mediaInfo by viewModel.currentMediaInfo.observeAsState()
val userDto by viewModel.currentUserDto.observeAsState()
val nextUp by viewModel.nextUp.observeAsState(null) val currentPlayback by viewModel.currentPlayback.collectAsState()
val playlist by viewModel.playlist.observeAsState(Playlist(listOf())) val currentItemPlayback by viewModel.currentItemPlayback.observeAsState(
ItemPlayback(
userId = -1,
itemId = UUID.randomUUID(),
),
)
val currentSegment by viewModel.currentSegment.observeAsState(null)
var segmentCancelled by remember(currentSegment?.id) { mutableStateOf(false) }
val subtitleSearch by viewModel.subtitleSearch.observeAsState(null) val cues by viewModel.subtitleCues.observeAsState(listOf())
val subtitleSearchLanguage by viewModel.subtitleSearchLanguage.observeAsState(Locale.current.language) var showDebugInfo by remember { mutableStateOf(prefs.showDebugInfo) }
var playbackDialog by remember { mutableStateOf<PlaybackDialogType?>(null) } val nextUp by viewModel.nextUp.observeAsState(null)
OneTimeLaunchedEffect { val playlist by viewModel.playlist.observeAsState(Playlist(listOf()))
if (prefs.playerBackend == PlayerBackend.MPV) {
scope.launch(Dispatchers.IO + ExceptionHandler()) {
preferences.appPreferences.interfacePreferences.subtitlesPreferences.applyToMpv(
configuration,
density,
)
}
}
}
AmbientPlayerListener(player)
var contentScale by remember {
mutableStateOf(
if (prefs.playerBackend == PlayerBackend.MPV) {
ContentScale.FillBounds
} else {
prefs.globalContentScale.scale
},
)
}
var playbackSpeed by remember { mutableFloatStateOf(1.0f) }
LaunchedEffect(playbackSpeed) { player.setPlaybackSpeed(playbackSpeed) }
val subtitleDelay = currentPlayback?.subtitleDelay ?: Duration.ZERO val subtitleSearch by viewModel.subtitleSearch.observeAsState(null)
LaunchedEffect(subtitleDelay) { val subtitleSearchLanguage by viewModel.subtitleSearchLanguage.observeAsState(Locale.current.language)
(player as? MpvPlayer)?.subtitleDelay = subtitleDelay
}
val presentationState = rememberPresentationState(player, false) var playbackDialog by remember { mutableStateOf<PlaybackDialogType?>(null) }
val scaledModifier = LaunchedEffect(player) {
Modifier.resizeWithContentScale(contentScale, presentationState.videoSizeDp) if (playerBackend == PlayerBackend.MPV) {
val focusRequester = remember { FocusRequester() } scope.launch(Dispatchers.IO + ExceptionHandler()) {
val playPauseState = rememberPlayPauseButtonState(player) preferences.appPreferences.interfacePreferences.subtitlesPreferences.applyToMpv(
val seekBarState = rememberSeekBarState(player, scope) configuration,
density,
LaunchedEffect(Unit) {
focusRequester.tryRequestFocus()
}
val controllerViewState = remember { viewModel.controllerViewState }
var skipIndicatorDuration by remember { mutableLongStateOf(0L) }
LaunchedEffect(controllerViewState.controlsVisible) {
// If controller shows/hides, immediately cancel the skip indicator
skipIndicatorDuration = 0L
}
var skipPosition by remember { mutableLongStateOf(0L) }
val updateSkipIndicator = { delta: Long ->
if ((skipIndicatorDuration > 0 && delta < 0) || (skipIndicatorDuration < 0 && delta > 0)) {
skipIndicatorDuration = 0
}
skipIndicatorDuration += delta
skipPosition = player.currentPosition
}
val keyHandler =
PlaybackKeyHandler(
player = player,
controlsEnabled = nextUp == null,
skipWithLeftRight = true,
seekForward = preferences.appPreferences.playbackPreferences.skipForwardMs.milliseconds,
seekBack = preferences.appPreferences.playbackPreferences.skipBackMs.milliseconds,
controllerViewState = controllerViewState,
updateSkipIndicator = updateSkipIndicator,
skipBackOnResume = preferences.appPreferences.playbackPreferences.skipBackOnResume,
onInteraction = viewModel::reportInteraction,
oneClickPause = preferences.appPreferences.playbackPreferences.oneClickPause,
onStop = {
player.stop()
viewModel.navigationManager.goBack()
},
onPlaybackDialogTypeClick = { playbackDialog = it },
)
val onPlaybackActionClick: (PlaybackAction) -> Unit = {
when (it) {
is PlaybackAction.PlaybackSpeed -> {
playbackSpeed = it.value
}
is PlaybackAction.Scale -> {
contentScale = it.scale
}
PlaybackAction.ShowDebug -> {
showDebugInfo = !showDebugInfo
}
PlaybackAction.ShowPlaylist -> {
TODO()
}
PlaybackAction.ShowVideoFilterDialog -> {
TODO()
}
is PlaybackAction.ToggleAudio -> {
viewModel.changeAudioStream(it.index)
}
is PlaybackAction.ToggleCaptions -> {
viewModel.changeSubtitleStream(it.index)
}
PlaybackAction.SearchCaptions -> {
controllerViewState.hideControls()
viewModel.searchForSubtitles()
}
PlaybackAction.Next -> {
// TODO focus is lost
viewModel.playNextUp()
}
PlaybackAction.Previous -> {
val pos = player.currentPosition
if (pos < player.maxSeekToPreviousPosition && playlist.hasPrevious()) {
viewModel.playPrevious()
} else {
player.seekToPrevious()
}
}
}
}
val showSegment =
!segmentCancelled && currentSegment != null &&
nextUp == null && !controllerViewState.controlsVisible && skipIndicatorDuration == 0L
BackHandler(showSegment) {
segmentCancelled = true
}
Box(
modifier
.background(if (nextUp == null) Color.Black else MaterialTheme.colorScheme.background),
) {
val playerSize by animateFloatAsState(if (nextUp == null) 1f else .6f)
Box(
modifier =
Modifier
.fillMaxSize(playerSize)
.align(Alignment.TopCenter)
.onKeyEvent(keyHandler::onKeyEvent)
.focusRequester(focusRequester)
.focusable(),
) {
PlayerSurface(
player = player,
surfaceType = SURFACE_TYPE_SURFACE_VIEW,
modifier = scaledModifier,
)
if (presentationState.coverSurface) {
Box(
Modifier
.matchParentSize()
.background(Color.Black),
) {
LoadingPage(focusEnabled = false)
}
}
// If D-pad skipping, show the amount skipped in an animation
if (!controllerViewState.controlsVisible && skipIndicatorDuration != 0L) {
SkipIndicator(
durationMs = skipIndicatorDuration,
onFinish = {
skipIndicatorDuration = 0L
},
modifier =
Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 70.dp),
)
// Show a small progress bar along the bottom of the screen
val showSkipProgress = true // TODO get from preferences
if (showSkipProgress) {
val percent = skipPosition.toFloat() / player.duration.toFloat()
Box(
modifier =
Modifier
.align(Alignment.BottomStart)
.background(MaterialTheme.colorScheme.border)
.clip(RectangleShape)
.height(3.dp)
.fillMaxWidth(percent),
)
}
}
// The playback controls
AnimatedVisibility(
controllerViewState.controlsVisible,
Modifier,
slideInVertically { it },
slideOutVertically { it },
) {
PlaybackOverlay(
modifier =
Modifier
.padding(WindowInsets.systemBars.asPaddingValues())
.fillMaxSize()
.background(Color.Transparent),
item = currentPlayback?.item,
playerControls = player,
controllerViewState = controllerViewState,
showPlay = playPauseState.showPlay,
previousEnabled = true,
nextEnabled = playlist.hasNext(),
seekEnabled = true,
seekForward = preferences.appPreferences.playbackPreferences.skipForwardMs.milliseconds,
seekBack = preferences.appPreferences.playbackPreferences.skipBackMs.milliseconds,
skipBackOnResume = preferences.appPreferences.playbackPreferences.skipBackOnResume,
onPlaybackActionClick = onPlaybackActionClick,
onClickPlaybackDialogType = { playbackDialog = it },
onSeekBarChange = seekBarState::onValueChange,
showDebugInfo = showDebugInfo,
currentPlayback = currentPlayback,
chapters = mediaInfo?.chapters ?: listOf(),
trickplayInfo = mediaInfo?.trickPlayInfo,
trickplayUrlFor = viewModel::getTrickplayUrl,
playlist = playlist,
onClickPlaylist = {
viewModel.playItemInPlaylist(it)
},
currentSegment = currentSegment,
showClock = preferences.appPreferences.interfacePreferences.showClock,
)
}
// Subtitles
if (skipIndicatorDuration == 0L && currentItemPlayback.subtitleIndexEnabled) {
val maxSize by animateFloatAsState(if (controllerViewState.controlsVisible) .7f else 1f)
AndroidView(
factory = { context ->
SubtitleView(context).apply {
preferences.appPreferences.interfacePreferences.subtitlesPreferences.let {
setStyle(it.toSubtitleStyle())
setFixedTextSize(Dimension.SP, it.fontSize.toFloat())
setBottomPaddingFraction(it.margin.toFloat() / 100f)
}
}
},
update = {
it.setCues(cues)
Media3SubtitleOverride(
preferences.appPreferences.interfacePreferences.subtitlesPreferences
.calculateEdgeSize(density),
).apply(it)
},
onReset = {
it.setCues(null)
},
modifier =
Modifier
.fillMaxSize(maxSize)
.align(Alignment.TopCenter)
.background(Color.Transparent),
)
}
}
// Ask to skip intros, etc button
AnimatedVisibility(
showSegment,
modifier =
Modifier
.padding(40.dp)
.align(Alignment.BottomEnd),
) {
currentSegment?.let { segment ->
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
focusRequester.tryRequestFocus()
delay(10.seconds)
segmentCancelled = true
}
TextButton(
stringRes = segment.type.skipStringRes,
onClick = {
segmentCancelled = true
player.seekTo(segment.endTicks.ticks.inWholeMilliseconds)
},
modifier = Modifier.focusRequester(focusRequester),
)
}
}
// Next up episode
BackHandler(nextUp != null) {
if (player.isPlaying) {
scope.launch(ExceptionHandler()) {
viewModel.cancelUpNextEpisode()
}
} else {
viewModel.navigationManager.goBack()
}
}
AnimatedVisibility(
nextUp != null,
modifier =
Modifier
.align(Alignment.BottomCenter),
) {
nextUp?.let {
var autoPlayEnabled by remember { mutableStateOf(viewModel.shouldAutoPlayNextUp()) }
var timeLeft by remember {
mutableLongStateOf(
preferences.appPreferences.playbackPreferences.autoPlayNextDelaySeconds,
)
}
BackHandler(timeLeft > 0 && autoPlayEnabled) {
timeLeft = -1
autoPlayEnabled = false
}
if (autoPlayEnabled) {
LaunchedEffect(Unit) {
if (timeLeft == 0L) {
viewModel.playNextUp()
} else {
while (timeLeft > 0) {
delay(1.seconds)
timeLeft--
}
if (timeLeft == 0L && autoPlayEnabled) {
viewModel.playNextUp()
}
}
}
}
NextUpEpisode(
title =
listOfNotNull(
it.data.seasonEpisode,
it.name,
).joinToString(" - "),
description = it.data.overview,
imageUrl = LocalImageUrlService.current.rememberImageUrl(it),
aspectRatio = it.aspectRatio ?: AspectRatios.WIDE,
onClick = {
viewModel.reportInteraction()
controllerViewState.hideControls()
viewModel.playNextUp()
},
timeLeft = if (autoPlayEnabled) timeLeft.seconds else null,
modifier =
Modifier
.padding(8.dp)
// .height(128.dp)
.fillMaxHeight(1 - playerSize)
.fillMaxWidth(.66f)
.align(Alignment.BottomCenter)
.background(
MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp),
shape = RoundedCornerShape(8.dp),
),
)
}
}
}
subtitleSearch?.let { state ->
val wasPlaying = remember { player.isPlaying }
LaunchedEffect(Unit) {
player.pause()
}
val onDismissRequest = {
if (wasPlaying) {
player.play()
}
viewModel.cancelSubtitleSearch()
}
Dialog(
onDismissRequest = onDismissRequest,
properties =
DialogProperties(
usePlatformDefaultWidth = false,
),
) {
DownloadSubtitlesContent(
state = state,
language = subtitleSearchLanguage,
onSearch = { lang ->
viewModel.searchForSubtitles(lang)
},
onClickDownload = {
viewModel.downloadAndSwitchSubtitles(it.id, wasPlaying)
},
onDismissRequest = onDismissRequest,
modifier =
Modifier
.widthIn(max = 640.dp)
.heightIn(max = 400.dp),
)
}
}
playbackDialog?.let { type ->
PlaybackDialog(
type = type,
settings =
PlaybackSettings(
showDebugInfo = showDebugInfo,
audioIndex = currentItemPlayback?.audioIndex,
audioStreams = mediaInfo?.audioStreams.orEmpty(),
subtitleIndex = currentItemPlayback?.subtitleIndex,
subtitleStreams = mediaInfo?.subtitleStreams.orEmpty(),
playbackSpeed = playbackSpeed,
contentScale = contentScale,
subtitleDelay = subtitleDelay,
hasSubtitleDownloadPermission =
remember(userDto) { userDto?.policy?.let { it.isAdministrator || it.enableSubtitleManagement } == true },
),
onDismissRequest = {
playbackDialog = null
if (controllerViewState.controlsVisible) {
controllerViewState.pulseControls()
}
},
onControllerInteraction = {
controllerViewState.pulseControls(Long.MAX_VALUE)
},
onClickPlaybackDialogType = {
if (it == PlaybackDialogType.SUBTITLE_DELAY) {
// Hide controls so subtitles are fully visible
controllerViewState.hideControls()
}
playbackDialog = it
},
onPlaybackActionClick = onPlaybackActionClick,
onChangeSubtitleDelay = { viewModel.updateSubtitleDelay(it) },
enableSubtitleDelay = player is MpvPlayer,
enableVideoScale = player !is MpvPlayer,
) )
} }
} }
} }
AmbientPlayerListener(player)
var contentScale by remember(playerBackend) {
mutableStateOf(
if (playerBackend == PlayerBackend.MPV) {
ContentScale.FillBounds
} else {
prefs.globalContentScale.scale
},
)
}
var playbackSpeed by remember { mutableFloatStateOf(1.0f) }
LaunchedEffect(playbackSpeed) { player.setPlaybackSpeed(playbackSpeed) }
val subtitleDelay = currentPlayback?.subtitleDelay ?: Duration.ZERO
LaunchedEffect(subtitleDelay) {
(player as? MpvPlayer)?.subtitleDelay = subtitleDelay
}
val presentationState = rememberPresentationState(player, false)
val scaledModifier =
Modifier.resizeWithContentScale(contentScale, presentationState.videoSizeDp)
val focusRequester = remember { FocusRequester() }
val playPauseState = rememberPlayPauseButtonState(player)
val seekBarState = rememberSeekBarState(player, scope)
LaunchedEffect(Unit) {
focusRequester.tryRequestFocus()
}
val controllerViewState = remember { viewModel.controllerViewState }
var skipIndicatorDuration by remember { mutableLongStateOf(0L) }
LaunchedEffect(controllerViewState.controlsVisible) {
// If controller shows/hides, immediately cancel the skip indicator
skipIndicatorDuration = 0L
}
var skipPosition by remember { mutableLongStateOf(0L) }
val updateSkipIndicator = { delta: Long ->
if ((skipIndicatorDuration > 0 && delta < 0) || (skipIndicatorDuration < 0 && delta > 0)) {
skipIndicatorDuration = 0
}
skipIndicatorDuration += delta
skipPosition = player.currentPosition
}
val keyHandler =
PlaybackKeyHandler(
player = player,
controlsEnabled = nextUp == null,
skipWithLeftRight = true,
seekForward = preferences.appPreferences.playbackPreferences.skipForwardMs.milliseconds,
seekBack = preferences.appPreferences.playbackPreferences.skipBackMs.milliseconds,
controllerViewState = controllerViewState,
updateSkipIndicator = updateSkipIndicator,
skipBackOnResume = preferences.appPreferences.playbackPreferences.skipBackOnResume,
onInteraction = viewModel::reportInteraction,
oneClickPause = preferences.appPreferences.playbackPreferences.oneClickPause,
onStop = {
player.stop()
viewModel.navigationManager.goBack()
},
onPlaybackDialogTypeClick = { playbackDialog = it },
)
val onPlaybackActionClick: (PlaybackAction) -> Unit = {
when (it) {
is PlaybackAction.PlaybackSpeed -> {
playbackSpeed = it.value
}
is PlaybackAction.Scale -> {
contentScale = it.scale
}
PlaybackAction.ShowDebug -> {
showDebugInfo = !showDebugInfo
}
PlaybackAction.ShowPlaylist -> {
TODO()
}
PlaybackAction.ShowVideoFilterDialog -> {
TODO()
}
is PlaybackAction.ToggleAudio -> {
viewModel.changeAudioStream(it.index)
}
is PlaybackAction.ToggleCaptions -> {
viewModel.changeSubtitleStream(it.index)
}
PlaybackAction.SearchCaptions -> {
controllerViewState.hideControls()
viewModel.searchForSubtitles()
}
PlaybackAction.Next -> {
// TODO focus is lost
viewModel.playNextUp()
}
PlaybackAction.Previous -> {
val pos = player.currentPosition
if (pos < player.maxSeekToPreviousPosition && playlist.hasPrevious()) {
viewModel.playPrevious()
} else {
player.seekToPrevious()
}
}
}
}
val showSegment =
!segmentCancelled && currentSegment != null &&
nextUp == null && !controllerViewState.controlsVisible && skipIndicatorDuration == 0L
BackHandler(showSegment) {
segmentCancelled = true
}
Box(
modifier
.background(if (nextUp == null) Color.Black else MaterialTheme.colorScheme.background),
) {
val playerSize by animateFloatAsState(if (nextUp == null) 1f else .6f)
Box(
modifier =
Modifier
.fillMaxSize(playerSize)
.align(Alignment.TopCenter)
.onKeyEvent(keyHandler::onKeyEvent)
.focusRequester(focusRequester)
.focusable(),
) {
PlayerSurface(
player = player,
surfaceType = SURFACE_TYPE_SURFACE_VIEW,
modifier = scaledModifier,
)
if (presentationState.coverSurface) {
Box(
Modifier
.matchParentSize()
.background(Color.Black),
) {
LoadingPage(focusEnabled = false)
}
}
// If D-pad skipping, show the amount skipped in an animation
if (!controllerViewState.controlsVisible && skipIndicatorDuration != 0L) {
SkipIndicator(
durationMs = skipIndicatorDuration,
onFinish = {
skipIndicatorDuration = 0L
},
modifier =
Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 70.dp),
)
// Show a small progress bar along the bottom of the screen
val showSkipProgress = true // TODO get from preferences
if (showSkipProgress) {
val percent = skipPosition.toFloat() / player.duration.toFloat()
Box(
modifier =
Modifier
.align(Alignment.BottomStart)
.background(MaterialTheme.colorScheme.border)
.clip(RectangleShape)
.height(3.dp)
.fillMaxWidth(percent),
)
}
}
// The playback controls
AnimatedVisibility(
controllerViewState.controlsVisible,
Modifier,
slideInVertically { it },
slideOutVertically { it },
) {
PlaybackOverlay(
modifier =
Modifier
.padding(WindowInsets.systemBars.asPaddingValues())
.fillMaxSize()
.background(Color.Transparent),
item = currentPlayback?.item,
playerControls = player,
controllerViewState = controllerViewState,
showPlay = playPauseState.showPlay,
previousEnabled = true,
nextEnabled = playlist.hasNext(),
seekEnabled = true,
seekForward = preferences.appPreferences.playbackPreferences.skipForwardMs.milliseconds,
seekBack = preferences.appPreferences.playbackPreferences.skipBackMs.milliseconds,
skipBackOnResume = preferences.appPreferences.playbackPreferences.skipBackOnResume,
onPlaybackActionClick = onPlaybackActionClick,
onClickPlaybackDialogType = { playbackDialog = it },
onSeekBarChange = seekBarState::onValueChange,
showDebugInfo = showDebugInfo,
currentPlayback = currentPlayback,
chapters = mediaInfo?.chapters ?: listOf(),
trickplayInfo = mediaInfo?.trickPlayInfo,
trickplayUrlFor = viewModel::getTrickplayUrl,
playlist = playlist,
onClickPlaylist = {
viewModel.playItemInPlaylist(it)
},
currentSegment = currentSegment,
showClock = preferences.appPreferences.interfacePreferences.showClock,
)
}
// Subtitles
if (skipIndicatorDuration == 0L && currentItemPlayback.subtitleIndexEnabled) {
val maxSize by animateFloatAsState(if (controllerViewState.controlsVisible) .7f else 1f)
AndroidView(
factory = { context ->
SubtitleView(context).apply {
preferences.appPreferences.interfacePreferences.subtitlesPreferences.let {
setStyle(it.toSubtitleStyle())
setFixedTextSize(Dimension.SP, it.fontSize.toFloat())
setBottomPaddingFraction(it.margin.toFloat() / 100f)
}
}
},
update = {
it.setCues(cues)
Media3SubtitleOverride(
preferences.appPreferences.interfacePreferences.subtitlesPreferences
.calculateEdgeSize(density),
).apply(it)
},
onReset = {
it.setCues(null)
},
modifier =
Modifier
.fillMaxSize(maxSize)
.align(Alignment.TopCenter)
.background(Color.Transparent),
)
}
}
// Ask to skip intros, etc button
AnimatedVisibility(
showSegment,
modifier =
Modifier
.padding(40.dp)
.align(Alignment.BottomEnd),
) {
currentSegment?.let { segment ->
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
focusRequester.tryRequestFocus()
delay(10.seconds)
segmentCancelled = true
}
TextButton(
stringRes = segment.type.skipStringRes,
onClick = {
segmentCancelled = true
player.seekTo(segment.endTicks.ticks.inWholeMilliseconds)
},
modifier = Modifier.focusRequester(focusRequester),
)
}
}
// Next up episode
BackHandler(nextUp != null) {
if (player.isPlaying) {
scope.launch(ExceptionHandler()) {
viewModel.cancelUpNextEpisode()
}
} else {
viewModel.navigationManager.goBack()
}
}
AnimatedVisibility(
nextUp != null,
modifier =
Modifier
.align(Alignment.BottomCenter),
) {
nextUp?.let {
var autoPlayEnabled by remember { mutableStateOf(viewModel.shouldAutoPlayNextUp()) }
var timeLeft by remember {
mutableLongStateOf(
preferences.appPreferences.playbackPreferences.autoPlayNextDelaySeconds,
)
}
BackHandler(timeLeft > 0 && autoPlayEnabled) {
timeLeft = -1
autoPlayEnabled = false
}
if (autoPlayEnabled) {
LaunchedEffect(Unit) {
if (timeLeft == 0L) {
viewModel.playNextUp()
} else {
while (timeLeft > 0) {
delay(1.seconds)
timeLeft--
}
if (timeLeft == 0L && autoPlayEnabled) {
viewModel.playNextUp()
}
}
}
}
NextUpEpisode(
title =
listOfNotNull(
it.data.seasonEpisode,
it.name,
).joinToString(" - "),
description = it.data.overview,
imageUrl = LocalImageUrlService.current.rememberImageUrl(it),
aspectRatio = it.aspectRatio ?: AspectRatios.WIDE,
onClick = {
viewModel.reportInteraction()
controllerViewState.hideControls()
viewModel.playNextUp()
},
timeLeft = if (autoPlayEnabled) timeLeft.seconds else null,
modifier =
Modifier
.padding(8.dp)
// .height(128.dp)
.fillMaxHeight(1 - playerSize)
.fillMaxWidth(.66f)
.align(Alignment.BottomCenter)
.background(
MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp),
shape = RoundedCornerShape(8.dp),
),
)
}
}
}
subtitleSearch?.let { state ->
val wasPlaying = remember { player.isPlaying }
LaunchedEffect(Unit) {
player.pause()
}
val onDismissRequest = {
if (wasPlaying) {
player.play()
}
viewModel.cancelSubtitleSearch()
}
Dialog(
onDismissRequest = onDismissRequest,
properties =
DialogProperties(
usePlatformDefaultWidth = false,
),
) {
DownloadSubtitlesContent(
state = state,
language = subtitleSearchLanguage,
onSearch = { lang ->
viewModel.searchForSubtitles(lang)
},
onClickDownload = {
viewModel.downloadAndSwitchSubtitles(it.id, wasPlaying)
},
onDismissRequest = onDismissRequest,
modifier =
Modifier
.widthIn(max = 640.dp)
.heightIn(max = 400.dp),
)
}
}
playbackDialog?.let { type ->
PlaybackDialog(
type = type,
settings =
PlaybackSettings(
showDebugInfo = showDebugInfo,
audioIndex = currentItemPlayback?.audioIndex,
audioStreams = mediaInfo?.audioStreams.orEmpty(),
subtitleIndex = currentItemPlayback?.subtitleIndex,
subtitleStreams = mediaInfo?.subtitleStreams.orEmpty(),
playbackSpeed = playbackSpeed,
contentScale = contentScale,
subtitleDelay = subtitleDelay,
hasSubtitleDownloadPermission =
remember(userDto) { userDto?.policy?.let { it.isAdministrator || it.enableSubtitleManagement } == true },
),
onDismissRequest = {
playbackDialog = null
if (controllerViewState.controlsVisible) {
controllerViewState.pulseControls()
}
},
onControllerInteraction = {
controllerViewState.pulseControls(Long.MAX_VALUE)
},
onClickPlaybackDialogType = {
if (it == PlaybackDialogType.SUBTITLE_DELAY) {
// Hide controls so subtitles are fully visible
controllerViewState.hideControls()
}
playbackDialog = it
},
onPlaybackActionClick = onPlaybackActionClick,
onChangeSubtitleDelay = { viewModel.updateSubtitleDelay(it) },
enableSubtitleDelay = player is MpvPlayer,
enableVideoScale = player !is MpvPlayer,
)
}
} }

View file

@ -47,6 +47,7 @@ import com.github.damontecres.wholphin.services.PlaylistCreationResult
import com.github.damontecres.wholphin.services.PlaylistCreator import com.github.damontecres.wholphin.services.PlaylistCreator
import com.github.damontecres.wholphin.services.RefreshRateService import com.github.damontecres.wholphin.services.RefreshRateService
import com.github.damontecres.wholphin.services.StreamChoiceService import com.github.damontecres.wholphin.services.StreamChoiceService
import com.github.damontecres.wholphin.services.UserPreferencesService
import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
@ -58,7 +59,6 @@ import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.util.EqualityMutableLiveData import com.github.damontecres.wholphin.util.EqualityMutableLiveData
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.TrackActivityPlaybackListener import com.github.damontecres.wholphin.util.TrackActivityPlaybackListener
import com.github.damontecres.wholphin.util.checkForSupport import com.github.damontecres.wholphin.util.checkForSupport
@ -66,6 +66,9 @@ import com.github.damontecres.wholphin.util.mpv.mpvDeviceProfile
import com.github.damontecres.wholphin.util.profile.Codec import com.github.damontecres.wholphin.util.profile.Codec
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.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
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.CancellationException import kotlinx.coroutines.CancellationException
@ -100,13 +103,14 @@ 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.TrickplayInfo import org.jellyfin.sdk.model.api.TrickplayInfo
import org.jellyfin.sdk.model.api.VideoRange
import org.jellyfin.sdk.model.api.VideoRangeType
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
import timber.log.Timber 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 kotlin.time.Duration import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@ -114,10 +118,10 @@ import kotlin.time.Duration.Companion.seconds
/** /**
* This [ViewModel] is responsible for playing media including moving through playlists (including next up episodes) * This [ViewModel] is responsible for playing media including moving through playlists (including next up episodes)
*/ */
@HiltViewModel @HiltViewModel(assistedFactory = PlaybackViewModel.Factory::class)
@OptIn(markerClass = [UnstableApi::class]) @OptIn(markerClass = [UnstableApi::class])
class PlaybackViewModel class PlaybackViewModel
@Inject @AssistedInject
constructor( constructor(
@param:ApplicationContext internal val context: Context, @param:ApplicationContext internal val context: Context,
internal val api: ApiClient, internal val api: ApiClient,
@ -132,12 +136,20 @@ class PlaybackViewModel
private val deviceProfileService: DeviceProfileService, private val deviceProfileService: DeviceProfileService,
private val refreshRateService: RefreshRateService, private val refreshRateService: RefreshRateService,
val streamChoiceService: StreamChoiceService, val streamChoiceService: StreamChoiceService,
private val userPreferencesService: UserPreferencesService,
@Assisted private val destination: Destination,
) : ViewModel(), ) : ViewModel(),
Player.Listener, Player.Listener,
AnalyticsListener { AnalyticsListener {
val player by lazy { @AssistedFactory
playerFactory.createVideoPlayer() interface Factory {
fun create(destination: Destination): PlaybackViewModel
} }
val currentPlayer = MutableStateFlow<PlayerState?>(null)
internal lateinit var player: Player
private var mediaSession: MediaSession? = null private var mediaSession: MediaSession? = null
internal val mutex = Mutex() internal val mutex = Mutex()
@ -174,7 +186,14 @@ class PlaybackViewModel
val currentUserDto = serverRepository.currentUserDto val currentUserDto = serverRepository.currentUserDto
init { init {
addCloseable { viewModelScope.launchIO {
addCloseable { disconnectPlayer() }
init()
}
}
private fun disconnectPlayer() {
if (this@PlaybackViewModel::player.isInitialized) {
player.removeListener(this@PlaybackViewModel) player.removeListener(this@PlaybackViewModel)
(player as? ExoPlayer)?.removeAnalyticsListener(this@PlaybackViewModel) (player as? ExoPlayer)?.removeAnalyticsListener(this@PlaybackViewModel)
@ -182,26 +201,62 @@ class PlaybackViewModel
it.release() it.release()
player.removeListener(it) player.removeListener(it)
} }
jobs.forEach { it.cancel() }
player.release() player.release()
mediaSession?.release() mediaSession?.release()
} }
viewModelScope.launch(ExceptionHandler()) { controllerViewState.observe() } jobs.forEach { it.cancel() }
}
private suspend fun createPlayer(isHdr: Boolean) {
val playerBackend =
when (preferences.appPreferences.playbackPreferences.playerBackend) {
PlayerBackend.UNRECOGNIZED,
PlayerBackend.EXO_PLAYER,
-> PlayerBackend.EXO_PLAYER
PlayerBackend.MPV -> PlayerBackend.MPV
PlayerBackend.PREFER_MPV -> if (isHdr) PlayerBackend.EXO_PLAYER else PlayerBackend.MPV
}
Timber.i("Selected backend: %s", playerBackend)
withContext(Dispatchers.Main) {
disconnectPlayer()
}
player =
playerFactory.createVideoPlayer(
playerBackend,
preferences.appPreferences.playbackPreferences,
)
currentPlayer.update {
PlayerState(player, playerBackend)
}
}
private fun configurePlayer() {
player.addListener(this) player.addListener(this)
(player as? ExoPlayer)?.addAnalyticsListener(this) (player as? ExoPlayer)?.addAnalyticsListener(this)
jobs.add(subscribe()) jobs.add(subscribe())
jobs.add(listenForTranscodeReason()) jobs.add(listenForTranscodeReason())
val sessionPlayer =
MediaSessionPlayer(
player,
controllerViewState,
preferences.appPreferences.playbackPreferences,
)
mediaSession =
MediaSession
.Builder(context, sessionPlayer)
.build()
} }
/** /**
* Initialize from the UI to start playback * Initialize from the UI to start playback
*/ */
fun init( private suspend fun init() {
destination: Destination, nextUp.setValueOnMain(null)
preferences: UserPreferences, this.preferences = userPreferencesService.getCurrent()
) {
nextUp.value = null
this.preferences = preferences
if (preferences.appPreferences.playbackPreferences.refreshRateSwitching) { if (preferences.appPreferences.playbackPreferences.refreshRateSwitching) {
addCloseable { refreshRateService.resetRefreshRate() } addCloseable { refreshRateService.resetRefreshRate() }
} }
@ -234,82 +289,64 @@ class PlaybackViewModel
} }
} }
this.itemId = itemId this.itemId = itemId
viewModelScope.launch( val queriedItem = api.userLibraryApi.getItem(itemId).content
Dispatchers.IO + val base =
LoadingExceptionHandler( if (queriedItem.type.playable) {
loading, queriedItem
"Error preparing for playback for $itemId", } else if (destination is Destination.PlaybackList) {
), isPlaylist = true
) { val playlistResult =
val queriedItem = api.userLibraryApi.getItem(itemId).content playlistCreator.createFrom(
val base = item = queriedItem,
if (queriedItem.type.playable) { startIndex = destination.startIndex ?: 0,
queriedItem sortAndDirection = destination.sortAndDirection,
} else if (destination is Destination.PlaybackList) { shuffled = destination.shuffle,
isPlaylist = true recursive = destination.recursive,
val playlistResult = filter = destination.filter,
playlistCreator.createFrom( )
item = queriedItem, when (val r = playlistResult) {
startIndex = destination.startIndex ?: 0, is PlaylistCreationResult.Error -> {
sortAndDirection = destination.sortAndDirection, loading.setValueOnMain(LoadingState.Error(r.message, r.ex))
shuffled = destination.shuffle, return
recursive = destination.recursive, }
filter = destination.filter,
) is PlaylistCreationResult.Success -> {
when (val r = playlistResult) { if (r.playlist.items.isEmpty()) {
is PlaylistCreationResult.Error -> { showToast(context, "Playlist is empty", Toast.LENGTH_SHORT)
loading.setValueOnMain(LoadingState.Error(r.message, r.ex)) navigationManager.goBack()
return@launch return
} }
withContext(Dispatchers.Main) {
is PlaylistCreationResult.Success -> { this@PlaybackViewModel.playlist.value = r.playlist
if (r.playlist.items.isEmpty()) { }
showToast(context, "Playlist is empty", Toast.LENGTH_SHORT) r.playlist.items
navigationManager.goBack() .first()
return@launch .data
}
withContext(Dispatchers.Main) {
this@PlaybackViewModel.playlist.value = r.playlist
}
r.playlist.items
.first()
.data
}
} }
} else {
throw IllegalArgumentException("Item is not playable and not PlaybackList: ${queriedItem.type}")
} }
} else {
val sessionPlayer = throw IllegalArgumentException("Item is not playable and not PlaybackList: ${queriedItem.type}")
MediaSessionPlayer(
player,
controllerViewState,
preferences.appPreferences.playbackPreferences,
)
mediaSession =
MediaSession
.Builder(context, sessionPlayer)
.build()
val item = BaseItem.from(base, api)
val played =
play(
item,
positionMs,
itemPlayback,
forceTranscoding,
)
if (!played) {
playNextUp()
} }
if (!isPlaylist) { viewModelScope.launch(ExceptionHandler()) { controllerViewState.observe() }
val result = playlistCreator.createFrom(queriedItem)
if (result is PlaylistCreationResult.Success && result.playlist.items.isNotEmpty()) { val item = BaseItem.from(base, api)
withContext(Dispatchers.Main) { val played =
this@PlaybackViewModel.playlist.value = result.playlist play(
} item,
positionMs,
itemPlayback,
forceTranscoding,
)
if (!played) {
playNextUp()
}
if (!isPlaylist) {
val result = playlistCreator.createFrom(queriedItem)
if (result is PlaylistCreationResult.Success && result.playlist.items.isNotEmpty()) {
withContext(Dispatchers.Main) {
this@PlaybackViewModel.playlist.value = result.playlist
} }
} }
} }
@ -380,6 +417,20 @@ class PlaybackViewModel
return@withContext false return@withContext false
} }
val videoStream =
mediaSource.mediaStreams
?.firstOrNull { it.type == MediaStreamType.VIDEO }
?.let {
val isHdr =
it.videoRange == VideoRange.HDR ||
(it.videoRangeType != VideoRangeType.SDR && it.videoRangeType != VideoRangeType.UNKNOWN)
SimpleVideoStream(it.index, isHdr)
}
// Create the correct player for the media
createPlayer(videoStream?.hdr == true)
configurePlayer()
val subtitleStreams = val subtitleStreams =
mediaSource.mediaStreams mediaSource.mediaStreams
?.filter { it.type == MediaStreamType.SUBTITLE } ?.filter { it.type == MediaStreamType.SUBTITLE }
@ -450,6 +501,8 @@ class PlaybackViewModel
this@PlaybackViewModel.currentItemPlayback.value = itemPlaybackToUse this@PlaybackViewModel.currentItemPlayback.value = itemPlaybackToUse
updateCurrentMedia { updateCurrentMedia {
CurrentMediaInfo( CurrentMediaInfo(
sourceId = mediaSource.id,
videoStream = videoStream,
audioStreams = audioStreams, audioStreams = audioStreams,
subtitleStreams = subtitleStreams, subtitleStreams = subtitleStreams,
chapters = chapters, chapters = chapters,
@ -489,71 +542,18 @@ class PlaybackViewModel
enableDirectStream: Boolean = !this.forceTranscoding, enableDirectStream: Boolean = !this.forceTranscoding,
) = withContext(Dispatchers.IO) { ) = withContext(Dispatchers.IO) {
val itemId = item.id val itemId = item.id
val playerBackend = preferences.appPreferences.playbackPreferences.playerBackend
val currentPlayback = this@PlaybackViewModel.currentPlayback.value val currentPlayback = this@PlaybackViewModel.currentPlayback.value
if (currentPlayback != null && currentPlayback.item.id == item.id && currentPlayback.playMethod == PlayMethod.DIRECT_PLAY) { if (currentPlayback != null && currentPlayback.item.id == item.id && currentPlayback.playMethod == PlayMethod.DIRECT_PLAY) {
// If direct playing, can try to switch tracks without playback restarting val wasSuccessful =
// Except for external subtitles changeStreamsDirectPlay(
// TODO there's probably no reason why we can't add external subtitles? currentPlayback = currentPlayback,
Timber.v("changeStreams direct play") currentItemPlayback = currentItemPlayback,
audioIndex = audioIndex,
val source = currentPlayback.mediaSourceInfo subtitleIndex = subtitleIndex,
val externalSubtitle = source.findExternalSubtitle(subtitleIndex) userInitiated = userInitiated,
)
if (externalSubtitle == null) { if (wasSuccessful) return@withContext
val result =
withContext(Dispatchers.Main) {
TrackSelectionUtils.createTrackSelections(
onMain { player.trackSelectionParameters },
onMain { player.currentTracks },
playerBackend,
true,
audioIndex,
subtitleIndex,
source,
)
}
if (result.bothSelected) {
onMain { player.trackSelectionParameters = result.trackSelectionParameters }
// TODO lots of duplicate code in this block
Timber.d("Changes tracks audio=$audioIndex, subtitle=$subtitleIndex")
val itemPlayback =
currentItemPlayback.copy(
sourceId = source.id?.toUUIDOrNull(),
audioIndex = audioIndex ?: TrackIndex.UNSPECIFIED,
// Preserve special constants (ONLY_FORCED, DISABLED) instead of resolved index
subtitleIndex =
if (currentItemPlayback.subtitleIndex < 0) {
currentItemPlayback.subtitleIndex
} else {
subtitleIndex ?: TrackIndex.DISABLED
},
)
if (userInitiated) {
viewModelScope.launchIO {
Timber.v("Saving user initiated item playback: %s", itemPlayback)
val updated = itemPlaybackRepository.saveItemPlayback(itemPlayback)
withContext(Dispatchers.Main) {
this@PlaybackViewModel.currentItemPlayback.value = updated
}
}
}
withContext(Dispatchers.Main) {
this@PlaybackViewModel.currentPlayback.update {
(it ?: currentPlayback).copy(
tracks = checkForSupport(player.currentTracks),
)
}
this@PlaybackViewModel.currentItemPlayback.value = itemPlayback
}
loadSubtitleDelay()
return@withContext
}
} else {
Timber.v("changeStreams direct play, external subtitle was requested")
}
} }
Timber.d( Timber.d(
@ -571,7 +571,7 @@ class PlaybackViewModel
PlaybackInfoDto( PlaybackInfoDto(
startTimeTicks = null, startTimeTicks = null,
deviceProfile = deviceProfile =
if (playerBackend == PlayerBackend.EXO_PLAYER) { if (currentPlayer.value!!.backend == PlayerBackend.EXO_PLAYER) {
deviceProfileService.getOrCreateDeviceProfile( deviceProfileService.getOrCreateDeviceProfile(
preferences.appPreferences.playbackPreferences, preferences.appPreferences.playbackPreferences,
serverRepository.currentServer.value?.serverVersion, serverRepository.currentServer.value?.serverVersion,
@ -676,7 +676,7 @@ class PlaybackViewModel
CurrentPlayback( CurrentPlayback(
item = item, item = item,
tracks = listOf(), tracks = listOf(),
backend = preferences.appPreferences.playbackPreferences.playerBackend, backend = currentPlayer.value!!.backend,
playMethod = transcodeType, playMethod = transcodeType,
playSessionId = response.playSessionId, playSessionId = response.playSessionId,
liveStreamId = source.liveStreamId, liveStreamId = source.liveStreamId,
@ -727,7 +727,7 @@ class PlaybackViewModel
TrackSelectionUtils.createTrackSelections( TrackSelectionUtils.createTrackSelections(
player.trackSelectionParameters, player.trackSelectionParameters,
player.currentTracks, player.currentTracks,
playerBackend, currentPlayer.value!!.backend,
source.supportsDirectPlay, source.supportsDirectPlay,
audioIndex.takeIf { transcodeType == PlayMethod.DIRECT_PLAY }, audioIndex.takeIf { transcodeType == PlayMethod.DIRECT_PLAY },
subtitleIndex, subtitleIndex,
@ -748,6 +748,81 @@ class PlaybackViewModel
} }
} }
/**
* If direct playing, can try to switch tracks without playback restarting
* Except for external subtitles
*/
@OptIn(UnstableApi::class)
private suspend fun changeStreamsDirectPlay(
currentPlayback: CurrentPlayback,
currentItemPlayback: ItemPlayback,
audioIndex: Int?,
subtitleIndex: Int?,
userInitiated: Boolean,
): Boolean =
withContext(Dispatchers.IO) {
// TODO there's probably no reason why we can't add external subtitles?
Timber.v("changeStreams direct play")
val source = currentPlayback.mediaSourceInfo
val externalSubtitle = source.findExternalSubtitle(subtitleIndex)
if (externalSubtitle == null) {
val result =
withContext(Dispatchers.Main) {
TrackSelectionUtils.createTrackSelections(
onMain { player.trackSelectionParameters },
onMain { player.currentTracks },
currentPlayer.value!!.backend,
true,
audioIndex,
subtitleIndex,
source,
)
}
if (result.bothSelected) {
onMain { player.trackSelectionParameters = result.trackSelectionParameters }
// TODO lots of duplicate code in this block
Timber.d("Changes tracks audio=$audioIndex, subtitle=$subtitleIndex")
val itemPlayback =
currentItemPlayback.copy(
sourceId = source.id?.toUUIDOrNull(),
audioIndex = audioIndex ?: TrackIndex.UNSPECIFIED,
// Preserve special constants (ONLY_FORCED, DISABLED) instead of resolved index
subtitleIndex =
if (currentItemPlayback.subtitleIndex < 0) {
currentItemPlayback.subtitleIndex
} else {
subtitleIndex ?: TrackIndex.DISABLED
},
)
if (userInitiated) {
viewModelScope.launchIO {
Timber.v("Saving user initiated item playback: %s", itemPlayback)
val updated = itemPlaybackRepository.saveItemPlayback(itemPlayback)
withContext(Dispatchers.Main) {
this@PlaybackViewModel.currentItemPlayback.value = updated
}
}
}
withContext(Dispatchers.Main) {
this@PlaybackViewModel.currentPlayback.update {
(it ?: currentPlayback).copy(
tracks = checkForSupport(player.currentTracks),
)
}
this@PlaybackViewModel.currentItemPlayback.value = itemPlayback
}
loadSubtitleDelay()
return@withContext true
}
} else {
Timber.v("changeStreams direct play, external subtitle was requested")
}
return@withContext false
}
fun changeAudioStream(index: Int) { fun changeAudioStream(index: Int) {
viewModelScope.launchIO { viewModelScope.launchIO {
Timber.d("Changing audio track to %s", index) Timber.d("Changing audio track to %s", index)
@ -1114,9 +1189,7 @@ class PlaybackViewModel
fun release() { fun release() {
Timber.v("release") Timber.v("release")
activityListener?.release() disconnectPlayer()
player.release()
mediaSession?.release()
activityListener = null activityListener = null
} }
@ -1292,3 +1365,8 @@ class PlaybackViewModel
} }
} }
} }
data class PlayerState(
val player: Player,
val backend: PlayerBackend,
)

View file

@ -23,3 +23,8 @@ data class SimpleMediaStream(
) )
} }
} }
data class SimpleVideoStream(
val index: Int,
val hdr: Boolean,
)

View file

@ -164,7 +164,9 @@ object TrackSelectionUtils {
} }
// TODO MPV could use literal indexes because they are stored in the track format ID // TODO MPV could use literal indexes because they are stored in the track format ID
PlayerBackend.MPV -> { PlayerBackend.PREFER_MPV,
PlayerBackend.MPV,
-> {
when (type) { when (type) {
MediaStreamType.VIDEO -> { MediaStreamType.VIDEO -> {
serverIndex - externalSubtitleCount + 1 serverIndex - externalSubtitleCount + 1

View file

@ -23,6 +23,7 @@ import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.tv.material3.Icon
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation import androidx.tv.material3.surfaceColorAtElevation
@ -37,6 +38,7 @@ import com.github.damontecres.wholphin.preferences.AppSwitchPreference
import com.github.damontecres.wholphin.ui.components.DialogItem import com.github.damontecres.wholphin.ui.components.DialogItem
import com.github.damontecres.wholphin.ui.components.DialogParams import com.github.damontecres.wholphin.ui.components.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -171,6 +173,8 @@ fun <T> ComposablePreference(
is AppChoicePreference -> { is AppChoicePreference -> {
val values = stringArrayResource(preference.displayValues).toList() val values = stringArrayResource(preference.displayValues).toList()
val subtitles =
preference.subtitles?.let { stringArrayResource(preference.subtitles).toList() }
val summary = val summary =
preference.summary?.let { stringResource(it) } preference.summary?.let { stringResource(it) }
?: preference.summary(context, value) ?: preference.summary(context, value)
@ -188,24 +192,31 @@ fun <T> ComposablePreference(
fromLongClick = false, fromLongClick = false,
items = items =
values.mapIndexed { index, it -> values.mapIndexed { index, it ->
if (index == selectedIndex) { DialogItem(
DialogItem( headlineContent = {
text = it, Text(it)
icon = Icons.Default.Done, },
onClick = { leadingContent = {
onValueChange(preference.indexToValue(index)) if (index == selectedIndex) {
dialogParams = null Icon(
}, imageVector = Icons.Default.Done,
) contentDescription = "selected",
} else { )
DialogItem( }
text = it, },
onClick = { supportingContent = {
onValueChange(preference.indexToValue(index)) subtitles?.let {
dialogParams = null val text = subtitles[index]
}, if (text.isNotNullOrBlank()) {
) Text(text)
} }
}
},
onClick = {
onValueChange(preference.indexToValue(index))
dialogParams = null
},
)
}, },
) )
}, },

View file

@ -36,6 +36,8 @@ enum class PreferenceScreenOption {
ADVANCED, ADVANCED,
USER_INTERFACE, USER_INTERFACE,
SUBTITLES, SUBTITLES,
EXO_PLAYER,
MPV,
; ;
companion object { companion object {

View file

@ -46,6 +46,8 @@ import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.SeerrAuthMethod import com.github.damontecres.wholphin.data.model.SeerrAuthMethod
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.AppPreferences
import com.github.damontecres.wholphin.preferences.ExoPlayerPreferences
import com.github.damontecres.wholphin.preferences.MpvPreferences
import com.github.damontecres.wholphin.preferences.PlayerBackend import com.github.damontecres.wholphin.preferences.PlayerBackend
import com.github.damontecres.wholphin.preferences.advancedPreferences import com.github.damontecres.wholphin.preferences.advancedPreferences
import com.github.damontecres.wholphin.preferences.basicPreferences import com.github.damontecres.wholphin.preferences.basicPreferences
@ -125,6 +127,8 @@ fun PreferencesContent(
PreferenceScreenOption.ADVANCED -> advancedPreferences PreferenceScreenOption.ADVANCED -> advancedPreferences
PreferenceScreenOption.USER_INTERFACE -> uiPreferences PreferenceScreenOption.USER_INTERFACE -> uiPreferences
PreferenceScreenOption.SUBTITLES -> SubtitleSettings.preferences PreferenceScreenOption.SUBTITLES -> SubtitleSettings.preferences
PreferenceScreenOption.EXO_PLAYER -> ExoPlayerPreferences
PreferenceScreenOption.MPV -> MpvPreferences
} }
val screenTitle = val screenTitle =
when (preferenceScreenOption) { when (preferenceScreenOption) {
@ -132,6 +136,8 @@ fun PreferencesContent(
PreferenceScreenOption.ADVANCED -> R.string.advanced_settings PreferenceScreenOption.ADVANCED -> R.string.advanced_settings
PreferenceScreenOption.USER_INTERFACE -> R.string.ui_interface PreferenceScreenOption.USER_INTERFACE -> R.string.ui_interface
PreferenceScreenOption.SUBTITLES -> R.string.subtitle_style PreferenceScreenOption.SUBTITLES -> R.string.subtitle_style
PreferenceScreenOption.EXO_PLAYER -> R.string.exoplayer_options
PreferenceScreenOption.MPV -> R.string.mpv_options
} }
var visible by remember { mutableStateOf(false) } var visible by remember { mutableStateOf(false) }
@ -527,6 +533,8 @@ fun PreferencesPage(
PreferenceScreenOption.BASIC, PreferenceScreenOption.BASIC,
PreferenceScreenOption.ADVANCED, PreferenceScreenOption.ADVANCED,
PreferenceScreenOption.USER_INTERFACE, PreferenceScreenOption.USER_INTERFACE,
PreferenceScreenOption.EXO_PLAYER,
PreferenceScreenOption.MPV,
-> { -> {
PreferencesContent( PreferencesContent(
initialPreferences, initialPreferences,

View file

@ -32,6 +32,7 @@ enum MediaExtensionStatus{
enum PlayerBackend{ enum PlayerBackend{
EXO_PLAYER = 0; EXO_PLAYER = 0;
MPV = 1; MPV = 1;
PREFER_MPV = 2;
} }
message MpvOptions{ message MpvOptions{

View file

@ -461,6 +461,7 @@
<string name="upcoming_tv">Upcoming TV Shows</string> <string name="upcoming_tv">Upcoming TV Shows</string>
<string name="request_4k">Request in 4K</string> <string name="request_4k">Request in 4K</string>
<string name="software_decoding_av1">AV1 software decoding</string> <string name="software_decoding_av1">AV1 software decoding</string>
<string name="upgrade_mpv_toast">MPV is now the default player except for HDR.\nYou can change this in settings.</string>
<string-array name="theme_song_volume"> <string-array name="theme_song_volume">
<item>Disabled</item> <item>Disabled</item>
@ -532,8 +533,15 @@
</string-array> </string-array>
<string-array name="player_backend_options"> <string-array name="player_backend_options">
<item>ExoPlayer (default)</item> <item>ExoPlayer</item>
<item>MPV (Experimental)</item> <item>MPV</item>
<item>Prefer MPV</item>
</string-array>
<string-array name="player_backend_options_subtitles">
<item />
<item />
<item>Use ExoPlayer for HDR playback</item>
</string-array> </string-array>
<string-array name="aspect_ratios"> <string-array name="aspect_ratios">