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 =
AppChoicePreference<AppPreferences, PlayerBackend>(
title = R.string.player_backend,
defaultValue = PlayerBackend.EXO_PLAYER,
defaultValue = PlayerBackend.PREFER_MPV,
getter = { it.playbackPreferences.playerBackend },
setter = { prefs, value ->
prefs.updatePlaybackPreferences { playerBackend = value }
},
displayValues = R.array.player_backend_options,
subtitles = R.array.player_backend_options_subtitles,
indexToValue = { PlayerBackend.forNumber(it) },
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 =
AppSwitchPreference<AppPreferences>(
title = R.string.mpv_hardware_decoding,
@ -958,6 +971,40 @@ val basicPreferences =
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 =
buildList {
add(
@ -1008,22 +1055,17 @@ val advancedPreferences =
listOf(
ConditionalPreferences(
{ it.playbackPreferences.playerBackend == PlayerBackend.EXO_PLAYER },
listOf(
AppPreference.FfmpegPreference,
AppPreference.DownMixStereo,
AppPreference.Ac3Supported,
AppPreference.DirectPlayAss,
AppPreference.DirectPlayPgs,
AppPreference.DirectPlayDoviProfile7,
AppPreference.DecodeAv1,
),
ExoPlayerSettings,
),
ConditionalPreferences(
{ it.playbackPreferences.playerBackend == PlayerBackend.MPV },
MpvSettings,
),
ConditionalPreferences(
{ it.playbackPreferences.playerBackend == PlayerBackend.PREFER_MPV },
listOf(
AppPreference.MpvHardwareDecoding,
AppPreference.MpvGpuNext,
AppPreference.MpvConfFile,
AppPreference.ExoPlayerSettings,
AppPreference.MpvSettings,
),
),
),
@ -1108,6 +1150,7 @@ data class AppChoicePreference<Pref, T>(
override val getter: (prefs: Pref) -> T,
override val setter: (prefs: Pref, value: T) -> Pref,
@param:StringRes val summary: Int? = null,
@param:ArrayRes val subtitles: Int? = null,
) : AppPreference<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.os.Build
import android.widget.Toast
import androidx.core.content.edit
import androidx.datastore.core.DataStore
import androidx.preference.PreferenceManager
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.WholphinApplication
import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.PlayerBackend
import com.github.damontecres.wholphin.preferences.update
import com.github.damontecres.wholphin.preferences.updateAdvancedPreferences
import com.github.damontecres.wholphin.preferences.updateInterfacePreferences
import com.github.damontecres.wholphin.preferences.updateLiveTvPreferences
import com.github.damontecres.wholphin.preferences.updateMpvOptions
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.ui.preferences.PreferencesViewModel
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.util.Version
import dagger.hilt.android.qualifiers.ApplicationContext
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.AppPreferences
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.util.mpv.MpvPlayer
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.lang.reflect.Constructor
import javax.inject.Inject
@ -53,7 +56,9 @@ class PlayerFactory
val backend = prefs?.playerBackend ?: AppPreference.PlayerBackendPref.defaultValue
val newPlayer =
when (backend) {
PlayerBackend.MPV -> {
PlayerBackend.PREFER_MPV,
PlayerBackend.MPV,
-> {
val enableHardwareDecoding =
prefs?.mpvOptions?.enableHardwareDecoding
?: AppPreference.MpvHardwareDecoding.defaultValue
@ -94,6 +99,53 @@ class PlayerFactory
currentPlayer = 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

View file

@ -4,12 +4,14 @@ import com.github.damontecres.wholphin.data.model.Chapter
import org.jellyfin.sdk.model.api.TrickplayInfo
data class CurrentMediaInfo(
val sourceId: String?,
val videoStream: SimpleVideoStream?,
val audioStreams: List<SimpleMediaStream>,
val subtitleStreams: List<SimpleMediaStream>,
val chapters: List<Chapter>,
val trickPlayInfo: TrickplayInfo?,
) {
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.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.LifecycleStartEffect
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.SubtitleView
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.ui.AspectRatios
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.LoadingPage
import com.github.damontecres.wholphin.ui.components.TextButton
@ -99,16 +99,16 @@ fun PlaybackPage(
preferences: UserPreferences,
destination: Destination,
modifier: Modifier = Modifier,
viewModel: PlaybackViewModel = hiltViewModel(),
viewModel: PlaybackViewModel =
hiltViewModel<PlaybackViewModel, PlaybackViewModel.Factory>(
creationCallback = { it.create(destination) },
),
) {
LifecycleStartEffect(destination) {
onStopOrDispose {
viewModel.release()
}
}
LaunchedEffect(destination) {
viewModel.init(destination, preferences)
}
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
when (val st = loading) {
@ -123,12 +123,33 @@ fun PlaybackPage(
}
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 scope = rememberCoroutineScope()
val configuration = LocalConfiguration.current
val density = LocalDensity.current
val player = viewModel.player
val mediaInfo by viewModel.currentMediaInfo.observeAsState()
val userDto by viewModel.currentUserDto.observeAsState()
@ -152,8 +173,8 @@ fun PlaybackPage(
val subtitleSearchLanguage by viewModel.subtitleSearchLanguage.observeAsState(Locale.current.language)
var playbackDialog by remember { mutableStateOf<PlaybackDialogType?>(null) }
OneTimeLaunchedEffect {
if (prefs.playerBackend == PlayerBackend.MPV) {
LaunchedEffect(player) {
if (playerBackend == PlayerBackend.MPV) {
scope.launch(Dispatchers.IO + ExceptionHandler()) {
preferences.appPreferences.interfacePreferences.subtitlesPreferences.applyToMpv(
configuration,
@ -162,10 +183,11 @@ fun PlaybackPage(
}
}
}
AmbientPlayerListener(player)
var contentScale by remember {
var contentScale by remember(playerBackend) {
mutableStateOf(
if (prefs.playerBackend == PlayerBackend.MPV) {
if (playerBackend == PlayerBackend.MPV) {
ContentScale.FillBounds
} else {
prefs.globalContentScale.scale
@ -585,5 +607,3 @@ fun PlaybackPage(
)
}
}
}
}

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.RefreshRateService
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.launchIO
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.util.EqualityMutableLiveData
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.TrackActivityPlaybackListener
import com.github.damontecres.wholphin.util.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.subtitleMimeTypes
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.qualifiers.ApplicationContext
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.PlaystateMessage
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.ticks
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber
import java.util.Date
import java.util.UUID
import javax.inject.Inject
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
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)
*/
@HiltViewModel
@HiltViewModel(assistedFactory = PlaybackViewModel.Factory::class)
@OptIn(markerClass = [UnstableApi::class])
class PlaybackViewModel
@Inject
@AssistedInject
constructor(
@param:ApplicationContext internal val context: Context,
internal val api: ApiClient,
@ -132,12 +136,20 @@ class PlaybackViewModel
private val deviceProfileService: DeviceProfileService,
private val refreshRateService: RefreshRateService,
val streamChoiceService: StreamChoiceService,
private val userPreferencesService: UserPreferencesService,
@Assisted private val destination: Destination,
) : ViewModel(),
Player.Listener,
AnalyticsListener {
val player by lazy {
playerFactory.createVideoPlayer()
@AssistedFactory
interface Factory {
fun create(destination: Destination): PlaybackViewModel
}
val currentPlayer = MutableStateFlow<PlayerState?>(null)
internal lateinit var player: Player
private var mediaSession: MediaSession? = null
internal val mutex = Mutex()
@ -174,7 +186,14 @@ class PlaybackViewModel
val currentUserDto = serverRepository.currentUserDto
init {
addCloseable {
viewModelScope.launchIO {
addCloseable { disconnectPlayer() }
init()
}
}
private fun disconnectPlayer() {
if (this@PlaybackViewModel::player.isInitialized) {
player.removeListener(this@PlaybackViewModel)
(player as? ExoPlayer)?.removeAnalyticsListener(this@PlaybackViewModel)
@ -182,26 +201,62 @@ class PlaybackViewModel
it.release()
player.removeListener(it)
}
jobs.forEach { it.cancel() }
player.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 as? ExoPlayer)?.addAnalyticsListener(this)
jobs.add(subscribe())
jobs.add(listenForTranscodeReason())
val sessionPlayer =
MediaSessionPlayer(
player,
controllerViewState,
preferences.appPreferences.playbackPreferences,
)
mediaSession =
MediaSession
.Builder(context, sessionPlayer)
.build()
}
/**
* Initialize from the UI to start playback
*/
fun init(
destination: Destination,
preferences: UserPreferences,
) {
nextUp.value = null
this.preferences = preferences
private suspend fun init() {
nextUp.setValueOnMain(null)
this.preferences = userPreferencesService.getCurrent()
if (preferences.appPreferences.playbackPreferences.refreshRateSwitching) {
addCloseable { refreshRateService.resetRefreshRate() }
}
@ -234,13 +289,6 @@ class PlaybackViewModel
}
}
this.itemId = itemId
viewModelScope.launch(
Dispatchers.IO +
LoadingExceptionHandler(
loading,
"Error preparing for playback for $itemId",
),
) {
val queriedItem = api.userLibraryApi.getItem(itemId).content
val base =
if (queriedItem.type.playable) {
@ -259,14 +307,14 @@ class PlaybackViewModel
when (val r = playlistResult) {
is PlaylistCreationResult.Error -> {
loading.setValueOnMain(LoadingState.Error(r.message, r.ex))
return@launch
return
}
is PlaylistCreationResult.Success -> {
if (r.playlist.items.isEmpty()) {
showToast(context, "Playlist is empty", Toast.LENGTH_SHORT)
navigationManager.goBack()
return@launch
return
}
withContext(Dispatchers.Main) {
this@PlaybackViewModel.playlist.value = r.playlist
@ -280,19 +328,9 @@ class PlaybackViewModel
throw IllegalArgumentException("Item is not playable and not PlaybackList: ${queriedItem.type}")
}
val sessionPlayer =
MediaSessionPlayer(
player,
controllerViewState,
preferences.appPreferences.playbackPreferences,
)
mediaSession =
MediaSession
.Builder(context, sessionPlayer)
.build()
viewModelScope.launch(ExceptionHandler()) { controllerViewState.observe() }
val item = BaseItem.from(base, api)
val played =
play(
item,
@ -313,7 +351,6 @@ class PlaybackViewModel
}
}
}
}
/**
* Play an item
@ -380,6 +417,20 @@ class PlaybackViewModel
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 =
mediaSource.mediaStreams
?.filter { it.type == MediaStreamType.SUBTITLE }
@ -450,6 +501,8 @@ class PlaybackViewModel
this@PlaybackViewModel.currentItemPlayback.value = itemPlaybackToUse
updateCurrentMedia {
CurrentMediaInfo(
sourceId = mediaSource.id,
videoStream = videoStream,
audioStreams = audioStreams,
subtitleStreams = subtitleStreams,
chapters = chapters,
@ -489,71 +542,18 @@ class PlaybackViewModel
enableDirectStream: Boolean = !this.forceTranscoding,
) = withContext(Dispatchers.IO) {
val itemId = item.id
val playerBackend = preferences.appPreferences.playbackPreferences.playerBackend
val currentPlayback = this@PlaybackViewModel.currentPlayback.value
if (currentPlayback != null && currentPlayback.item.id == item.id && currentPlayback.playMethod == PlayMethod.DIRECT_PLAY) {
// If direct playing, can try to switch tracks without playback restarting
// Except for external subtitles
// 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 },
playerBackend,
true,
audioIndex,
subtitleIndex,
source,
val wasSuccessful =
changeStreamsDirectPlay(
currentPlayback = currentPlayback,
currentItemPlayback = currentItemPlayback,
audioIndex = audioIndex,
subtitleIndex = subtitleIndex,
userInitiated = userInitiated,
)
}
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")
}
if (wasSuccessful) return@withContext
}
Timber.d(
@ -571,7 +571,7 @@ class PlaybackViewModel
PlaybackInfoDto(
startTimeTicks = null,
deviceProfile =
if (playerBackend == PlayerBackend.EXO_PLAYER) {
if (currentPlayer.value!!.backend == PlayerBackend.EXO_PLAYER) {
deviceProfileService.getOrCreateDeviceProfile(
preferences.appPreferences.playbackPreferences,
serverRepository.currentServer.value?.serverVersion,
@ -676,7 +676,7 @@ class PlaybackViewModel
CurrentPlayback(
item = item,
tracks = listOf(),
backend = preferences.appPreferences.playbackPreferences.playerBackend,
backend = currentPlayer.value!!.backend,
playMethod = transcodeType,
playSessionId = response.playSessionId,
liveStreamId = source.liveStreamId,
@ -727,7 +727,7 @@ class PlaybackViewModel
TrackSelectionUtils.createTrackSelections(
player.trackSelectionParameters,
player.currentTracks,
playerBackend,
currentPlayer.value!!.backend,
source.supportsDirectPlay,
audioIndex.takeIf { transcodeType == PlayMethod.DIRECT_PLAY },
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) {
viewModelScope.launchIO {
Timber.d("Changing audio track to %s", index)
@ -1114,9 +1189,7 @@ class PlaybackViewModel
fun release() {
Timber.v("release")
activityListener?.release()
player.release()
mediaSession?.release()
disconnectPlayer()
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
PlayerBackend.MPV -> {
PlayerBackend.PREFER_MPV,
PlayerBackend.MPV,
-> {
when (type) {
MediaStreamType.VIDEO -> {
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.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.tv.material3.Icon
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
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.DialogParams
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.util.ExceptionHandler
import kotlinx.coroutines.launch
@ -171,6 +173,8 @@ fun <T> ComposablePreference(
is AppChoicePreference -> {
val values = stringArrayResource(preference.displayValues).toList()
val subtitles =
preference.subtitles?.let { stringArrayResource(preference.subtitles).toList() }
val summary =
preference.summary?.let { stringResource(it) }
?: preference.summary(context, value)
@ -188,25 +192,32 @@ fun <T> ComposablePreference(
fromLongClick = false,
items =
values.mapIndexed { index, it ->
DialogItem(
headlineContent = {
Text(it)
},
leadingContent = {
if (index == selectedIndex) {
DialogItem(
text = it,
icon = Icons.Default.Done,
onClick = {
onValueChange(preference.indexToValue(index))
dialogParams = null
},
)
} else {
DialogItem(
text = it,
onClick = {
onValueChange(preference.indexToValue(index))
dialogParams = null
},
Icon(
imageVector = Icons.Default.Done,
contentDescription = "selected",
)
}
},
supportingContent = {
subtitles?.let {
val text = subtitles[index]
if (text.isNotNullOrBlank()) {
Text(text)
}
}
},
onClick = {
onValueChange(preference.indexToValue(index))
dialogParams = null
},
)
},
)
},
interactionSource = interactionSource,

View file

@ -36,6 +36,8 @@ enum class PreferenceScreenOption {
ADVANCED,
USER_INTERFACE,
SUBTITLES,
EXO_PLAYER,
MPV,
;
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.preferences.AppPreference
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.advancedPreferences
import com.github.damontecres.wholphin.preferences.basicPreferences
@ -125,6 +127,8 @@ fun PreferencesContent(
PreferenceScreenOption.ADVANCED -> advancedPreferences
PreferenceScreenOption.USER_INTERFACE -> uiPreferences
PreferenceScreenOption.SUBTITLES -> SubtitleSettings.preferences
PreferenceScreenOption.EXO_PLAYER -> ExoPlayerPreferences
PreferenceScreenOption.MPV -> MpvPreferences
}
val screenTitle =
when (preferenceScreenOption) {
@ -132,6 +136,8 @@ fun PreferencesContent(
PreferenceScreenOption.ADVANCED -> R.string.advanced_settings
PreferenceScreenOption.USER_INTERFACE -> R.string.ui_interface
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) }
@ -527,6 +533,8 @@ fun PreferencesPage(
PreferenceScreenOption.BASIC,
PreferenceScreenOption.ADVANCED,
PreferenceScreenOption.USER_INTERFACE,
PreferenceScreenOption.EXO_PLAYER,
PreferenceScreenOption.MPV,
-> {
PreferencesContent(
initialPreferences,

View file

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

View file

@ -461,6 +461,7 @@
<string name="upcoming_tv">Upcoming TV Shows</string>
<string name="request_4k">Request in 4K</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">
<item>Disabled</item>
@ -532,8 +533,15 @@
</string-array>
<string-array name="player_backend_options">
<item>ExoPlayer (default)</item>
<item>MPV (Experimental)</item>
<item>ExoPlayer</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 name="aspect_ratios">