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,12 +123,33 @@ fun PlaybackPage(
} }
LoadingState.Success -> { LoadingState.Success -> {
val playerState by viewModel.currentPlayer.collectAsState()
PlaybackPageContent(
player = playerState!!.player,
playerBackend = playerState!!.backend,
preferences = preferences,
destination = destination,
viewModel = viewModel,
modifier = modifier,
)
}
}
}
@OptIn(UnstableApi::class)
@Composable
fun PlaybackPageContent(
player: Player,
playerBackend: PlayerBackend,
preferences: UserPreferences,
destination: Destination,
modifier: Modifier = Modifier,
viewModel: PlaybackViewModel,
) {
val prefs = preferences.appPreferences.playbackPreferences val prefs = preferences.appPreferences.playbackPreferences
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val configuration = LocalConfiguration.current val configuration = LocalConfiguration.current
val density = LocalDensity.current val density = LocalDensity.current
val player = viewModel.player
val mediaInfo by viewModel.currentMediaInfo.observeAsState() val mediaInfo by viewModel.currentMediaInfo.observeAsState()
val userDto by viewModel.currentUserDto.observeAsState() val userDto by viewModel.currentUserDto.observeAsState()
@ -152,8 +173,8 @@ fun PlaybackPage(
val subtitleSearchLanguage by viewModel.subtitleSearchLanguage.observeAsState(Locale.current.language) val subtitleSearchLanguage by viewModel.subtitleSearchLanguage.observeAsState(Locale.current.language)
var playbackDialog by remember { mutableStateOf<PlaybackDialogType?>(null) } var playbackDialog by remember { mutableStateOf<PlaybackDialogType?>(null) }
OneTimeLaunchedEffect { LaunchedEffect(player) {
if (prefs.playerBackend == PlayerBackend.MPV) { if (playerBackend == PlayerBackend.MPV) {
scope.launch(Dispatchers.IO + ExceptionHandler()) { scope.launch(Dispatchers.IO + ExceptionHandler()) {
preferences.appPreferences.interfacePreferences.subtitlesPreferences.applyToMpv( preferences.appPreferences.interfacePreferences.subtitlesPreferences.applyToMpv(
configuration, configuration,
@ -162,10 +183,11 @@ fun PlaybackPage(
} }
} }
} }
AmbientPlayerListener(player) AmbientPlayerListener(player)
var contentScale by remember { var contentScale by remember(playerBackend) {
mutableStateOf( mutableStateOf(
if (prefs.playerBackend == PlayerBackend.MPV) { if (playerBackend == PlayerBackend.MPV) {
ContentScale.FillBounds ContentScale.FillBounds
} else { } else {
prefs.globalContentScale.scale prefs.globalContentScale.scale
@ -584,6 +606,4 @@ fun PlaybackPage(
enableVideoScale = 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,13 +289,6 @@ class PlaybackViewModel
} }
} }
this.itemId = itemId this.itemId = itemId
viewModelScope.launch(
Dispatchers.IO +
LoadingExceptionHandler(
loading,
"Error preparing for playback for $itemId",
),
) {
val queriedItem = api.userLibraryApi.getItem(itemId).content val queriedItem = api.userLibraryApi.getItem(itemId).content
val base = val base =
if (queriedItem.type.playable) { if (queriedItem.type.playable) {
@ -259,14 +307,14 @@ class PlaybackViewModel
when (val r = playlistResult) { when (val r = playlistResult) {
is PlaylistCreationResult.Error -> { is PlaylistCreationResult.Error -> {
loading.setValueOnMain(LoadingState.Error(r.message, r.ex)) loading.setValueOnMain(LoadingState.Error(r.message, r.ex))
return@launch return
} }
is PlaylistCreationResult.Success -> { is PlaylistCreationResult.Success -> {
if (r.playlist.items.isEmpty()) { if (r.playlist.items.isEmpty()) {
showToast(context, "Playlist is empty", Toast.LENGTH_SHORT) showToast(context, "Playlist is empty", Toast.LENGTH_SHORT)
navigationManager.goBack() navigationManager.goBack()
return@launch return
} }
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
this@PlaybackViewModel.playlist.value = r.playlist this@PlaybackViewModel.playlist.value = r.playlist
@ -280,19 +328,9 @@ class PlaybackViewModel
throw IllegalArgumentException("Item is not playable and not PlaybackList: ${queriedItem.type}") throw IllegalArgumentException("Item is not playable and not PlaybackList: ${queriedItem.type}")
} }
val sessionPlayer = viewModelScope.launch(ExceptionHandler()) { controllerViewState.observe() }
MediaSessionPlayer(
player,
controllerViewState,
preferences.appPreferences.playbackPreferences,
)
mediaSession =
MediaSession
.Builder(context, sessionPlayer)
.build()
val item = BaseItem.from(base, api) val item = BaseItem.from(base, api)
val played = val played =
play( play(
item, item,
@ -313,7 +351,6 @@ class PlaybackViewModel
} }
} }
} }
}
/** /**
* Play an item * Play an item
@ -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) {
val result =
withContext(Dispatchers.Main) {
TrackSelectionUtils.createTrackSelections(
onMain { player.trackSelectionParameters },
onMain { player.currentTracks },
playerBackend,
true,
audioIndex,
subtitleIndex,
source,
) )
} if (wasSuccessful) return@withContext
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,25 +192,32 @@ fun <T> ComposablePreference(
fromLongClick = false, fromLongClick = false,
items = items =
values.mapIndexed { index, it -> values.mapIndexed { index, it ->
DialogItem(
headlineContent = {
Text(it)
},
leadingContent = {
if (index == selectedIndex) { if (index == selectedIndex) {
DialogItem( Icon(
text = it, imageVector = Icons.Default.Done,
icon = Icons.Default.Done, contentDescription = "selected",
onClick = {
onValueChange(preference.indexToValue(index))
dialogParams = null
},
)
} else {
DialogItem(
text = it,
onClick = {
onValueChange(preference.indexToValue(index))
dialogParams = null
},
) )
} }
}, },
supportingContent = {
subtitles?.let {
val text = subtitles[index]
if (text.isNotNullOrBlank()) {
Text(text)
}
}
},
onClick = {
onValueChange(preference.indexToValue(index))
dialogParams = null
},
)
},
) )
}, },
interactionSource = interactionSource, interactionSource = interactionSource,

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">