mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 23:51:21 +02:00
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:
parent
16ac02a3fd
commit
0639a7a1da
13 changed files with 892 additions and 648 deletions
|
|
@ -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>(
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -23,3 +23,8 @@ data class SimpleMediaStream(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data class SimpleVideoStream(
|
||||||
|
val index: Int,
|
||||||
|
val hdr: Boolean,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,8 @@ enum class PreferenceScreenOption {
|
||||||
ADVANCED,
|
ADVANCED,
|
||||||
USER_INTERFACE,
|
USER_INTERFACE,
|
||||||
SUBTITLES,
|
SUBTITLES,
|
||||||
|
EXO_PLAYER,
|
||||||
|
MPV,
|
||||||
;
|
;
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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{
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue