diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3504f887a..02b20fb91 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -171,6 +171,12 @@ android { isUniversalApk = true } } + packaging { + jniLibs { + // Work around because libass-android & wholphin-mpv both (incorrectly) package libc++_shared.so + pickFirsts += "lib/*/libc++_shared.so" + } + } sourceSets { getByName("main") { @@ -269,6 +275,7 @@ dependencies { implementation(libs.androidx.media3.exoplayer.dash) implementation(libs.androidx.media3.ui) implementation(libs.androidx.media3.ui.compose) + implementation(libs.ass.media) implementation(libs.coil.core) implementation(libs.coil.compose) 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 26e8e6234..8e488a128 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 @@ -431,16 +431,18 @@ sealed interface AppPreference { summaryOn = R.string.enabled, summaryOff = R.string.disabled, ) - val DirectPlayAss = - AppSwitchPreference( - title = R.string.direct_play_ass, - defaultValue = true, - getter = { it.playbackPreferences.overrides.directPlayAss }, + val AssSubtitleMode = + AppChoicePreference( + title = R.string.ass_subtitle_playback, + defaultValue = AssPlaybackMode.ASS_LIBASS, + getter = { it.playbackPreferences.overrides.assPlaybackMode }, setter = { prefs, value -> - prefs.updatePlaybackOverrides { directPlayAss = value } + prefs.updatePlaybackOverrides { assPlaybackMode = value } }, - summaryOn = R.string.enabled, - summaryOff = R.string.disabled, + displayValues = R.array.ass_subtitle_modes, + subtitles = R.array.ass_subtitle_modes_summary, + indexToValue = { AssPlaybackMode.forNumber(it) }, + valueToIndex = { if (it != AssPlaybackMode.UNRECOGNIZED) it.number else AssPlaybackMode.ASS_LIBASS.number }, ) val DirectPlayPgs = AppSwitchPreference( @@ -1100,7 +1102,7 @@ private val ExoPlayerSettings = AppPreference.FfmpegPreference, AppPreference.DownMixStereo, AppPreference.Ac3Supported, - AppPreference.DirectPlayAss, + AppPreference.AssSubtitleMode, AppPreference.DirectPlayPgs, AppPreference.DirectPlayDoviProfile7, AppPreference.DecodeAv1, diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt index e202fca32..9b46de839 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt @@ -61,10 +61,11 @@ class AppPreferencesSerializer .apply { ac3Supported = AppPreference.Ac3Supported.defaultValue downmixStereo = AppPreference.DownMixStereo.defaultValue - directPlayAss = AppPreference.DirectPlayAss.defaultValue +// directPlayAss = AppPreference.DirectPlayAss.defaultValue directPlayPgs = AppPreference.DirectPlayPgs.defaultValue mediaExtensionsEnabled = AppPreference.FfmpegPreference.defaultValue + assPlaybackMode = AppPreference.AssSubtitleMode.defaultValue }.build() mpvOptions = 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 23093cfd4..8899fe8e3 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 @@ -141,7 +141,7 @@ class AppUpgradeHandler it.updatePlaybackOverrides { ac3Supported = AppPreference.Ac3Supported.defaultValue downmixStereo = AppPreference.DownMixStereo.defaultValue - directPlayAss = AppPreference.DirectPlayAss.defaultValue +// directPlayAss = AppPreference.DirectPlayAss.defaultValue directPlayPgs = AppPreference.DirectPlayPgs.defaultValue } } @@ -319,5 +319,13 @@ class AppUpgradeHandler } } } + + if (previous.isEqualOrBefore(Version.fromString("0.6.2-1-g0"))) { + appPreferences.updateData { + it.updatePlaybackOverrides { + assPlaybackMode = AppPreference.AssSubtitleMode.defaultValue + } + } + } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt index a001eb6af..4405092ee 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt @@ -1,6 +1,7 @@ package com.github.damontecres.wholphin.services import android.content.Context +import com.github.damontecres.wholphin.preferences.AssPlaybackMode import com.github.damontecres.wholphin.preferences.PlaybackPreferences import com.github.damontecres.wholphin.util.profile.MediaCodecCapabilitiesTest import com.github.damontecres.wholphin.util.profile.createDeviceProfile @@ -43,7 +44,7 @@ class DeviceProfileService maxBitrate = prefs.maxBitrate.toInt(), isAC3Enabled = prefs.overrides.ac3Supported, downMixAudio = prefs.overrides.downmixStereo, - assDirectPlay = prefs.overrides.directPlayAss, + assPlaybackMode = prefs.overrides.assPlaybackMode, pgsDirectPlay = prefs.overrides.directPlayPgs, dolbyVisionELDirectPlay = prefs.overrides.directPlayDolbyVisionEL, decodeAv1 = prefs.overrides.decodeAv1, @@ -58,7 +59,7 @@ class DeviceProfileService maxBitrate = newConfig.maxBitrate, isAC3Enabled = newConfig.isAC3Enabled, downMixAudio = newConfig.downMixAudio, - assDirectPlay = newConfig.assDirectPlay, + assDirectPlay = newConfig.assPlaybackMode != AssPlaybackMode.ASS_TRANSCODE, pgsDirectPlay = newConfig.pgsDirectPlay, dolbyVisionELDirectPlay = newConfig.dolbyVisionELDirectPlay, decodeAv1 = prefs.overrides.decodeAv1, @@ -77,7 +78,7 @@ data class DeviceProfileConfiguration( val maxBitrate: Int, val isAC3Enabled: Boolean, val downMixAudio: Boolean, - val assDirectPlay: Boolean, + val assPlaybackMode: AssPlaybackMode, val pgsDirectPlay: Boolean, val dolbyVisionELDirectPlay: Boolean, val decodeAv1: Boolean, 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 425ab94d4..335438e58 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 @@ -10,22 +10,29 @@ import androidx.datastore.core.DataStore import androidx.media3.common.C import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.DefaultDataSource import androidx.media3.exoplayer.DefaultRenderersFactory import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.Renderer +import androidx.media3.exoplayer.RenderersFactory import androidx.media3.exoplayer.mediacodec.MediaCodecSelector +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.media3.exoplayer.video.MediaCodecVideoRenderer import androidx.media3.exoplayer.video.VideoRendererEventListener -import com.github.damontecres.wholphin.preferences.AppPreference +import androidx.media3.extractor.DefaultExtractorsFactory import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.AssPlaybackMode 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 io.github.peerless2012.ass.media.AssHandler +import io.github.peerless2012.ass.media.factory.AssRenderersFactory +import io.github.peerless2012.ass.media.kt.withAssMkvSupport +import io.github.peerless2012.ass.media.parser.AssSubtitleParserFactory +import io.github.peerless2012.ass.media.type.AssRenderType 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 @@ -46,71 +53,17 @@ class PlayerFactory var currentPlayer: Player? = null private set - fun createVideoPlayer(): Player { - if (currentPlayer?.isReleased == false) { - Timber.w("Player was not released before trying to create a new one!") - currentPlayer?.release() - } - - val prefs = runBlocking { appPreferences.data.firstOrNull()?.playbackPreferences } - val backend = prefs?.playerBackend ?: AppPreference.PlayerBackendPref.defaultValue - val newPlayer = - when (backend) { - PlayerBackend.PREFER_MPV, - PlayerBackend.MPV, - -> { - val enableHardwareDecoding = - prefs?.mpvOptions?.enableHardwareDecoding - ?: AppPreference.MpvHardwareDecoding.defaultValue - val useGpuNext = - prefs?.mpvOptions?.useGpuNext - ?: AppPreference.MpvGpuNext.defaultValue - MpvPlayer(context, enableHardwareDecoding, useGpuNext) - .apply { - playWhenReady = true - } - } - - PlayerBackend.EXO_PLAYER, - PlayerBackend.UNRECOGNIZED, - -> { - val extensions = prefs?.overrides?.mediaExtensionsEnabled - val decodeAv1 = prefs?.overrides?.decodeAv1 == true - 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() - .apply { - playWhenReady = true - } - } - } - currentPlayer = newPlayer - return newPlayer - } - suspend fun createVideoPlayer( backend: PlayerBackend, prefs: PlaybackPreferences, - ): Player { + ): PlayerCreation { withContext(Dispatchers.Main) { if (currentPlayer?.isReleased == false) { Timber.w("Player was not released before trying to create a new one!") currentPlayer?.release() } } - + var assHandler: AssHandler? = null val newPlayer = when (backend) { PlayerBackend.PREFER_MPV, @@ -125,8 +78,14 @@ class PlayerFactory PlayerBackend.UNRECOGNIZED, -> { val extensions = prefs.overrides.mediaExtensionsEnabled + val useLibAss = + prefs.overrides.assPlaybackMode == AssPlaybackMode.ASS_LIBASS val decodeAv1 = prefs.overrides.decodeAv1 - Timber.v("extensions=$extensions") + Timber.v( + "extensions=%s, assPlaybackMode=%s", + extensions, + prefs.overrides.assPlaybackMode, + ) val rendererMode = when (extensions) { MediaExtensionStatus.MES_FALLBACK -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON @@ -134,17 +93,42 @@ class PlayerFactory MediaExtensionStatus.MES_DISABLED -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF else -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON } + val dataSourceFactory = DefaultDataSource.Factory(context) + val extractorsFactory = DefaultExtractorsFactory() + var renderersFactory: RenderersFactory = + WholphinRenderersFactory(context, decodeAv1) + .setEnableDecoderFallback(true) + .setExtensionRendererMode(rendererMode) + val mediaSourceFactory = + if (useLibAss) { + assHandler = AssHandler(AssRenderType.OVERLAY_OPEN_GL) + val assSubtitleParserFactory = AssSubtitleParserFactory(assHandler) + renderersFactory = AssRenderersFactory(assHandler, renderersFactory) + DefaultMediaSourceFactory( + dataSourceFactory, + extractorsFactory.withAssMkvSupport( + assSubtitleParserFactory, + assHandler, + ), + ).setSubtitleParserFactory(assSubtitleParserFactory) + } else { + DefaultMediaSourceFactory( + dataSourceFactory, + extractorsFactory, + ) + } ExoPlayer .Builder(context) - .setRenderersFactory( - WholphinRenderersFactory(context, decodeAv1) - .setEnableDecoderFallback(true) - .setExtensionRendererMode(rendererMode), - ).build() + .setMediaSourceFactory(mediaSourceFactory) + .setRenderersFactory(renderersFactory) + .build() + .apply { + assHandler?.init(this) + } } } currentPlayer = newPlayer - return newPlayer + return PlayerCreation(newPlayer, assHandler) } } @@ -157,6 +141,11 @@ val Player.isReleased: Boolean } } +data class PlayerCreation( + val player: Player, + val assHandler: AssHandler? = null, +) + // Code is adapted from https://github.com/androidx/media/blob/release/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/DefaultRenderersFactory.java#L436 class WholphinRenderersFactory( context: Context, 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 3159c4ddc..7a22f0069 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 @@ -1,5 +1,8 @@ package com.github.damontecres.wholphin.ui.playback +import android.view.Gravity +import android.view.ViewGroup +import android.widget.FrameLayout import androidx.activity.compose.BackHandler import androidx.annotation.Dimension import androidx.annotation.OptIn @@ -40,16 +43,18 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.intl.Locale +import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties +import androidx.core.view.children 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 @@ -61,6 +66,7 @@ import androidx.tv.material3.MaterialTheme import androidx.tv.material3.surfaceColorAtElevation import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.data.model.Playlist +import com.github.damontecres.wholphin.preferences.AssPlaybackMode import com.github.damontecres.wholphin.preferences.PlayerBackend import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.skipBackOnResume @@ -81,6 +87,7 @@ import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.Media3SubtitleOverride import com.github.damontecres.wholphin.util.mpv.MpvPlayer +import io.github.peerless2012.ass.media.widget.AssSubtitleView import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -125,8 +132,7 @@ fun PlaybackPage( LoadingState.Success -> { val playerState by viewModel.currentPlayer.collectAsState() PlaybackPageContent( - player = playerState!!.player, - playerBackend = playerState!!.backend, + playerState = playerState!!, preferences = preferences, destination = destination, viewModel = viewModel, @@ -139,13 +145,15 @@ fun PlaybackPage( @OptIn(UnstableApi::class) @Composable fun PlaybackPageContent( - player: Player, - playerBackend: PlayerBackend, + playerState: PlayerState, preferences: UserPreferences, destination: Destination, modifier: Modifier = Modifier, viewModel: PlaybackViewModel, ) { + val player = playerState.player + val playerBackend = playerState.backend + val prefs = preferences.appPreferences.playbackPreferences val scope = rememberCoroutineScope() val configuration = LocalConfiguration.current @@ -318,10 +326,14 @@ fun PlaybackPageContent( .focusRequester(focusRequester) .focusable(), ) { + var playerSurfaceSize by remember { mutableStateOf(IntSize.Zero) } PlayerSurface( player = player, surfaceType = SURFACE_TYPE_SURFACE_VIEW, - modifier = scaledModifier, + modifier = + scaledModifier.onSizeChanged { + playerSurfaceSize = it + }, ) if (presentationState.coverSurface) { Box( @@ -417,7 +429,7 @@ fun PlaybackPageContent( remember(subtitleSettings) { subtitleSettings.imageSubtitleOpacity / 100f } // Subtitles - if (skipIndicatorDuration == 0L && currentItemPlayback.subtitleIndexEnabled) { + if (skipIndicatorDuration == 0L && currentItemPlayback.subtitleIndexEnabled && !presentationState.coverSurface) { val maxSize by animateFloatAsState(if (controllerViewState.controlsVisible) .7f else 1f) val isImageSubtitles = remember(cues) { cues.firstOrNull()?.bitmap != null } AndroidView( @@ -428,12 +440,42 @@ fun PlaybackPageContent( setFixedTextSize(Dimension.SP, it.fontSize.toFloat()) setBottomPaddingFraction(it.margin.toFloat() / 100f) } + playerState.assHandler?.let { assHandler -> + if (prefs.overrides.assPlaybackMode == AssPlaybackMode.ASS_LIBASS) { + Timber.v("Adding AssSubtitleView") + addView( + AssSubtitleView(context, assHandler).apply { + layoutParams = + FrameLayout + .LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ).apply { gravity = Gravity.CENTER } + }, + ) + } + } } }, update = { it.setCues(cues) Media3SubtitleOverride(subtitleSettings.calculateEdgeSize(density)) .apply(it) + it.children.firstOrNull { it is AssSubtitleView }?.let { + (it as? AssSubtitleView)?.apply { + val resized = + layoutParams.let { it.width != playerSurfaceSize.width || it.height != playerSurfaceSize.height } + if (resized) { + Timber.v("Resizing AssSubtitleView: $playerSurfaceSize") + layoutParams = + FrameLayout + .LayoutParams( + playerSurfaceSize.width, + playerSurfaceSize.height, + ).apply { gravity = Gravity.CENTER } + } + } + } }, onReset = { it.setCues(null) 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 e9af15f1a..b9e51bb90 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 @@ -75,6 +75,7 @@ import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import io.github.peerless2012.ass.media.AssHandler import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -221,7 +222,8 @@ class PlaybackViewModel isHdr: Boolean, is4k: Boolean, ) { - val softwareDecoding = !preferences.appPreferences.playbackPreferences.mpvOptions.enableHardwareDecoding + val softwareDecoding = + !preferences.appPreferences.playbackPreferences.mpvOptions.enableHardwareDecoding val playerBackend = when (preferences.appPreferences.playbackPreferences.playerBackend) { PlayerBackend.UNRECOGNIZED, @@ -240,13 +242,14 @@ class PlaybackViewModel disconnectPlayer() } - player = + val playerCreation = playerFactory.createVideoPlayer( playerBackend, preferences.appPreferences.playbackPreferences, ) + this.player = playerCreation.player currentPlayer.update { - PlayerState(player, playerBackend) + PlayerState(playerCreation.player, playerBackend, playerCreation.assHandler) } configurePlayer() } @@ -1450,6 +1453,7 @@ class PlaybackViewModel data class PlayerState( val player: Player, val backend: PlayerBackend, + val assHandler: AssHandler?, ) data class MediaSegmentState( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowPage.kt index 2b3ef667a..faccb4cc4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowPage.kt @@ -69,6 +69,7 @@ import com.github.damontecres.wholphin.ui.playback.isDirectionalDpad import com.github.damontecres.wholphin.ui.playback.isDpad import com.github.damontecres.wholphin.ui.playback.isEnterKey import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.util.LoadingState import org.jellyfin.sdk.model.api.MediaType import timber.log.Timber import kotlin.math.abs @@ -92,403 +93,418 @@ fun SlideshowPage( ), ) { val context = LocalContext.current + val loading by viewModel.loading.collectAsState() - val loadingState by viewModel.loadingState.observeAsState(ImageLoadingState.Loading) - val imageFilter by viewModel.imageFilter.observeAsState(VideoFilter()) - val position by viewModel.position.observeAsState(0) - val pager by viewModel.pager.observeAsState() -// val imageState by viewModel.image.observeAsState() + when (val st = loading) { + is LoadingState.Error -> { + ErrorMessage(st, modifier) + } - var zoomFactor by rememberSaveable { mutableFloatStateOf(1f) } - val isZoomed = zoomFactor * 100 > 102 - var rotation by rememberSaveable { mutableFloatStateOf(0f) } - var showOverlay by rememberSaveable { mutableStateOf(false) } - var showFilterDialog by rememberSaveable { mutableStateOf(false) } - var panX by rememberSaveable { mutableFloatStateOf(0f) } - var panY by rememberSaveable { mutableFloatStateOf(0f) } + LoadingState.Loading, + LoadingState.Pending, + -> { + LoadingPage(modifier) + } - val slideshowControls = - object : SlideshowControls { - override fun startSlideshow() { - showOverlay = false - viewModel.startSlideshow() - } + LoadingState.Success -> { + val loadingState by viewModel.loadingState.observeAsState(ImageLoadingState.Loading) + val imageFilter by viewModel.imageFilter.observeAsState(VideoFilter()) + val position by viewModel.position.observeAsState(0) + val pager by viewModel.pager.observeAsState() +// val imageState by viewModel.image.observeAsState() - override fun stopSlideshow() { - viewModel.stopSlideshow() - } - } + var zoomFactor by rememberSaveable { mutableFloatStateOf(1f) } + val isZoomed = zoomFactor * 100 > 102 + var rotation by rememberSaveable { mutableFloatStateOf(0f) } + var showOverlay by rememberSaveable { mutableStateOf(false) } + var showFilterDialog by rememberSaveable { mutableStateOf(false) } + var panX by rememberSaveable { mutableFloatStateOf(0f) } + var panY by rememberSaveable { mutableFloatStateOf(0f) } - val rotateAnimation: Float by animateFloatAsState( - targetValue = rotation, - label = "image_rotation", - ) - val zoomAnimation: Float by animateFloatAsState( - targetValue = zoomFactor, - label = "image_zoom", - ) - val panXAnimation: Float by animateFloatAsState( - targetValue = panX, - label = "image_panX", - ) - val panYAnimation: Float by animateFloatAsState( - targetValue = panY, - label = "image_panY", - ) + val slideshowControls = + object : SlideshowControls { + override fun startSlideshow() { + showOverlay = false + viewModel.startSlideshow() + } - val slideshowState by viewModel.slideshow.collectAsState() - val slideshowActive by viewModel.slideshowActive.collectAsState(false) + override fun stopSlideshow() { + viewModel.stopSlideshow() + } + } - val focusRequester = remember { FocusRequester() } + val rotateAnimation: Float by animateFloatAsState( + targetValue = rotation, + label = "image_rotation", + ) + val zoomAnimation: Float by animateFloatAsState( + targetValue = zoomFactor, + label = "image_zoom", + ) + val panXAnimation: Float by animateFloatAsState( + targetValue = panX, + label = "image_panX", + ) + val panYAnimation: Float by animateFloatAsState( + targetValue = panY, + label = "image_panY", + ) - LaunchedEffect(Unit) { - focusRequester.tryRequestFocus() - } + val slideshowState by viewModel.slideshow.collectAsState() + val slideshowActive by viewModel.slideshowActive.collectAsState(false) - val density = LocalDensity.current - val screenHeight = LocalWindowInfo.current.containerSize.height - val screenWidth = LocalWindowInfo.current.containerSize.width + val focusRequester = remember { FocusRequester() } - val maxPanX = screenWidth * .75f - val maxPanY = screenHeight * .75f + LaunchedEffect(Unit) { + focusRequester.tryRequestFocus() + } - fun reset(resetRotate: Boolean) { - zoomFactor = 1f - panX = 0f - panY = 0f - if (resetRotate) rotation = 0f - } + val density = LocalDensity.current + val screenHeight = LocalWindowInfo.current.containerSize.height + val screenWidth = LocalWindowInfo.current.containerSize.width - fun pan( - xFactor: Int, - yFactor: Int, - ) { - if (xFactor != 0) { - panX = (panX + with(density) { xFactor.dp.toPx() }).coerceIn(-maxPanX, maxPanX) - } - if (yFactor != 0) { - panY = (panY + with(density) { yFactor.dp.toPx() }).coerceIn(-maxPanY, maxPanY) - } - } + val maxPanX = screenWidth * .75f + val maxPanY = screenHeight * .75f - fun zoom(factor: Float) { - if (factor < 0) { - val diffFactor = factor / (zoomFactor - 1f) - // zooming out - val panXDiff = abs(panX * diffFactor) - val panYDiff = abs(panY * diffFactor) - if (DEBUG) { - Timber.d( - "zoomFactor=$zoomFactor, factor=$factor, panX=$panX, panY=$panY, panXDiff=$panXDiff, panYDiff=$panYDiff", - ) + fun reset(resetRotate: Boolean) { + zoomFactor = 1f + panX = 0f + panY = 0f + if (resetRotate) rotation = 0f } - if (panX > 0f) { - panX -= panXDiff - } else if (panX < 0f) { - panX += panXDiff + + fun pan( + xFactor: Int, + yFactor: Int, + ) { + if (xFactor != 0) { + panX = (panX + with(density) { xFactor.dp.toPx() }).coerceIn(-maxPanX, maxPanX) + } + if (yFactor != 0) { + panY = (panY + with(density) { yFactor.dp.toPx() }).coerceIn(-maxPanY, maxPanY) + } } - if (panY > 0f) { - panY -= panYDiff - } else if (panY < 0f) { - panY += panYDiff + + fun zoom(factor: Float) { + if (factor < 0) { + val diffFactor = factor / (zoomFactor - 1f) + // zooming out + val panXDiff = abs(panX * diffFactor) + val panYDiff = abs(panY * diffFactor) + if (DEBUG) { + Timber.d( + "zoomFactor=$zoomFactor, factor=$factor, panX=$panX, panY=$panY, panXDiff=$panXDiff, panYDiff=$panYDiff", + ) + } + if (panX > 0f) { + panX -= panXDiff + } else if (panX < 0f) { + panX += panXDiff + } + if (panY > 0f) { + panY -= panYDiff + } else if (panY < 0f) { + panY += panYDiff + } + } + zoomFactor = (zoomFactor + factor).coerceIn(1f, 5f) + if (!isZoomed) { + // Always reset if not zoomed + panX = 0f + panY = 0f + } } - } - zoomFactor = (zoomFactor + factor).coerceIn(1f, 5f) - if (!isZoomed) { - // Always reset if not zoomed - panX = 0f - panY = 0f - } - } - val player = viewModel.player - val presentationState = rememberPresentationState(player) - LaunchedEffect(slideshowActive) { - player.repeatMode = - if (slideshowState.enabled) Player.REPEAT_MODE_OFF else Player.REPEAT_MODE_ONE - } + val player = viewModel.player + val presentationState = rememberPresentationState(player) + LaunchedEffect(slideshowActive) { + player.repeatMode = + if (slideshowState.enabled) Player.REPEAT_MODE_OFF else Player.REPEAT_MODE_ONE + } - var longPressing by remember { mutableStateOf(false) } + var longPressing by remember { mutableStateOf(false) } - Box( - modifier = - modifier - .background(Color.Black) - .focusRequester(focusRequester) - .focusable() - .onKeyEvent { - val isOverlayShowing = showOverlay || showFilterDialog - var result = false - if (!isOverlayShowing) { - if (longPressing && it.type == KeyEventType.KeyUp) { - // User stopped long pressing, so cancel the zooming action, but still consume the event so it doesn't move the image - longPressing = false - return@onKeyEvent true - } - longPressing = - it.nativeKeyEvent.isLongPress || - it.nativeKeyEvent.repeatCount > 0 - if (longPressing) { - when (it.key) { - Key.DirectionUp -> zoom(.05f) - Key.DirectionDown -> zoom(-.05f) + Box( + modifier = + modifier + .background(Color.Black) + .focusRequester(focusRequester) + .focusable() + .onKeyEvent { + val isOverlayShowing = showOverlay || showFilterDialog + var result = false + if (!isOverlayShowing) { + if (longPressing && it.type == KeyEventType.KeyUp) { + // User stopped long pressing, so cancel the zooming action, but still consume the event so it doesn't move the image + longPressing = false + return@onKeyEvent true + } + longPressing = + it.nativeKeyEvent.isLongPress || + it.nativeKeyEvent.repeatCount > 0 + if (longPressing) { + when (it.key) { + Key.DirectionUp -> zoom(.05f) + Key.DirectionDown -> zoom(-.05f) - // These work, but feel awkward because Up/Down zoom, so you can't long press them to pan - // Key.DirectionLeft -> panX += with(density) { 15.dp.toPx() } - // Key.DirectionRight -> panX -= with(density) { 15.dp.toPx() } + // These work, but feel awkward because Up/Down zoom, so you can't long press them to pan + // Key.DirectionLeft -> panX += with(density) { 15.dp.toPx() } + // Key.DirectionRight -> panX -= with(density) { 15.dp.toPx() } + } + return@onKeyEvent true + } } - return@onKeyEvent true - } + if (it.type != KeyEventType.KeyUp) { + result = false + } else if (!isOverlayShowing && isZoomed && isDirectionalDpad(it)) { + // Image is zoomed in + when (it.key) { + Key.DirectionLeft -> pan(30, 0) + Key.DirectionRight -> pan(-30, 0) + Key.DirectionUp -> pan(0, 30) + Key.DirectionDown -> pan(0, -30) + } + result = true + } else if (!isOverlayShowing && isZoomed && it.key == Key.Back) { + reset(false) + result = true + } else if (!isOverlayShowing && (it.key == Key.DirectionLeft || it.key == Key.DirectionRight)) { + when (it.key) { + Key.DirectionLeft, Key.DirectionUpLeft, Key.DirectionDownLeft -> { + if (!viewModel.previousImage()) { + Toast + .makeText( + context, + R.string.slideshow_at_beginning, + Toast.LENGTH_SHORT, + ).show() + } + } + + Key.DirectionRight, Key.DirectionUpRight, Key.DirectionDownRight -> { + if (!viewModel.nextImage()) { + Toast + .makeText( + context, + R.string.no_more_images, + Toast.LENGTH_SHORT, + ).show() + } + } + } + } else if (isOverlayShowing && it.key == Key.Back) { + showOverlay = false + viewModel.unpauseSlideshow() + result = true + } else if (!isOverlayShowing && (isDpad(it) || isEnterKey(it))) { + showOverlay = true + viewModel.pauseSlideshow() + result = true + } + if (result) { + // Handled the key, so reset the slideshow timer + viewModel.pulseSlideshow() + } + result + }, + ) { + when (val st = loadingState) { + ImageLoadingState.Error -> { + ErrorMessage("Error loading image", null, modifier) + } + + ImageLoadingState.Loading -> { + LoadingPage(modifier, false) } - if (it.type != KeyEventType.KeyUp) { - result = false - } else if (!isOverlayShowing && isZoomed && isDirectionalDpad(it)) { - // Image is zoomed in - when (it.key) { - Key.DirectionLeft -> pan(30, 0) - Key.DirectionRight -> pan(-30, 0) - Key.DirectionUp -> pan(0, 30) - Key.DirectionDown -> pan(0, -30) + + is ImageLoadingState.Success -> { + val imageState = st.image + LaunchedEffect(imageState) { + reset(true) } - result = true - } else if (!isOverlayShowing && isZoomed && it.key == Key.Back) { - reset(false) - result = true - } else if (!isOverlayShowing && (it.key == Key.DirectionLeft || it.key == Key.DirectionRight)) { - when (it.key) { - Key.DirectionLeft, Key.DirectionUpLeft, Key.DirectionDownLeft -> { - if (!viewModel.previousImage()) { - Toast - .makeText( - context, - R.string.slideshow_at_beginning, - Toast.LENGTH_SHORT, - ).show() + if (imageState.image.data.mediaType == MediaType.VIDEO) { + LaunchedEffect(imageState.id) { + val mediaItem = + MediaItem + .Builder() + .setUri(imageState.url) + .build() + player.setMediaItem(mediaItem) + player.repeatMode = + if (slideshowState.enabled) { + Player.REPEAT_MODE_OFF + } else { + Player.REPEAT_MODE_ONE + } + player.prepare() + player.play() + viewModel.pulseSlideshow(Long.MAX_VALUE) + } + LifecycleStartEffect(Unit) { + onStopOrDispose { + player.stop() } } + val contentScale = ContentScale.Fit + val scaledModifier = + Modifier.resizeWithContentScale( + contentScale, + presentationState.videoSizeDp, + ) + PlayerSurface( + player = player, + surfaceType = SURFACE_TYPE_SURFACE_VIEW, + modifier = + scaledModifier + .fillMaxSize() + .graphicsLayer { + scaleX = zoomAnimation + scaleY = zoomAnimation + translationX = panXAnimation + translationY = panYAnimation + }.rotate(rotateAnimation), + ) + if (presentationState.coverSurface) { + Box( + Modifier + .matchParentSize() + .background(Color.Black), + ) + } + } else { + val colorFilter = + remember(imageState.id, imageFilter) { + if (imageFilter.hasImageFilter()) { + ColorMatrixColorFilter(imageFilter.colorMatrix) + } else { + null + } + } + // If the image loading is large, show the thumbnail while waiting + // TODO + val showLoadingThumbnail = true + SubcomposeAsyncImage( + modifier = + Modifier + .fillMaxSize() + .graphicsLayer { + scaleX = zoomAnimation + scaleY = zoomAnimation + translationX = panXAnimation + translationY = panYAnimation - Key.DirectionRight, Key.DirectionUpRight, Key.DirectionDownRight -> { - if (!viewModel.nextImage()) { + val xTransform = + (screenWidth - panXAnimation) / (screenWidth * 2) + val yTransform = + (screenHeight - panYAnimation) / (screenHeight * 2) + if (DEBUG) { + Timber.d( + "graphicsLayer: xTransform=$xTransform, yTransform=$yTransform", + ) + } + + transformOrigin = + TransformOrigin(xTransform, yTransform) + }.rotate(rotateAnimation), + model = + ImageRequest + .Builder(LocalContext.current) + .data(imageState.url) + .size(Size.ORIGINAL) + .transitionFactory(CrossFadeFactory(750.milliseconds)) + .useExistingImageAsPlaceholder(true) + .build(), + contentDescription = null, + contentScale = ContentScale.Fit, + colorFilter = colorFilter, + error = { + Text( + modifier = + Modifier + .align(Alignment.Center), + text = "Error loading image", + color = MaterialTheme.colorScheme.onBackground, + ) + }, + // Ensure that if an image takes a long time to load, it won't be skipped + onLoading = { + viewModel.pulseSlideshow(Long.MAX_VALUE) + }, + onSuccess = { + viewModel.pulseSlideshow() + }, + onError = { + Timber.e( + it.result.throwable, + "Error loading image ${imageState.id}", + ) Toast .makeText( context, - R.string.no_more_images, - Toast.LENGTH_SHORT, + "Error loading image: ${it.result.throwable.localizedMessage}", + Toast.LENGTH_LONG, ).show() - } - } + viewModel.pulseSlideshow() + }, + ) } - } else if (isOverlayShowing && it.key == Key.Back) { - showOverlay = false - viewModel.unpauseSlideshow() - result = true - } else if (!isOverlayShowing && (isDpad(it) || isEnterKey(it))) { - showOverlay = true - viewModel.pauseSlideshow() - result = true - } - if (result) { - // Handled the key, so reset the slideshow timer - viewModel.pulseSlideshow() } - result - }, - ) { - when (val st = loadingState) { - ImageLoadingState.Error -> { - ErrorMessage("Error loading image", null, modifier) - } - - ImageLoadingState.Loading -> { - LoadingPage(modifier, false) - } - - is ImageLoadingState.Success -> { - val imageState = st.image - LaunchedEffect(imageState) { - reset(true) } - if (imageState.image.data.mediaType == MediaType.VIDEO) { - LaunchedEffect(imageState.id) { - val mediaItem = - MediaItem - .Builder() - .setUri(imageState.url) - .build() - player.setMediaItem(mediaItem) - player.repeatMode = - if (slideshowState.enabled) { - Player.REPEAT_MODE_OFF - } else { - Player.REPEAT_MODE_ONE - } - player.prepare() - player.play() - viewModel.pulseSlideshow(Long.MAX_VALUE) - } - LifecycleStartEffect(Unit) { - onStopOrDispose { - player.stop() - } - } - val contentScale = ContentScale.Fit - val scaledModifier = - Modifier.resizeWithContentScale( - contentScale, - presentationState.videoSizeDp, - ) - PlayerSurface( - player = player, - surfaceType = SURFACE_TYPE_SURFACE_VIEW, - modifier = - scaledModifier - .fillMaxSize() - .graphicsLayer { - scaleX = zoomAnimation - scaleY = zoomAnimation - translationX = panXAnimation - translationY = panYAnimation - }.rotate(rotateAnimation), - ) - if (presentationState.coverSurface) { - Box( - Modifier - .matchParentSize() - .background(Color.Black), - ) - } - } else { - val colorFilter = - remember(imageState.id, imageFilter) { - if (imageFilter.hasImageFilter()) { - ColorMatrixColorFilter(imageFilter.colorMatrix) - } else { - null - } - } - // If the image loading is large, show the thumbnail while waiting - // TODO - val showLoadingThumbnail = true - SubcomposeAsyncImage( - modifier = - Modifier - .fillMaxSize() - .graphicsLayer { - scaleX = zoomAnimation - scaleY = zoomAnimation - translationX = panXAnimation - translationY = panYAnimation + AnimatedVisibility( + showOverlay, + enter = slideInVertically { it }, + exit = slideOutVertically { it }, + modifier = Modifier.align(Alignment.BottomStart), + ) { + when (val st = loadingState) { + ImageLoadingState.Error -> {} - val xTransform = - (screenWidth - panXAnimation) / (screenWidth * 2) - val yTransform = - (screenHeight - panYAnimation) / (screenHeight * 2) - if (DEBUG) { - Timber.d( - "graphicsLayer: xTransform=$xTransform, yTransform=$yTransform", - ) - } + ImageLoadingState.Loading -> {} - transformOrigin = TransformOrigin(xTransform, yTransform) - }.rotate(rotateAnimation), - model = - ImageRequest - .Builder(LocalContext.current) - .data(imageState.url) - .apply { - if (isZoomed) size(Size.ORIGINAL) - }.transitionFactory(CrossFadeFactory(750.milliseconds)) - .useExistingImageAsPlaceholder(true) - .build(), - contentDescription = null, - contentScale = ContentScale.Fit, - colorFilter = colorFilter, - error = { - Text( + is ImageLoadingState.Success -> { + val imageState = st.image + ImageOverlay( modifier = Modifier - .align(Alignment.Center), - text = "Error loading image", - color = MaterialTheme.colorScheme.onBackground, - ) - }, - // Ensure that if an image takes a long time to load, it won't be skipped - onLoading = { - viewModel.pulseSlideshow(Long.MAX_VALUE) - }, - onSuccess = { - viewModel.pulseSlideshow() - }, - onError = { - Timber.e( - it.result.throwable, - "Error loading image ${imageState.id}", + .fillMaxWidth() + .background(AppColors.TransparentBlack50), + onDismiss = { showOverlay = false }, + player = player, + slideshowControls = slideshowControls, + slideshowEnabled = slideshowState.enabled, + image = imageState, + position = position, + count = pager?.size ?: -1, + onClickItem = {}, + onLongClickItem = {}, + onZoom = ::zoom, + onRotate = { rotation += it }, + onReset = { reset(true) }, + onShowFilterDialogClick = { + showFilterDialog = true + showOverlay = false + viewModel.pauseSlideshow() + }, ) - Toast - .makeText( - context, - "Error loading image: ${it.result.throwable.localizedMessage}", - Toast.LENGTH_LONG, - ).show() - viewModel.pulseSlideshow() - }, - ) + } + } } - } - } - AnimatedVisibility( - showOverlay, - enter = slideInVertically { it }, - exit = slideOutVertically { it }, - modifier = Modifier.align(Alignment.BottomStart), - ) { - when (val st = loadingState) { - ImageLoadingState.Error -> {} - - ImageLoadingState.Loading -> {} - - is ImageLoadingState.Success -> { - val imageState = st.image - ImageOverlay( - modifier = - Modifier - .fillMaxWidth() - .background(AppColors.TransparentBlack50), - onDismiss = { showOverlay = false }, - player = player, - slideshowControls = slideshowControls, - slideshowEnabled = slideshowState.enabled, - image = imageState, - position = position, - count = pager?.size ?: -1, - onClickItem = {}, - onLongClickItem = {}, - onZoom = ::zoom, - onRotate = { rotation += it }, - onReset = { reset(true) }, - onShowFilterDialogClick = { - showFilterDialog = true - showOverlay = false - viewModel.pauseSlideshow() + AnimatedVisibility(showFilterDialog) { + ImageFilterDialog( + filter = imageFilter, + showVideoOptions = false, + showSaveGalleryButton = true, + onChange = viewModel::updateImageFilter, + onClickSave = viewModel::saveImageFilter, + onClickSaveGallery = viewModel::saveGalleryFilter, + onDismissRequest = { + showFilterDialog = false + viewModel.unpauseSlideshow() + viewModel.pulseSlideshow() }, ) } } } - AnimatedVisibility(showFilterDialog) { - ImageFilterDialog( - filter = imageFilter, - showVideoOptions = false, - showSaveGalleryButton = true, - onChange = viewModel::updateImageFilter, - onClickSave = viewModel::saveImageFilter, - onClickSaveGallery = viewModel::saveGalleryFilter, - onDismissRequest = { - showFilterDialog = false - viewModel.unpauseSlideshow() - viewModel.pulseSlideshow() - }, - ) - } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowViewModel.kt index 3a4f3bce9..f9abe15d6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowViewModel.kt @@ -16,6 +16,7 @@ import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.PlaybackEffect import com.github.damontecres.wholphin.data.model.VideoFilter import com.github.damontecres.wholphin.preferences.AppPreference +import com.github.damontecres.wholphin.preferences.PlayerBackend import com.github.damontecres.wholphin.services.ImageUrlService import com.github.damontecres.wholphin.services.PlayerFactory import com.github.damontecres.wholphin.services.ScreensaverService @@ -30,6 +31,7 @@ import com.github.damontecres.wholphin.ui.util.ThrottledLiveData import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler +import com.github.damontecres.wholphin.util.LoadingState import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -77,9 +79,8 @@ class SlideshowViewModel fun create(slideshow: Destination.Slideshow): SlideshowViewModel } - val player by lazy { - playerFactory.createVideoPlayer() - } + lateinit var player: Player + private set private var saveFilters = true @@ -96,7 +97,6 @@ class SlideshowViewModel var slideshowDelay by Delegates.notNull() - // private val album = MutableLiveData() private val _pager = MutableLiveData>() val pager: LiveData> = _pager.map { it } val position = MutableLiveData(0) @@ -104,6 +104,8 @@ class SlideshowViewModel private val _image = MutableLiveData() val image: LiveData = _image + val loading = MutableStateFlow(LoadingState.Pending) + val loadingState = MutableLiveData(ImageLoadingState.Loading) private val _imageFilter = MutableLiveData(VideoFilter()) val imageFilter = ThrottledLiveData(_imageFilter, 500L) @@ -113,58 +115,67 @@ class SlideshowViewModel init { addCloseable { screensaverService.keepScreenOn(false) - player.removeListener(this@SlideshowViewModel) - player.release() + if (this@SlideshowViewModel::player.isInitialized) { + player.removeListener(this@SlideshowViewModel) + player.release() + } } - player.addListener(this@SlideshowViewModel) viewModelScope.launchIO { - val photoPrefs = userPreferencesService.getCurrent().appPreferences.photoPreferences - slideshowDelay = - photoPrefs.slideshowDuration.takeIf { it >= AppPreference.SlideshowDuration.min } - ?: AppPreference.SlideshowDuration.defaultValue -// val album = -// api.userLibraryApi -// .getItem( -// itemId = slideshowSettings.parentId, -// ).content -// .let { BaseItem(it, false) } -// this@SlideshowViewModel.album.setValueOnMain(album) - val includeItemTypes = - if (photoPrefs.slideshowPlayVideos) { - listOf(BaseItemKind.PHOTO, BaseItemKind.VIDEO) - } else { - listOf(BaseItemKind.PHOTO) - } - val request = - slideshowSettings.filter.filter.applyTo( - GetItemsRequest( - parentId = slideshowSettings.parentId, - includeItemTypes = includeItemTypes, - fields = PhotoItemFields, - recursive = slideshowSettings.recursive, - sortBy = listOf(slideshowSettings.sortAndDirection.sort), - sortOrder = listOf(slideshowSettings.sortAndDirection.direction), - ), - ) - serverRepository.currentUser.value?.let { user -> - val filter = - playbackEffectDao - .getPlaybackEffect( - user.rowId, - slideshowSettings.parentId, - BaseItemKind.PHOTO_ALBUM, - )?.videoFilter - if (filter != null) { - Timber.v("Got filter for album %s", slideshowSettings.parentId) - albumImageFilter = filter + try { + val appPreferences = userPreferencesService.getCurrent().appPreferences + val playerCreation = + playerFactory.createVideoPlayer( + backend = PlayerBackend.EXO_PLAYER, + appPreferences.playbackPreferences, + ) + player = playerCreation.player + player.addListener(this@SlideshowViewModel) + + val photoPrefs = appPreferences.photoPreferences + slideshowDelay = + photoPrefs.slideshowDuration.takeIf { it >= AppPreference.SlideshowDuration.min } + ?: AppPreference.SlideshowDuration.defaultValue + val includeItemTypes = + if (photoPrefs.slideshowPlayVideos) { + listOf(BaseItemKind.PHOTO, BaseItemKind.VIDEO) + } else { + listOf(BaseItemKind.PHOTO) + } + val request = + slideshowSettings.filter.filter.applyTo( + GetItemsRequest( + parentId = slideshowSettings.parentId, + includeItemTypes = includeItemTypes, + fields = PhotoItemFields, + recursive = slideshowSettings.recursive, + sortBy = listOf(slideshowSettings.sortAndDirection.sort), + sortOrder = listOf(slideshowSettings.sortAndDirection.direction), + ), + ) + serverRepository.currentUser.value?.let { user -> + val filter = + playbackEffectDao + .getPlaybackEffect( + user.rowId, + slideshowSettings.parentId, + BaseItemKind.PHOTO_ALBUM, + )?.videoFilter + if (filter != null) { + Timber.v("Got filter for album %s", slideshowSettings.parentId) + albumImageFilter = filter + } } + val pager = + ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope) + .init(slideshowSettings.index) + this@SlideshowViewModel._pager.setValueOnMain(pager) + loading.update { LoadingState.Success } + updatePosition(slideshowSettings.index)?.join() + if (slideshowSettings.startSlideshow) onMain { startSlideshow() } + } catch (ex: Exception) { + Timber.e(ex, "Error") + loading.update { LoadingState.Error(ex) } } - val pager = - ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope) - .init(slideshowSettings.index) - this@SlideshowViewModel._pager.setValueOnMain(pager) - updatePosition(slideshowSettings.index)?.join() - if (slideshowSettings.startSlideshow) onMain { startSlideshow() } } } diff --git a/app/src/main/proto/WholphinDataStore.proto b/app/src/main/proto/WholphinDataStore.proto index ce9080a7f..a817fbae1 100644 --- a/app/src/main/proto/WholphinDataStore.proto +++ b/app/src/main/proto/WholphinDataStore.proto @@ -40,14 +40,21 @@ message MpvOptions{ bool use_gpu_next = 2; } +enum AssPlaybackMode{ + ASS_LIBASS = 0; + ASS_EXO_PLAYER = 1; + ASS_TRANSCODE = 2; +} + message PlaybackOverrides{ bool ac3_supported = 1; bool downmix_stereo = 2; - bool direct_play_ass = 3; + bool direct_play_ass = 3 [deprecated = true]; bool direct_play_pgs = 4; MediaExtensionStatus media_extensions_enabled = 5; bool direct_play_dolby_vision_e_l = 6; bool decode_av1 = 7; + AssPlaybackMode ass_playback_mode = 8; } message PlaybackPreferences { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 154aeeae1..68b2f5d38 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -312,7 +312,7 @@ Check for updates Applies to TV Series only - Direct play ASS subtitles + Use libass for ASS subtitles Direct play PGS subtitles Always downmix to stereo Use FFmpeg decoder module @@ -757,4 +757,18 @@ Discover Movies Studios in %1$s + Direct play with libass + Direct play with ExoPlayer built-in + Burn in/transcode on server + SSA/ASS subtitle playback + + @string/ass_subtitle_mode_libass + @string/ass_subtitle_mode_exoplayer + @string/ass_subtitle_mode_transcode + + + @string/default_track + + + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ad6fa0593..df0d67315 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -40,6 +40,7 @@ preferenceKtx = "1.2.1" tvprovider = "1.1.0" workRuntimeKtx = "2.11.2" paletteKtx = "1.0.0" +assMedia = "0.4.0" kotlinxCoroutinesTest = "1.10.2" coreTesting = "2.2.0" openapi-generator = "7.21.0" @@ -139,6 +140,8 @@ kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-t androidx-core-testing = { module = "androidx.arch.core:core-testing", version.ref = "coreTesting" } androidx-runner = { group = "androidx.test", name = "runner", version.ref = "runner" } +ass-media = { group = "io.github.peerless2012", name = "ass-media", version.ref = "assMedia" } + [plugins] android-application = { id = "com.android.application", version.ref = "agp" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }