From 09ae2598878102b55366e108d12fc33f6a3f597b Mon Sep 17 00:00:00 2001 From: Damontecres Date: Wed, 24 Dec 2025 17:48:29 -0500 Subject: [PATCH 1/8] Integrate with libass-android --- app/build.gradle.kts | 1 + .../wholphin/services/PlayerFactory.kt | 61 +++++++++++++++---- .../wholphin/ui/playback/PlaybackPage.kt | 40 +++++++++++- .../wholphin/ui/playback/PlaybackViewModel.kt | 6 +- gradle/libs.versions.toml | 3 + 5 files changed, 98 insertions(+), 13 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f564ca847..c8ae975b9 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -197,6 +197,7 @@ dependencies { implementation(libs.androidx.media3.exoplayer.hls) 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/services/PlayerFactory.kt b/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt index c59ba81d1..5850f05a7 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 @@ -7,14 +7,23 @@ import androidx.annotation.OptIn import androidx.datastore.core.DataStore 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.RenderersFactory +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory +import androidx.media3.extractor.DefaultExtractorsFactory 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.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.flow.firstOrNull import kotlinx.coroutines.runBlocking import timber.log.Timber @@ -35,12 +44,12 @@ class PlayerFactory var currentPlayer: Player? = null private set - fun createVideoPlayer(): Player { + fun createVideoPlayer(): PlayerCreation { if (currentPlayer?.isReleased == false) { Timber.w("Player was not released before trying to create a new one!") currentPlayer?.release() } - + var assHandler: AssHandler? = null val prefs = runBlocking { appPreferences.data.firstOrNull()?.playbackPreferences } val backend = prefs?.playerBackend ?: AppPreference.PlayerBackendPref.defaultValue val newPlayer = @@ -61,9 +70,11 @@ class PlayerFactory PlayerBackend.EXO_PLAYER, PlayerBackend.UNRECOGNIZED, -> { - val extensions = - runBlocking { appPreferences.data.firstOrNull() }?.playbackPreferences?.overrides?.mediaExtensionsEnabled - Timber.v("extensions=$extensions") + val extensions = prefs?.overrides?.mediaExtensionsEnabled + val directPlayAss = + prefs?.overrides?.directPlayAss + ?: AppPreference.DirectPlayAss.defaultValue + Timber.v("extensions=$extensions, directPlayAss=$directPlayAss") val rendererMode = when (extensions) { MediaExtensionStatus.MES_FALLBACK -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON @@ -71,20 +82,43 @@ 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 = + DefaultRenderersFactory(context) + .setEnableDecoderFallback(true) + .setExtensionRendererMode(rendererMode) + val mediaSourceFactory = + if (directPlayAss) { + 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( - DefaultRenderersFactory(context) - .setEnableDecoderFallback(true) - .setExtensionRendererMode(rendererMode), - ).build() + .setMediaSourceFactory(mediaSourceFactory) + .setRenderersFactory(renderersFactory) + .build() .apply { + assHandler?.init(this) playWhenReady = true } } } currentPlayer = newPlayer - return newPlayer + return PlayerCreation(newPlayer, assHandler) } } @@ -96,3 +130,8 @@ val Player.isReleased: Boolean else -> throw IllegalStateException("Unknown Player type: ${this::class.qualifiedName}") } } + +data class PlayerCreation( + val player: Player, + val assHandler: AssHandler? = 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 e27ea11f6..2955b4697 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,13 +43,16 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.layout.onGloballyPositioned 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.util.UnstableApi @@ -80,10 +86,12 @@ 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 import org.jellyfin.sdk.model.extensions.ticks +import timber.log.Timber import java.util.UUID import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds @@ -286,10 +294,14 @@ fun PlaybackPage( .focusRequester(focusRequester) .focusable(), ) { + var playerSize by remember { mutableStateOf(IntSize.Zero) } PlayerSurface( player = player, surfaceType = SURFACE_TYPE_SURFACE_VIEW, - modifier = scaledModifier, + modifier = + scaledModifier.onGloballyPositioned { + playerSize = it.size + }, ) if (presentationState.coverSurface) { val isLoading by rememberPlayerLoadingState(player) @@ -383,6 +395,21 @@ fun PlaybackPage( setFixedTextSize(Dimension.SP, it.fontSize.toFloat()) setBottomPaddingFraction(it.margin.toFloat() / 100f) } + viewModel.assHandler?.let { assHandler -> + if (prefs.overrides.directPlayAss) { + 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 = { @@ -391,6 +418,17 @@ fun PlaybackPage( preferences.appPreferences.interfacePreferences.subtitlesPreferences .calculateEdgeSize(density), ).apply(it) + it.children.firstOrNull { it is AssSubtitleView }?.let { + (it as? AssSubtitleView)?.apply { + Timber.v("Resize: $playerSize") + layoutParams = + FrameLayout + .LayoutParams( + playerSize.width, + playerSize.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 1d380e08c..4e71a1e81 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 @@ -63,6 +63,7 @@ import com.github.damontecres.wholphin.util.subtitleMimeTypes import com.github.damontecres.wholphin.util.supportItemKinds 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 @@ -130,8 +131,11 @@ class PlaybackViewModel ) : ViewModel(), Player.Listener, AnalyticsListener { + var assHandler: AssHandler? = null val player by lazy { - playerFactory.createVideoPlayer() + val creation = playerFactory.createVideoPlayer() + assHandler = creation.assHandler + creation.player } internal val mutex = Mutex() diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 79d1ae5c6..7a5ffa423 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -33,6 +33,7 @@ hilt = "2.57.2" room = "2.8.4" preferenceKtx = "1.2.1" paletteKtx = "1.0.0" +assMedia = "0.4.0-alpha01" [libraries] aboutlibraries-core = { module = "com.mikepenz:aboutlibraries-core", version.ref = "aboutLibraries" } @@ -103,6 +104,8 @@ androidx-preference-ktx = { group = "androidx.preference", name = "preference-kt androidx-room-testing = { group = "androidx.room", name = "room-testing", version.ref = "room" } androidx-palette-ktx = { group = "androidx.palette", name = "palette-ktx", version.ref = "paletteKtx" } +ass-media = { group = "io.github.peerless2012", name = "ass-media", version.ref = "assMedia" } + [plugins] android-application = { id = "com.android.application", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } From fc5babac457cba2870d01775c228bf7a147897e3 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Thu, 5 Mar 2026 20:58:36 -0500 Subject: [PATCH 2/8] Refactor slideshow to use new player creation --- .../wholphin/ui/slideshow/SlideshowPage.kt | 751 +++++++++--------- .../ui/slideshow/SlideshowViewModel.kt | 114 +-- 2 files changed, 447 insertions(+), 418 deletions(-) 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 f35bc1025..3b57017e4 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 @@ -67,6 +67,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 @@ -88,413 +89,429 @@ 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 - } - } - LaunchedEffect(imageState) { - reset(true) - } - val player = viewModel.player - val presentationState = rememberPresentationState(player) - LaunchedEffect(slideshowActive) { - player.repeatMode = - if (slideshowState.enabled) Player.REPEAT_MODE_OFF else Player.REPEAT_MODE_ONE - } + LaunchedEffect(imageState) { + reset(true) + } + 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) } - val contentModifier = - Modifier - .clickable( - interactionSource = null, - indication = null, - onClick = { - showOverlay = !showOverlay - }, - ) + val contentModifier = + Modifier + .clickable( + interactionSource = null, + indication = null, + onClick = { + showOverlay = !showOverlay + }, + ) - 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() } - } - 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() + // 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 } } + 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() + 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 } - } - } 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() + if (result) { + // Handled the key, so reset the slideshow timer + viewModel.pulseSlideshow() + } + result + }, + ) { + when (loadingState) { + ImageLoadingState.Error -> { + ErrorMessage("Error loading image", null, modifier) } - result - }, - ) { - when (loadingState) { - ImageLoadingState.Error -> { - ErrorMessage("Error loading image", null, modifier) - } - ImageLoadingState.Loading -> { - LoadingPage(modifier) - } + ImageLoadingState.Loading -> { + LoadingPage(modifier) + } - is ImageLoadingState.Success -> { - imageState?.let { imageState -> - 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 + is ImageLoadingState.Success -> { + imageState?.let { imageState -> + 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) } - player.prepare() - player.play() - viewModel.pulseSlideshow(Long.MAX_VALUE) - } - LifecycleStartEffect(Unit) { - onStopOrDispose { - player.stop() - } - } - val contentScale = ContentScale.Fit - val scaledModifier = - contentModifier.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 + LifecycleStartEffect(Unit) { + onStopOrDispose { + player.stop() + } } - } - // If the image loading is large, show the thumbnail while waiting - // TODO - val showLoadingThumbnail = true - SubcomposeAsyncImage( - modifier = - contentModifier - .fillMaxSize() - .graphicsLayer { - scaleX = zoomAnimation - scaleY = zoomAnimation - translationX = panXAnimation - translationY = panYAnimation - - 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) - .crossfade(!showLoadingThumbnail) - .build(), - contentDescription = null, - contentScale = ContentScale.Fit, - colorFilter = colorFilter, - error = { - Text( + val contentScale = ContentScale.Fit + val scaledModifier = + contentModifier.resizeWithContentScale( + contentScale, + presentationState.videoSizeDp, + ) + PlayerSurface( + player = player, + surfaceType = SURFACE_TYPE_SURFACE_VIEW, modifier = - Modifier - .align(Alignment.Center), - text = "Error loading image", - color = MaterialTheme.colorScheme.onBackground, + scaledModifier + .fillMaxSize() + .graphicsLayer { + scaleX = zoomAnimation + scaleY = zoomAnimation + translationX = panXAnimation + translationY = panYAnimation + }.rotate(rotateAnimation), ) - }, - loading = { - ImageLoadingPlaceholder( - thumbnailUrl = imageState.thumbnailUrl, - showThumbnail = showLoadingThumbnail, + 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 = + contentModifier + .fillMaxSize() + .graphicsLayer { + scaleX = zoomAnimation + scaleY = zoomAnimation + translationX = panXAnimation + translationY = panYAnimation + + 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) + .crossfade(!showLoadingThumbnail) + .build(), + contentDescription = null, + contentScale = ContentScale.Fit, colorFilter = colorFilter, - modifier = Modifier.fillMaxSize(), - ) - }, - // 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}", + error = { + Text( + modifier = + Modifier + .align(Alignment.Center), + text = "Error loading image", + color = MaterialTheme.colorScheme.onBackground, + ) + }, + loading = { + ImageLoadingPlaceholder( + thumbnailUrl = imageState.thumbnailUrl, + showThumbnail = showLoadingThumbnail, + colorFilter = colorFilter, + modifier = Modifier.fillMaxSize(), + ) + }, + // 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, + "Error loading image: ${it.result.throwable.localizedMessage}", + Toast.LENGTH_LONG, + ).show() + viewModel.pulseSlideshow() + }, ) - 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), + ) { + imageState?.let { imageState -> + ImageOverlay( + modifier = + contentModifier + .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( - showOverlay, - enter = slideInVertically { it }, - exit = slideOutVertically { it }, - modifier = Modifier.align(Alignment.BottomStart), - ) { - imageState?.let { imageState -> - ImageOverlay( - modifier = - contentModifier - .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() - }, - ) - } } } 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 992659cad..b86814aca 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 @@ -104,6 +105,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 +116,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 = true, - 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 = true, + 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() } } } From 5db6d2c901b0cc4b60aa852c4ec420d7b03077f9 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Thu, 5 Mar 2026 20:59:01 -0500 Subject: [PATCH 3/8] Update resize logic & use libass-media 0.4.0 --- .../wholphin/ui/playback/PlaybackPage.kt | 18 +++++++++++------- gradle/libs.versions.toml | 2 +- 2 files changed, 12 insertions(+), 8 deletions(-) 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 a4ce04e21..326831694 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 @@ -461,13 +461,17 @@ fun PlaybackPageContent( .apply(it) it.children.firstOrNull { it is AssSubtitleView }?.let { (it as? AssSubtitleView)?.apply { - Timber.v("Resize: $playerSize") - layoutParams = - FrameLayout - .LayoutParams( - playerSize.width, - playerSize.height, - ).apply { gravity = Gravity.CENTER } + val resized = + layoutParams.let { it.width != playerSize.width || it.height != playerSize.height } + if (resized) { + Timber.v("Resizing AssSubtitleView: $playerSize") + layoutParams = + FrameLayout + .LayoutParams( + playerSize.width, + playerSize.height, + ).apply { gravity = Gravity.CENTER } + } } } }, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7cba58602..10d9ad97b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -41,7 +41,7 @@ tvprovider = "1.1.0" workRuntimeKtx = "2.11.1" paletteKtx = "1.0.0" openapi-generator = "7.19.0" -assMedia = "0.4.0-beta01" +assMedia = "0.4.0" kotlinxCoroutinesTest = "1.10.2" coreTesting = "2.2.0" runner = "1.7.0" From 30f96e2c58a827ebcaf47afd90dd30b5845f960d Mon Sep 17 00:00:00 2001 From: Damontecres Date: Thu, 5 Mar 2026 21:09:28 -0500 Subject: [PATCH 4/8] Don't show subtitles until player presentation is ready --- .../wholphin/ui/playback/PlaybackPage.kt | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) 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 326831694..c7a4bf10f 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 @@ -45,7 +45,7 @@ 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.onGloballyPositioned +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 @@ -327,13 +327,13 @@ fun PlaybackPageContent( .focusRequester(focusRequester) .focusable(), ) { - var playerSize by remember { mutableStateOf(IntSize.Zero) } + var playerSurfaceSize by remember { mutableStateOf(IntSize.Zero) } PlayerSurface( player = player, surfaceType = SURFACE_TYPE_SURFACE_VIEW, modifier = - scaledModifier.onGloballyPositioned { - playerSize = it.size + scaledModifier.onSizeChanged { + playerSurfaceSize = it }, ) if (presentationState.coverSurface) { @@ -427,7 +427,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( @@ -462,14 +462,14 @@ fun PlaybackPageContent( it.children.firstOrNull { it is AssSubtitleView }?.let { (it as? AssSubtitleView)?.apply { val resized = - layoutParams.let { it.width != playerSize.width || it.height != playerSize.height } + layoutParams.let { it.width != playerSurfaceSize.width || it.height != playerSurfaceSize.height } if (resized) { - Timber.v("Resizing AssSubtitleView: $playerSize") + Timber.v("Resizing AssSubtitleView: $playerSurfaceSize") layoutParams = FrameLayout .LayoutParams( - playerSize.width, - playerSize.height, + playerSurfaceSize.width, + playerSurfaceSize.height, ).apply { gravity = Gravity.CENTER } } } From f06f4f0bad0ae322648672b4c7dbbf28e0ac7968 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Thu, 5 Mar 2026 21:13:31 -0500 Subject: [PATCH 5/8] Change preference text --- .../damontecres/wholphin/services/PlayerFactory.kt | 9 +++------ app/src/main/res/values/strings.xml | 2 +- 2 files changed, 4 insertions(+), 7 deletions(-) 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 225c4b694..a5d6fc78c 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 @@ -20,7 +20,6 @@ import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.media3.exoplayer.video.MediaCodecVideoRenderer import androidx.media3.exoplayer.video.VideoRendererEventListener import androidx.media3.extractor.DefaultExtractorsFactory -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 @@ -77,11 +76,9 @@ class PlayerFactory PlayerBackend.EXO_PLAYER, PlayerBackend.UNRECOGNIZED, -> { - val extensions = prefs?.overrides?.mediaExtensionsEnabled - val directPlayAss = - prefs?.overrides?.directPlayAss - ?: AppPreference.DirectPlayAss.defaultValue - val decodeAv1 = prefs?.overrides?.decodeAv1 == true + val extensions = prefs.overrides.mediaExtensionsEnabled + val directPlayAss = prefs.overrides.directPlayAss + val decodeAv1 = prefs.overrides.decodeAv1 Timber.v("extensions=$extensions, directPlayAss=$directPlayAss") val rendererMode = when (extensions) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c7a2fb922..5136c83a0 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 From 2aaea19f096a961931dc99fc622c3413a8d65f7c Mon Sep 17 00:00:00 2001 From: Damontecres Date: Thu, 5 Mar 2026 21:14:55 -0500 Subject: [PATCH 6/8] Fix bad merge --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 10d9ad97b..a4de22685 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -40,10 +40,10 @@ preferenceKtx = "1.2.1" tvprovider = "1.1.0" workRuntimeKtx = "2.11.1" paletteKtx = "1.0.0" -openapi-generator = "7.19.0" assMedia = "0.4.0" kotlinxCoroutinesTest = "1.10.2" coreTesting = "2.2.0" +openapi-generator = "7.20.0" runner = "1.7.0" [libraries] From 77062652fa4bc3e844b5894374f85db03a956dd0 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Mon, 30 Mar 2026 13:55:27 -0400 Subject: [PATCH 7/8] WIP libass options --- .../wholphin/preferences/AppPreference.kt | 20 ++++++++++--------- .../preferences/AppPreferencesSerializer.kt | 4 +++- .../wholphin/services/AppUpgradeHandler.kt | 2 +- .../wholphin/services/DeviceProfileService.kt | 7 ++++--- .../wholphin/services/PlayerFactory.kt | 12 ++++++++--- .../wholphin/ui/playback/PlaybackPage.kt | 3 ++- app/src/main/proto/WholphinDataStore.proto | 9 ++++++++- app/src/main/res/values/strings.xml | 13 ++++++++++++ 8 files changed, 51 insertions(+), 19 deletions(-) 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 e88c1ffb7..765e41d25 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 @@ -430,16 +430,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.app_theme, + 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( @@ -1097,7 +1099,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..68047896b 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,12 @@ 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 c1507d9f1..1c35a2705 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 @@ -131,7 +131,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 } } 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 a5d6fc78c..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 @@ -21,6 +21,7 @@ import androidx.media3.exoplayer.video.MediaCodecVideoRenderer import androidx.media3.exoplayer.video.VideoRendererEventListener 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 @@ -77,9 +78,14 @@ class PlayerFactory PlayerBackend.UNRECOGNIZED, -> { val extensions = prefs.overrides.mediaExtensionsEnabled - val directPlayAss = prefs.overrides.directPlayAss + val useLibAss = + prefs.overrides.assPlaybackMode == AssPlaybackMode.ASS_LIBASS val decodeAv1 = prefs.overrides.decodeAv1 - Timber.v("extensions=$extensions, directPlayAss=$directPlayAss") + Timber.v( + "extensions=%s, assPlaybackMode=%s", + extensions, + prefs.overrides.assPlaybackMode, + ) val rendererMode = when (extensions) { MediaExtensionStatus.MES_FALLBACK -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON @@ -94,7 +100,7 @@ class PlayerFactory .setEnableDecoderFallback(true) .setExtensionRendererMode(rendererMode) val mediaSourceFactory = - if (directPlayAss) { + if (useLibAss) { assHandler = AssHandler(AssRenderType.OVERLAY_OPEN_GL) val assSubtitleParserFactory = AssSubtitleParserFactory(assHandler) renderersFactory = AssRenderersFactory(assHandler, renderersFactory) 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 edea557d3..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 @@ -66,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 @@ -440,7 +441,7 @@ fun PlaybackPageContent( setBottomPaddingFraction(it.margin.toFloat() / 100f) } playerState.assHandler?.let { assHandler -> - if (prefs.overrides.directPlayAss) { + if (prefs.overrides.assPlaybackMode == AssPlaybackMode.ASS_LIBASS) { Timber.v("Adding AssSubtitleView") addView( AssSubtitleView(context, assHandler).apply { 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 ca8c32be5..37c866dcf 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -749,4 +749,17 @@ Separate types Prefer showing logos for titles + Direct play with libass + Direct play with ExoPlayer built-in + Burn in/transcode on server + + @string/ass_subtitle_mode_libass + @string/ass_subtitle_mode_exoplayer + @string/ass_subtitle_mode_transcode + + + @string/default_track + + + From 1255f3c845cd90106955e97a0ce22232343fc76e Mon Sep 17 00:00:00 2001 From: Damontecres Date: Tue, 7 Apr 2026 16:43:03 -0400 Subject: [PATCH 8/8] Add work around for incorrect libc++_shared.so packaging --- app/build.gradle.kts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d5cfeeac0..6221f85ad 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -152,6 +152,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") {