diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt index a04abc59..f1be13a4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt @@ -746,16 +746,29 @@ sealed interface AppPreference { val PlayerBackendPref = AppChoicePreference( 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( + title = R.string.exoplayer_options, + destination = Destination.Settings(PreferenceScreenOption.EXO_PLAYER), + ) + + val MpvSettings = + AppDestinationPreference( + title = R.string.mpv_options, + destination = Destination.Settings(PreferenceScreenOption.MPV), + ) + val MpvHardwareDecoding = AppSwitchPreference( title = R.string.mpv_hardware_decoding, @@ -958,6 +971,40 @@ val basicPreferences = val uiPreferences = listOf() +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( 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 data class AppMultiChoicePreference( diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt b/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt index 08dc0d77..24410fa0 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt @@ -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) + } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt b/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt index 8ee8fa75..425ab94d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt @@ -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 diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/CurrentMediaInfo.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/CurrentMediaInfo.kt index 309f099b..2b7d61bc 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/CurrentMediaInfo.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/CurrentMediaInfo.kt @@ -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, val subtitleStreams: List, val chapters: List, val trickPlayInfo: TrickplayInfo?, ) { companion object { - val EMPTY = CurrentMediaInfo(listOf(), listOf(), listOf(), null) + val EMPTY = CurrentMediaInfo(null, null, listOf(), listOf(), listOf(), null) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt index b671b10b..fde12a22 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt @@ -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( + 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(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(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, + ) + } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt index 151a15a2..0cefdcdf 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt @@ -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(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, +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SimpleMediaStream.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SimpleMediaStream.kt index 885be814..f63e91b2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SimpleMediaStream.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SimpleMediaStream.kt @@ -23,3 +23,8 @@ data class SimpleMediaStream( ) } } + +data class SimpleVideoStream( + val index: Int, + val hdr: Boolean, +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/TrackSelectionUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/TrackSelectionUtils.kt index 4c952a20..201b5982 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/TrackSelectionUtils.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/TrackSelectionUtils.kt @@ -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 diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/ComposablePreference.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/ComposablePreference.kt index b9ceee25..9b9bcf9c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/ComposablePreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/ComposablePreference.kt @@ -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 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 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 + }, + ) }, ) }, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferenceUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferenceUtils.kt index ba3d54da..f84d3b89 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferenceUtils.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferenceUtils.kt @@ -36,6 +36,8 @@ enum class PreferenceScreenOption { ADVANCED, USER_INTERFACE, SUBTITLES, + EXO_PLAYER, + MPV, ; companion object { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt index 95432283..597fd963 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt @@ -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, diff --git a/app/src/main/proto/WholphinDataStore.proto b/app/src/main/proto/WholphinDataStore.proto index 0710b51d..3c7f38ed 100644 --- a/app/src/main/proto/WholphinDataStore.proto +++ b/app/src/main/proto/WholphinDataStore.proto @@ -32,6 +32,7 @@ enum MediaExtensionStatus{ enum PlayerBackend{ EXO_PLAYER = 0; MPV = 1; + PREFER_MPV = 2; } message MpvOptions{ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 58fd02b1..c83a3b63 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -461,6 +461,7 @@ Upcoming TV Shows Request in 4K AV1 software decoding + MPV is now the default player except for HDR.\nYou can change this in settings. Disabled @@ -532,8 +533,15 @@ - ExoPlayer (default) - MPV (Experimental) + ExoPlayer + MPV + Prefer MPV + + + + + + Use ExoPlayer for HDR playback