mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 15:50:16 +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 =
|
||||
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>(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,467 +123,487 @@ fun PlaybackPage(
|
|||
}
|
||||
|
||||
LoadingState.Success -> {
|
||||
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()
|
||||
|
||||
val currentPlayback by viewModel.currentPlayback.collectAsState()
|
||||
val currentItemPlayback by viewModel.currentItemPlayback.observeAsState(
|
||||
ItemPlayback(
|
||||
userId = -1,
|
||||
itemId = UUID.randomUUID(),
|
||||
),
|
||||
val playerState by viewModel.currentPlayer.collectAsState()
|
||||
PlaybackPageContent(
|
||||
player = playerState!!.player,
|
||||
playerBackend = playerState!!.backend,
|
||||
preferences = preferences,
|
||||
destination = destination,
|
||||
viewModel = viewModel,
|
||||
modifier = modifier,
|
||||
)
|
||||
val currentSegment by viewModel.currentSegment.observeAsState(null)
|
||||
var segmentCancelled by remember(currentSegment?.id) { mutableStateOf(false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val cues by viewModel.subtitleCues.observeAsState(listOf())
|
||||
var showDebugInfo by remember { mutableStateOf(prefs.showDebugInfo) }
|
||||
@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 mediaInfo by viewModel.currentMediaInfo.observeAsState()
|
||||
val userDto by viewModel.currentUserDto.observeAsState()
|
||||
|
||||
val nextUp by viewModel.nextUp.observeAsState(null)
|
||||
val playlist by viewModel.playlist.observeAsState(Playlist(listOf()))
|
||||
val currentPlayback by viewModel.currentPlayback.collectAsState()
|
||||
val currentItemPlayback by viewModel.currentItemPlayback.observeAsState(
|
||||
ItemPlayback(
|
||||
userId = -1,
|
||||
itemId = UUID.randomUUID(),
|
||||
),
|
||||
)
|
||||
val currentSegment by viewModel.currentSegment.observeAsState(null)
|
||||
var segmentCancelled by remember(currentSegment?.id) { mutableStateOf(false) }
|
||||
|
||||
val subtitleSearch by viewModel.subtitleSearch.observeAsState(null)
|
||||
val subtitleSearchLanguage by viewModel.subtitleSearchLanguage.observeAsState(Locale.current.language)
|
||||
val cues by viewModel.subtitleCues.observeAsState(listOf())
|
||||
var showDebugInfo by remember { mutableStateOf(prefs.showDebugInfo) }
|
||||
|
||||
var playbackDialog by remember { mutableStateOf<PlaybackDialogType?>(null) }
|
||||
OneTimeLaunchedEffect {
|
||||
if (prefs.playerBackend == PlayerBackend.MPV) {
|
||||
scope.launch(Dispatchers.IO + ExceptionHandler()) {
|
||||
preferences.appPreferences.interfacePreferences.subtitlesPreferences.applyToMpv(
|
||||
configuration,
|
||||
density,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
AmbientPlayerListener(player)
|
||||
var contentScale by remember {
|
||||
mutableStateOf(
|
||||
if (prefs.playerBackend == PlayerBackend.MPV) {
|
||||
ContentScale.FillBounds
|
||||
} else {
|
||||
prefs.globalContentScale.scale
|
||||
},
|
||||
)
|
||||
}
|
||||
var playbackSpeed by remember { mutableFloatStateOf(1.0f) }
|
||||
LaunchedEffect(playbackSpeed) { player.setPlaybackSpeed(playbackSpeed) }
|
||||
val nextUp by viewModel.nextUp.observeAsState(null)
|
||||
val playlist by viewModel.playlist.observeAsState(Playlist(listOf()))
|
||||
|
||||
val subtitleDelay = currentPlayback?.subtitleDelay ?: Duration.ZERO
|
||||
LaunchedEffect(subtitleDelay) {
|
||||
(player as? MpvPlayer)?.subtitleDelay = subtitleDelay
|
||||
}
|
||||
val subtitleSearch by viewModel.subtitleSearch.observeAsState(null)
|
||||
val subtitleSearchLanguage by viewModel.subtitleSearchLanguage.observeAsState(Locale.current.language)
|
||||
|
||||
val presentationState = rememberPresentationState(player, false)
|
||||
val scaledModifier =
|
||||
Modifier.resizeWithContentScale(contentScale, presentationState.videoSizeDp)
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val playPauseState = rememberPlayPauseButtonState(player)
|
||||
val seekBarState = rememberSeekBarState(player, scope)
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequester.tryRequestFocus()
|
||||
}
|
||||
val controllerViewState = remember { viewModel.controllerViewState }
|
||||
|
||||
var skipIndicatorDuration by remember { mutableLongStateOf(0L) }
|
||||
LaunchedEffect(controllerViewState.controlsVisible) {
|
||||
// If controller shows/hides, immediately cancel the skip indicator
|
||||
skipIndicatorDuration = 0L
|
||||
}
|
||||
var skipPosition by remember { mutableLongStateOf(0L) }
|
||||
val updateSkipIndicator = { delta: Long ->
|
||||
if ((skipIndicatorDuration > 0 && delta < 0) || (skipIndicatorDuration < 0 && delta > 0)) {
|
||||
skipIndicatorDuration = 0
|
||||
}
|
||||
skipIndicatorDuration += delta
|
||||
skipPosition = player.currentPosition
|
||||
}
|
||||
val keyHandler =
|
||||
PlaybackKeyHandler(
|
||||
player = player,
|
||||
controlsEnabled = nextUp == null,
|
||||
skipWithLeftRight = true,
|
||||
seekForward = preferences.appPreferences.playbackPreferences.skipForwardMs.milliseconds,
|
||||
seekBack = preferences.appPreferences.playbackPreferences.skipBackMs.milliseconds,
|
||||
controllerViewState = controllerViewState,
|
||||
updateSkipIndicator = updateSkipIndicator,
|
||||
skipBackOnResume = preferences.appPreferences.playbackPreferences.skipBackOnResume,
|
||||
onInteraction = viewModel::reportInteraction,
|
||||
oneClickPause = preferences.appPreferences.playbackPreferences.oneClickPause,
|
||||
onStop = {
|
||||
player.stop()
|
||||
viewModel.navigationManager.goBack()
|
||||
},
|
||||
onPlaybackDialogTypeClick = { playbackDialog = it },
|
||||
)
|
||||
|
||||
val onPlaybackActionClick: (PlaybackAction) -> Unit = {
|
||||
when (it) {
|
||||
is PlaybackAction.PlaybackSpeed -> {
|
||||
playbackSpeed = it.value
|
||||
}
|
||||
|
||||
is PlaybackAction.Scale -> {
|
||||
contentScale = it.scale
|
||||
}
|
||||
|
||||
PlaybackAction.ShowDebug -> {
|
||||
showDebugInfo = !showDebugInfo
|
||||
}
|
||||
|
||||
PlaybackAction.ShowPlaylist -> {
|
||||
TODO()
|
||||
}
|
||||
|
||||
PlaybackAction.ShowVideoFilterDialog -> {
|
||||
TODO()
|
||||
}
|
||||
|
||||
is PlaybackAction.ToggleAudio -> {
|
||||
viewModel.changeAudioStream(it.index)
|
||||
}
|
||||
|
||||
is PlaybackAction.ToggleCaptions -> {
|
||||
viewModel.changeSubtitleStream(it.index)
|
||||
}
|
||||
|
||||
PlaybackAction.SearchCaptions -> {
|
||||
controllerViewState.hideControls()
|
||||
viewModel.searchForSubtitles()
|
||||
}
|
||||
|
||||
PlaybackAction.Next -> {
|
||||
// TODO focus is lost
|
||||
viewModel.playNextUp()
|
||||
}
|
||||
|
||||
PlaybackAction.Previous -> {
|
||||
val pos = player.currentPosition
|
||||
if (pos < player.maxSeekToPreviousPosition && playlist.hasPrevious()) {
|
||||
viewModel.playPrevious()
|
||||
} else {
|
||||
player.seekToPrevious()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val showSegment =
|
||||
!segmentCancelled && currentSegment != null &&
|
||||
nextUp == null && !controllerViewState.controlsVisible && skipIndicatorDuration == 0L
|
||||
BackHandler(showSegment) {
|
||||
segmentCancelled = true
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier
|
||||
.background(if (nextUp == null) Color.Black else MaterialTheme.colorScheme.background),
|
||||
) {
|
||||
val playerSize by animateFloatAsState(if (nextUp == null) 1f else .6f)
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize(playerSize)
|
||||
.align(Alignment.TopCenter)
|
||||
.onKeyEvent(keyHandler::onKeyEvent)
|
||||
.focusRequester(focusRequester)
|
||||
.focusable(),
|
||||
) {
|
||||
PlayerSurface(
|
||||
player = player,
|
||||
surfaceType = SURFACE_TYPE_SURFACE_VIEW,
|
||||
modifier = scaledModifier,
|
||||
)
|
||||
if (presentationState.coverSurface) {
|
||||
Box(
|
||||
Modifier
|
||||
.matchParentSize()
|
||||
.background(Color.Black),
|
||||
) {
|
||||
LoadingPage(focusEnabled = false)
|
||||
}
|
||||
}
|
||||
|
||||
// If D-pad skipping, show the amount skipped in an animation
|
||||
if (!controllerViewState.controlsVisible && skipIndicatorDuration != 0L) {
|
||||
SkipIndicator(
|
||||
durationMs = skipIndicatorDuration,
|
||||
onFinish = {
|
||||
skipIndicatorDuration = 0L
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = 70.dp),
|
||||
)
|
||||
// Show a small progress bar along the bottom of the screen
|
||||
val showSkipProgress = true // TODO get from preferences
|
||||
if (showSkipProgress) {
|
||||
val percent = skipPosition.toFloat() / player.duration.toFloat()
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.background(MaterialTheme.colorScheme.border)
|
||||
.clip(RectangleShape)
|
||||
.height(3.dp)
|
||||
.fillMaxWidth(percent),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// The playback controls
|
||||
AnimatedVisibility(
|
||||
controllerViewState.controlsVisible,
|
||||
Modifier,
|
||||
slideInVertically { it },
|
||||
slideOutVertically { it },
|
||||
) {
|
||||
PlaybackOverlay(
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(WindowInsets.systemBars.asPaddingValues())
|
||||
.fillMaxSize()
|
||||
.background(Color.Transparent),
|
||||
item = currentPlayback?.item,
|
||||
playerControls = player,
|
||||
controllerViewState = controllerViewState,
|
||||
showPlay = playPauseState.showPlay,
|
||||
previousEnabled = true,
|
||||
nextEnabled = playlist.hasNext(),
|
||||
seekEnabled = true,
|
||||
seekForward = preferences.appPreferences.playbackPreferences.skipForwardMs.milliseconds,
|
||||
seekBack = preferences.appPreferences.playbackPreferences.skipBackMs.milliseconds,
|
||||
skipBackOnResume = preferences.appPreferences.playbackPreferences.skipBackOnResume,
|
||||
onPlaybackActionClick = onPlaybackActionClick,
|
||||
onClickPlaybackDialogType = { playbackDialog = it },
|
||||
onSeekBarChange = seekBarState::onValueChange,
|
||||
showDebugInfo = showDebugInfo,
|
||||
currentPlayback = currentPlayback,
|
||||
chapters = mediaInfo?.chapters ?: listOf(),
|
||||
trickplayInfo = mediaInfo?.trickPlayInfo,
|
||||
trickplayUrlFor = viewModel::getTrickplayUrl,
|
||||
playlist = playlist,
|
||||
onClickPlaylist = {
|
||||
viewModel.playItemInPlaylist(it)
|
||||
},
|
||||
currentSegment = currentSegment,
|
||||
showClock = preferences.appPreferences.interfacePreferences.showClock,
|
||||
)
|
||||
}
|
||||
|
||||
// Subtitles
|
||||
if (skipIndicatorDuration == 0L && currentItemPlayback.subtitleIndexEnabled) {
|
||||
val maxSize by animateFloatAsState(if (controllerViewState.controlsVisible) .7f else 1f)
|
||||
AndroidView(
|
||||
factory = { context ->
|
||||
SubtitleView(context).apply {
|
||||
preferences.appPreferences.interfacePreferences.subtitlesPreferences.let {
|
||||
setStyle(it.toSubtitleStyle())
|
||||
setFixedTextSize(Dimension.SP, it.fontSize.toFloat())
|
||||
setBottomPaddingFraction(it.margin.toFloat() / 100f)
|
||||
}
|
||||
}
|
||||
},
|
||||
update = {
|
||||
it.setCues(cues)
|
||||
Media3SubtitleOverride(
|
||||
preferences.appPreferences.interfacePreferences.subtitlesPreferences
|
||||
.calculateEdgeSize(density),
|
||||
).apply(it)
|
||||
},
|
||||
onReset = {
|
||||
it.setCues(null)
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize(maxSize)
|
||||
.align(Alignment.TopCenter)
|
||||
.background(Color.Transparent),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Ask to skip intros, etc button
|
||||
AnimatedVisibility(
|
||||
showSegment,
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(40.dp)
|
||||
.align(Alignment.BottomEnd),
|
||||
) {
|
||||
currentSegment?.let { segment ->
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequester.tryRequestFocus()
|
||||
delay(10.seconds)
|
||||
segmentCancelled = true
|
||||
}
|
||||
TextButton(
|
||||
stringRes = segment.type.skipStringRes,
|
||||
onClick = {
|
||||
segmentCancelled = true
|
||||
player.seekTo(segment.endTicks.ticks.inWholeMilliseconds)
|
||||
},
|
||||
modifier = Modifier.focusRequester(focusRequester),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Next up episode
|
||||
BackHandler(nextUp != null) {
|
||||
if (player.isPlaying) {
|
||||
scope.launch(ExceptionHandler()) {
|
||||
viewModel.cancelUpNextEpisode()
|
||||
}
|
||||
} else {
|
||||
viewModel.navigationManager.goBack()
|
||||
}
|
||||
}
|
||||
AnimatedVisibility(
|
||||
nextUp != null,
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.BottomCenter),
|
||||
) {
|
||||
nextUp?.let {
|
||||
var autoPlayEnabled by remember { mutableStateOf(viewModel.shouldAutoPlayNextUp()) }
|
||||
var timeLeft by remember {
|
||||
mutableLongStateOf(
|
||||
preferences.appPreferences.playbackPreferences.autoPlayNextDelaySeconds,
|
||||
)
|
||||
}
|
||||
BackHandler(timeLeft > 0 && autoPlayEnabled) {
|
||||
timeLeft = -1
|
||||
autoPlayEnabled = false
|
||||
}
|
||||
if (autoPlayEnabled) {
|
||||
LaunchedEffect(Unit) {
|
||||
if (timeLeft == 0L) {
|
||||
viewModel.playNextUp()
|
||||
} else {
|
||||
while (timeLeft > 0) {
|
||||
delay(1.seconds)
|
||||
timeLeft--
|
||||
}
|
||||
if (timeLeft == 0L && autoPlayEnabled) {
|
||||
viewModel.playNextUp()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
NextUpEpisode(
|
||||
title =
|
||||
listOfNotNull(
|
||||
it.data.seasonEpisode,
|
||||
it.name,
|
||||
).joinToString(" - "),
|
||||
description = it.data.overview,
|
||||
imageUrl = LocalImageUrlService.current.rememberImageUrl(it),
|
||||
aspectRatio = it.aspectRatio ?: AspectRatios.WIDE,
|
||||
onClick = {
|
||||
viewModel.reportInteraction()
|
||||
controllerViewState.hideControls()
|
||||
viewModel.playNextUp()
|
||||
},
|
||||
timeLeft = if (autoPlayEnabled) timeLeft.seconds else null,
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(8.dp)
|
||||
// .height(128.dp)
|
||||
.fillMaxHeight(1 - playerSize)
|
||||
.fillMaxWidth(.66f)
|
||||
.align(Alignment.BottomCenter)
|
||||
.background(
|
||||
MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
subtitleSearch?.let { state ->
|
||||
val wasPlaying = remember { player.isPlaying }
|
||||
LaunchedEffect(Unit) {
|
||||
player.pause()
|
||||
}
|
||||
val onDismissRequest = {
|
||||
if (wasPlaying) {
|
||||
player.play()
|
||||
}
|
||||
viewModel.cancelSubtitleSearch()
|
||||
}
|
||||
Dialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
properties =
|
||||
DialogProperties(
|
||||
usePlatformDefaultWidth = false,
|
||||
),
|
||||
) {
|
||||
DownloadSubtitlesContent(
|
||||
state = state,
|
||||
language = subtitleSearchLanguage,
|
||||
onSearch = { lang ->
|
||||
viewModel.searchForSubtitles(lang)
|
||||
},
|
||||
onClickDownload = {
|
||||
viewModel.downloadAndSwitchSubtitles(it.id, wasPlaying)
|
||||
},
|
||||
onDismissRequest = onDismissRequest,
|
||||
modifier =
|
||||
Modifier
|
||||
.widthIn(max = 640.dp)
|
||||
.heightIn(max = 400.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
playbackDialog?.let { type ->
|
||||
PlaybackDialog(
|
||||
type = type,
|
||||
settings =
|
||||
PlaybackSettings(
|
||||
showDebugInfo = showDebugInfo,
|
||||
audioIndex = currentItemPlayback?.audioIndex,
|
||||
audioStreams = mediaInfo?.audioStreams.orEmpty(),
|
||||
subtitleIndex = currentItemPlayback?.subtitleIndex,
|
||||
subtitleStreams = mediaInfo?.subtitleStreams.orEmpty(),
|
||||
playbackSpeed = playbackSpeed,
|
||||
contentScale = contentScale,
|
||||
subtitleDelay = subtitleDelay,
|
||||
hasSubtitleDownloadPermission =
|
||||
remember(userDto) { userDto?.policy?.let { it.isAdministrator || it.enableSubtitleManagement } == true },
|
||||
),
|
||||
onDismissRequest = {
|
||||
playbackDialog = null
|
||||
if (controllerViewState.controlsVisible) {
|
||||
controllerViewState.pulseControls()
|
||||
}
|
||||
},
|
||||
onControllerInteraction = {
|
||||
controllerViewState.pulseControls(Long.MAX_VALUE)
|
||||
},
|
||||
onClickPlaybackDialogType = {
|
||||
if (it == PlaybackDialogType.SUBTITLE_DELAY) {
|
||||
// Hide controls so subtitles are fully visible
|
||||
controllerViewState.hideControls()
|
||||
}
|
||||
playbackDialog = it
|
||||
},
|
||||
onPlaybackActionClick = onPlaybackActionClick,
|
||||
onChangeSubtitleDelay = { viewModel.updateSubtitleDelay(it) },
|
||||
enableSubtitleDelay = player is MpvPlayer,
|
||||
enableVideoScale = player !is MpvPlayer,
|
||||
var playbackDialog by remember { mutableStateOf<PlaybackDialogType?>(null) }
|
||||
LaunchedEffect(player) {
|
||||
if (playerBackend == PlayerBackend.MPV) {
|
||||
scope.launch(Dispatchers.IO + ExceptionHandler()) {
|
||||
preferences.appPreferences.interfacePreferences.subtitlesPreferences.applyToMpv(
|
||||
configuration,
|
||||
density,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AmbientPlayerListener(player)
|
||||
var contentScale by remember(playerBackend) {
|
||||
mutableStateOf(
|
||||
if (playerBackend == PlayerBackend.MPV) {
|
||||
ContentScale.FillBounds
|
||||
} else {
|
||||
prefs.globalContentScale.scale
|
||||
},
|
||||
)
|
||||
}
|
||||
var playbackSpeed by remember { mutableFloatStateOf(1.0f) }
|
||||
LaunchedEffect(playbackSpeed) { player.setPlaybackSpeed(playbackSpeed) }
|
||||
|
||||
val subtitleDelay = currentPlayback?.subtitleDelay ?: Duration.ZERO
|
||||
LaunchedEffect(subtitleDelay) {
|
||||
(player as? MpvPlayer)?.subtitleDelay = subtitleDelay
|
||||
}
|
||||
|
||||
val presentationState = rememberPresentationState(player, false)
|
||||
val scaledModifier =
|
||||
Modifier.resizeWithContentScale(contentScale, presentationState.videoSizeDp)
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val playPauseState = rememberPlayPauseButtonState(player)
|
||||
val seekBarState = rememberSeekBarState(player, scope)
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequester.tryRequestFocus()
|
||||
}
|
||||
val controllerViewState = remember { viewModel.controllerViewState }
|
||||
|
||||
var skipIndicatorDuration by remember { mutableLongStateOf(0L) }
|
||||
LaunchedEffect(controllerViewState.controlsVisible) {
|
||||
// If controller shows/hides, immediately cancel the skip indicator
|
||||
skipIndicatorDuration = 0L
|
||||
}
|
||||
var skipPosition by remember { mutableLongStateOf(0L) }
|
||||
val updateSkipIndicator = { delta: Long ->
|
||||
if ((skipIndicatorDuration > 0 && delta < 0) || (skipIndicatorDuration < 0 && delta > 0)) {
|
||||
skipIndicatorDuration = 0
|
||||
}
|
||||
skipIndicatorDuration += delta
|
||||
skipPosition = player.currentPosition
|
||||
}
|
||||
val keyHandler =
|
||||
PlaybackKeyHandler(
|
||||
player = player,
|
||||
controlsEnabled = nextUp == null,
|
||||
skipWithLeftRight = true,
|
||||
seekForward = preferences.appPreferences.playbackPreferences.skipForwardMs.milliseconds,
|
||||
seekBack = preferences.appPreferences.playbackPreferences.skipBackMs.milliseconds,
|
||||
controllerViewState = controllerViewState,
|
||||
updateSkipIndicator = updateSkipIndicator,
|
||||
skipBackOnResume = preferences.appPreferences.playbackPreferences.skipBackOnResume,
|
||||
onInteraction = viewModel::reportInteraction,
|
||||
oneClickPause = preferences.appPreferences.playbackPreferences.oneClickPause,
|
||||
onStop = {
|
||||
player.stop()
|
||||
viewModel.navigationManager.goBack()
|
||||
},
|
||||
onPlaybackDialogTypeClick = { playbackDialog = it },
|
||||
)
|
||||
|
||||
val onPlaybackActionClick: (PlaybackAction) -> Unit = {
|
||||
when (it) {
|
||||
is PlaybackAction.PlaybackSpeed -> {
|
||||
playbackSpeed = it.value
|
||||
}
|
||||
|
||||
is PlaybackAction.Scale -> {
|
||||
contentScale = it.scale
|
||||
}
|
||||
|
||||
PlaybackAction.ShowDebug -> {
|
||||
showDebugInfo = !showDebugInfo
|
||||
}
|
||||
|
||||
PlaybackAction.ShowPlaylist -> {
|
||||
TODO()
|
||||
}
|
||||
|
||||
PlaybackAction.ShowVideoFilterDialog -> {
|
||||
TODO()
|
||||
}
|
||||
|
||||
is PlaybackAction.ToggleAudio -> {
|
||||
viewModel.changeAudioStream(it.index)
|
||||
}
|
||||
|
||||
is PlaybackAction.ToggleCaptions -> {
|
||||
viewModel.changeSubtitleStream(it.index)
|
||||
}
|
||||
|
||||
PlaybackAction.SearchCaptions -> {
|
||||
controllerViewState.hideControls()
|
||||
viewModel.searchForSubtitles()
|
||||
}
|
||||
|
||||
PlaybackAction.Next -> {
|
||||
// TODO focus is lost
|
||||
viewModel.playNextUp()
|
||||
}
|
||||
|
||||
PlaybackAction.Previous -> {
|
||||
val pos = player.currentPosition
|
||||
if (pos < player.maxSeekToPreviousPosition && playlist.hasPrevious()) {
|
||||
viewModel.playPrevious()
|
||||
} else {
|
||||
player.seekToPrevious()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val showSegment =
|
||||
!segmentCancelled && currentSegment != null &&
|
||||
nextUp == null && !controllerViewState.controlsVisible && skipIndicatorDuration == 0L
|
||||
BackHandler(showSegment) {
|
||||
segmentCancelled = true
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier
|
||||
.background(if (nextUp == null) Color.Black else MaterialTheme.colorScheme.background),
|
||||
) {
|
||||
val playerSize by animateFloatAsState(if (nextUp == null) 1f else .6f)
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize(playerSize)
|
||||
.align(Alignment.TopCenter)
|
||||
.onKeyEvent(keyHandler::onKeyEvent)
|
||||
.focusRequester(focusRequester)
|
||||
.focusable(),
|
||||
) {
|
||||
PlayerSurface(
|
||||
player = player,
|
||||
surfaceType = SURFACE_TYPE_SURFACE_VIEW,
|
||||
modifier = scaledModifier,
|
||||
)
|
||||
if (presentationState.coverSurface) {
|
||||
Box(
|
||||
Modifier
|
||||
.matchParentSize()
|
||||
.background(Color.Black),
|
||||
) {
|
||||
LoadingPage(focusEnabled = false)
|
||||
}
|
||||
}
|
||||
|
||||
// If D-pad skipping, show the amount skipped in an animation
|
||||
if (!controllerViewState.controlsVisible && skipIndicatorDuration != 0L) {
|
||||
SkipIndicator(
|
||||
durationMs = skipIndicatorDuration,
|
||||
onFinish = {
|
||||
skipIndicatorDuration = 0L
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = 70.dp),
|
||||
)
|
||||
// Show a small progress bar along the bottom of the screen
|
||||
val showSkipProgress = true // TODO get from preferences
|
||||
if (showSkipProgress) {
|
||||
val percent = skipPosition.toFloat() / player.duration.toFloat()
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.background(MaterialTheme.colorScheme.border)
|
||||
.clip(RectangleShape)
|
||||
.height(3.dp)
|
||||
.fillMaxWidth(percent),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// The playback controls
|
||||
AnimatedVisibility(
|
||||
controllerViewState.controlsVisible,
|
||||
Modifier,
|
||||
slideInVertically { it },
|
||||
slideOutVertically { it },
|
||||
) {
|
||||
PlaybackOverlay(
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(WindowInsets.systemBars.asPaddingValues())
|
||||
.fillMaxSize()
|
||||
.background(Color.Transparent),
|
||||
item = currentPlayback?.item,
|
||||
playerControls = player,
|
||||
controllerViewState = controllerViewState,
|
||||
showPlay = playPauseState.showPlay,
|
||||
previousEnabled = true,
|
||||
nextEnabled = playlist.hasNext(),
|
||||
seekEnabled = true,
|
||||
seekForward = preferences.appPreferences.playbackPreferences.skipForwardMs.milliseconds,
|
||||
seekBack = preferences.appPreferences.playbackPreferences.skipBackMs.milliseconds,
|
||||
skipBackOnResume = preferences.appPreferences.playbackPreferences.skipBackOnResume,
|
||||
onPlaybackActionClick = onPlaybackActionClick,
|
||||
onClickPlaybackDialogType = { playbackDialog = it },
|
||||
onSeekBarChange = seekBarState::onValueChange,
|
||||
showDebugInfo = showDebugInfo,
|
||||
currentPlayback = currentPlayback,
|
||||
chapters = mediaInfo?.chapters ?: listOf(),
|
||||
trickplayInfo = mediaInfo?.trickPlayInfo,
|
||||
trickplayUrlFor = viewModel::getTrickplayUrl,
|
||||
playlist = playlist,
|
||||
onClickPlaylist = {
|
||||
viewModel.playItemInPlaylist(it)
|
||||
},
|
||||
currentSegment = currentSegment,
|
||||
showClock = preferences.appPreferences.interfacePreferences.showClock,
|
||||
)
|
||||
}
|
||||
|
||||
// Subtitles
|
||||
if (skipIndicatorDuration == 0L && currentItemPlayback.subtitleIndexEnabled) {
|
||||
val maxSize by animateFloatAsState(if (controllerViewState.controlsVisible) .7f else 1f)
|
||||
AndroidView(
|
||||
factory = { context ->
|
||||
SubtitleView(context).apply {
|
||||
preferences.appPreferences.interfacePreferences.subtitlesPreferences.let {
|
||||
setStyle(it.toSubtitleStyle())
|
||||
setFixedTextSize(Dimension.SP, it.fontSize.toFloat())
|
||||
setBottomPaddingFraction(it.margin.toFloat() / 100f)
|
||||
}
|
||||
}
|
||||
},
|
||||
update = {
|
||||
it.setCues(cues)
|
||||
Media3SubtitleOverride(
|
||||
preferences.appPreferences.interfacePreferences.subtitlesPreferences
|
||||
.calculateEdgeSize(density),
|
||||
).apply(it)
|
||||
},
|
||||
onReset = {
|
||||
it.setCues(null)
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize(maxSize)
|
||||
.align(Alignment.TopCenter)
|
||||
.background(Color.Transparent),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Ask to skip intros, etc button
|
||||
AnimatedVisibility(
|
||||
showSegment,
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(40.dp)
|
||||
.align(Alignment.BottomEnd),
|
||||
) {
|
||||
currentSegment?.let { segment ->
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequester.tryRequestFocus()
|
||||
delay(10.seconds)
|
||||
segmentCancelled = true
|
||||
}
|
||||
TextButton(
|
||||
stringRes = segment.type.skipStringRes,
|
||||
onClick = {
|
||||
segmentCancelled = true
|
||||
player.seekTo(segment.endTicks.ticks.inWholeMilliseconds)
|
||||
},
|
||||
modifier = Modifier.focusRequester(focusRequester),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Next up episode
|
||||
BackHandler(nextUp != null) {
|
||||
if (player.isPlaying) {
|
||||
scope.launch(ExceptionHandler()) {
|
||||
viewModel.cancelUpNextEpisode()
|
||||
}
|
||||
} else {
|
||||
viewModel.navigationManager.goBack()
|
||||
}
|
||||
}
|
||||
AnimatedVisibility(
|
||||
nextUp != null,
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.BottomCenter),
|
||||
) {
|
||||
nextUp?.let {
|
||||
var autoPlayEnabled by remember { mutableStateOf(viewModel.shouldAutoPlayNextUp()) }
|
||||
var timeLeft by remember {
|
||||
mutableLongStateOf(
|
||||
preferences.appPreferences.playbackPreferences.autoPlayNextDelaySeconds,
|
||||
)
|
||||
}
|
||||
BackHandler(timeLeft > 0 && autoPlayEnabled) {
|
||||
timeLeft = -1
|
||||
autoPlayEnabled = false
|
||||
}
|
||||
if (autoPlayEnabled) {
|
||||
LaunchedEffect(Unit) {
|
||||
if (timeLeft == 0L) {
|
||||
viewModel.playNextUp()
|
||||
} else {
|
||||
while (timeLeft > 0) {
|
||||
delay(1.seconds)
|
||||
timeLeft--
|
||||
}
|
||||
if (timeLeft == 0L && autoPlayEnabled) {
|
||||
viewModel.playNextUp()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
NextUpEpisode(
|
||||
title =
|
||||
listOfNotNull(
|
||||
it.data.seasonEpisode,
|
||||
it.name,
|
||||
).joinToString(" - "),
|
||||
description = it.data.overview,
|
||||
imageUrl = LocalImageUrlService.current.rememberImageUrl(it),
|
||||
aspectRatio = it.aspectRatio ?: AspectRatios.WIDE,
|
||||
onClick = {
|
||||
viewModel.reportInteraction()
|
||||
controllerViewState.hideControls()
|
||||
viewModel.playNextUp()
|
||||
},
|
||||
timeLeft = if (autoPlayEnabled) timeLeft.seconds else null,
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(8.dp)
|
||||
// .height(128.dp)
|
||||
.fillMaxHeight(1 - playerSize)
|
||||
.fillMaxWidth(.66f)
|
||||
.align(Alignment.BottomCenter)
|
||||
.background(
|
||||
MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
subtitleSearch?.let { state ->
|
||||
val wasPlaying = remember { player.isPlaying }
|
||||
LaunchedEffect(Unit) {
|
||||
player.pause()
|
||||
}
|
||||
val onDismissRequest = {
|
||||
if (wasPlaying) {
|
||||
player.play()
|
||||
}
|
||||
viewModel.cancelSubtitleSearch()
|
||||
}
|
||||
Dialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
properties =
|
||||
DialogProperties(
|
||||
usePlatformDefaultWidth = false,
|
||||
),
|
||||
) {
|
||||
DownloadSubtitlesContent(
|
||||
state = state,
|
||||
language = subtitleSearchLanguage,
|
||||
onSearch = { lang ->
|
||||
viewModel.searchForSubtitles(lang)
|
||||
},
|
||||
onClickDownload = {
|
||||
viewModel.downloadAndSwitchSubtitles(it.id, wasPlaying)
|
||||
},
|
||||
onDismissRequest = onDismissRequest,
|
||||
modifier =
|
||||
Modifier
|
||||
.widthIn(max = 640.dp)
|
||||
.heightIn(max = 400.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
playbackDialog?.let { type ->
|
||||
PlaybackDialog(
|
||||
type = type,
|
||||
settings =
|
||||
PlaybackSettings(
|
||||
showDebugInfo = showDebugInfo,
|
||||
audioIndex = currentItemPlayback?.audioIndex,
|
||||
audioStreams = mediaInfo?.audioStreams.orEmpty(),
|
||||
subtitleIndex = currentItemPlayback?.subtitleIndex,
|
||||
subtitleStreams = mediaInfo?.subtitleStreams.orEmpty(),
|
||||
playbackSpeed = playbackSpeed,
|
||||
contentScale = contentScale,
|
||||
subtitleDelay = subtitleDelay,
|
||||
hasSubtitleDownloadPermission =
|
||||
remember(userDto) { userDto?.policy?.let { it.isAdministrator || it.enableSubtitleManagement } == true },
|
||||
),
|
||||
onDismissRequest = {
|
||||
playbackDialog = null
|
||||
if (controllerViewState.controlsVisible) {
|
||||
controllerViewState.pulseControls()
|
||||
}
|
||||
},
|
||||
onControllerInteraction = {
|
||||
controllerViewState.pulseControls(Long.MAX_VALUE)
|
||||
},
|
||||
onClickPlaybackDialogType = {
|
||||
if (it == PlaybackDialogType.SUBTITLE_DELAY) {
|
||||
// Hide controls so subtitles are fully visible
|
||||
controllerViewState.hideControls()
|
||||
}
|
||||
playbackDialog = it
|
||||
},
|
||||
onPlaybackActionClick = onPlaybackActionClick,
|
||||
onChangeSubtitleDelay = { viewModel.updateSubtitleDelay(it) },
|
||||
enableSubtitleDelay = player is MpvPlayer,
|
||||
enableVideoScale = player !is MpvPlayer,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,82 +289,64 @@ 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) {
|
||||
queriedItem
|
||||
} else if (destination is Destination.PlaybackList) {
|
||||
isPlaylist = true
|
||||
val playlistResult =
|
||||
playlistCreator.createFrom(
|
||||
item = queriedItem,
|
||||
startIndex = destination.startIndex ?: 0,
|
||||
sortAndDirection = destination.sortAndDirection,
|
||||
shuffled = destination.shuffle,
|
||||
recursive = destination.recursive,
|
||||
filter = destination.filter,
|
||||
)
|
||||
when (val r = playlistResult) {
|
||||
is PlaylistCreationResult.Error -> {
|
||||
loading.setValueOnMain(LoadingState.Error(r.message, r.ex))
|
||||
return@launch
|
||||
}
|
||||
|
||||
is PlaylistCreationResult.Success -> {
|
||||
if (r.playlist.items.isEmpty()) {
|
||||
showToast(context, "Playlist is empty", Toast.LENGTH_SHORT)
|
||||
navigationManager.goBack()
|
||||
return@launch
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
this@PlaybackViewModel.playlist.value = r.playlist
|
||||
}
|
||||
r.playlist.items
|
||||
.first()
|
||||
.data
|
||||
}
|
||||
val queriedItem = api.userLibraryApi.getItem(itemId).content
|
||||
val base =
|
||||
if (queriedItem.type.playable) {
|
||||
queriedItem
|
||||
} else if (destination is Destination.PlaybackList) {
|
||||
isPlaylist = true
|
||||
val playlistResult =
|
||||
playlistCreator.createFrom(
|
||||
item = queriedItem,
|
||||
startIndex = destination.startIndex ?: 0,
|
||||
sortAndDirection = destination.sortAndDirection,
|
||||
shuffled = destination.shuffle,
|
||||
recursive = destination.recursive,
|
||||
filter = destination.filter,
|
||||
)
|
||||
when (val r = playlistResult) {
|
||||
is PlaylistCreationResult.Error -> {
|
||||
loading.setValueOnMain(LoadingState.Error(r.message, r.ex))
|
||||
return
|
||||
}
|
||||
|
||||
is PlaylistCreationResult.Success -> {
|
||||
if (r.playlist.items.isEmpty()) {
|
||||
showToast(context, "Playlist is empty", Toast.LENGTH_SHORT)
|
||||
navigationManager.goBack()
|
||||
return
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
this@PlaybackViewModel.playlist.value = r.playlist
|
||||
}
|
||||
r.playlist.items
|
||||
.first()
|
||||
.data
|
||||
}
|
||||
} else {
|
||||
throw IllegalArgumentException("Item is not playable and not PlaybackList: ${queriedItem.type}")
|
||||
}
|
||||
|
||||
val sessionPlayer =
|
||||
MediaSessionPlayer(
|
||||
player,
|
||||
controllerViewState,
|
||||
preferences.appPreferences.playbackPreferences,
|
||||
)
|
||||
mediaSession =
|
||||
MediaSession
|
||||
.Builder(context, sessionPlayer)
|
||||
.build()
|
||||
|
||||
val item = BaseItem.from(base, api)
|
||||
|
||||
val played =
|
||||
play(
|
||||
item,
|
||||
positionMs,
|
||||
itemPlayback,
|
||||
forceTranscoding,
|
||||
)
|
||||
if (!played) {
|
||||
playNextUp()
|
||||
} else {
|
||||
throw IllegalArgumentException("Item is not playable and not PlaybackList: ${queriedItem.type}")
|
||||
}
|
||||
|
||||
if (!isPlaylist) {
|
||||
val result = playlistCreator.createFrom(queriedItem)
|
||||
if (result is PlaylistCreationResult.Success && result.playlist.items.isNotEmpty()) {
|
||||
withContext(Dispatchers.Main) {
|
||||
this@PlaybackViewModel.playlist.value = result.playlist
|
||||
}
|
||||
viewModelScope.launch(ExceptionHandler()) { controllerViewState.observe() }
|
||||
|
||||
val item = BaseItem.from(base, api)
|
||||
val played =
|
||||
play(
|
||||
item,
|
||||
positionMs,
|
||||
itemPlayback,
|
||||
forceTranscoding,
|
||||
)
|
||||
if (!played) {
|
||||
playNextUp()
|
||||
}
|
||||
|
||||
if (!isPlaylist) {
|
||||
val result = playlistCreator.createFrom(queriedItem)
|
||||
if (result is PlaylistCreationResult.Success && result.playlist.items.isNotEmpty()) {
|
||||
withContext(Dispatchers.Main) {
|
||||
this@PlaybackViewModel.playlist.value = result.playlist
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -380,6 +417,20 @@ class PlaybackViewModel
|
|||
return@withContext false
|
||||
}
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
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")
|
||||
}
|
||||
val wasSuccessful =
|
||||
changeStreamsDirectPlay(
|
||||
currentPlayback = currentPlayback,
|
||||
currentItemPlayback = currentItemPlayback,
|
||||
audioIndex = audioIndex,
|
||||
subtitleIndex = subtitleIndex,
|
||||
userInitiated = userInitiated,
|
||||
)
|
||||
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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
PlayerBackend.MPV -> {
|
||||
PlayerBackend.PREFER_MPV,
|
||||
PlayerBackend.MPV,
|
||||
-> {
|
||||
when (type) {
|
||||
MediaStreamType.VIDEO -> {
|
||||
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.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,24 +192,31 @@ fun <T> ComposablePreference(
|
|||
fromLongClick = false,
|
||||
items =
|
||||
values.mapIndexed { index, it ->
|
||||
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
|
||||
},
|
||||
)
|
||||
}
|
||||
DialogItem(
|
||||
headlineContent = {
|
||||
Text(it)
|
||||
},
|
||||
leadingContent = {
|
||||
if (index == selectedIndex) {
|
||||
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
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -36,6 +36,8 @@ enum class PreferenceScreenOption {
|
|||
ADVANCED,
|
||||
USER_INTERFACE,
|
||||
SUBTITLES,
|
||||
EXO_PLAYER,
|
||||
MPV,
|
||||
;
|
||||
|
||||
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.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,
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ enum MediaExtensionStatus{
|
|||
enum PlayerBackend{
|
||||
EXO_PLAYER = 0;
|
||||
MPV = 1;
|
||||
PREFER_MPV = 2;
|
||||
}
|
||||
|
||||
message MpvOptions{
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue