Integrate with libass-android to support SSA/ASS subtitles in ExoPlayer (#569)

## Description
Add better ASS/SSA support to ExoPlayer by using
[`libass-android`](https://github.com/peerless2012/libass-android).

This is enabled with the "Use libass for ASS subtitles" advanced
settings for ExoPlayer. It is enabled by default.

### Related issues
Related to #22
This commit is contained in:
Ray 2026-03-06 08:34:57 -05:00 committed by GitHub
parent 014bed1bf3
commit 83ebd922d9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 573 additions and 512 deletions

View file

@ -231,6 +231,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)

View file

@ -10,22 +10,28 @@ 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.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 +52,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 +77,9 @@ class PlayerFactory
PlayerBackend.UNRECOGNIZED,
-> {
val extensions = prefs.overrides.mediaExtensionsEnabled
val directPlayAss = prefs.overrides.directPlayAss
val decodeAv1 = prefs.overrides.decodeAv1
Timber.v("extensions=$extensions")
Timber.v("extensions=$extensions, directPlayAss=$directPlayAss")
val rendererMode =
when (extensions) {
MediaExtensionStatus.MES_FALLBACK -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON
@ -134,17 +87,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 (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(
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 +135,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,

View file

@ -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
@ -42,16 +45,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
@ -83,6 +88,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
@ -127,8 +133,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,
@ -141,13 +146,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
@ -320,10 +327,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(
@ -416,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(
@ -427,12 +438,42 @@ fun PlaybackPageContent(
setFixedTextSize(Dimension.SP, it.fontSize.toFloat())
setBottomPaddingFraction(it.margin.toFloat() / 100f)
}
playerState.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 = {
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)

View file

@ -73,6 +73,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
@ -218,7 +219,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,
@ -237,13 +239,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()
}
@ -1438,6 +1441,7 @@ class PlaybackViewModel
data class PlayerState(
val player: Player,
val backend: PlayerBackend,
val assHandler: AssHandler?,
)
data class MediaSegmentState(

View file

@ -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()
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 slideshowControls =
object : SlideshowControls {
override fun startSlideshow() {
showOverlay = false
viewModel.startSlideshow()
}
override fun stopSlideshow() {
viewModel.stopSlideshow()
}
when (val st = loading) {
is LoadingState.Error -> {
ErrorMessage(st, modifier)
}
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 slideshowState by viewModel.slideshow.collectAsState()
val slideshowActive by viewModel.slideshowActive.collectAsState(false)
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
focusRequester.tryRequestFocus()
}
val density = LocalDensity.current
val screenHeight = LocalWindowInfo.current.containerSize.height
val screenWidth = LocalWindowInfo.current.containerSize.width
val maxPanX = screenWidth * .75f
val maxPanY = screenHeight * .75f
fun reset(resetRotate: Boolean) {
zoomFactor = 1f
panX = 0f
panY = 0f
if (resetRotate) rotation = 0f
}
fun pan(
xFactor: Int,
yFactor: Int,
) {
if (xFactor != 0) {
panX = (panX + with(density) { xFactor.dp.toPx() }).coerceIn(-maxPanX, maxPanX)
LoadingState.Loading,
LoadingState.Pending,
-> {
LoadingPage(modifier)
}
if (yFactor != 0) {
panY = (panY + with(density) { yFactor.dp.toPx() }).coerceIn(-maxPanY, maxPanY)
}
}
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
}
}
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()
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 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) }
var longPressing by remember { mutableStateOf(false) }
val slideshowControls =
object : SlideshowControls {
override fun startSlideshow() {
showOverlay = false
viewModel.startSlideshow()
}
val contentModifier =
Modifier
.clickable(
interactionSource = null,
indication = null,
onClick = {
showOverlay = !showOverlay
},
override fun stopSlideshow() {
viewModel.stopSlideshow()
}
}
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",
)
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)
val slideshowState by viewModel.slideshow.collectAsState()
val slideshowActive by viewModel.slideshowActive.collectAsState(false)
// 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()
}
}
val focusRequester = remember { FocusRequester() }
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 (loadingState) {
ImageLoadingState.Error -> {
ErrorMessage("Error loading image", null, modifier)
LaunchedEffect(Unit) {
focusRequester.tryRequestFocus()
}
ImageLoadingState.Loading -> {
LoadingPage(modifier)
val density = LocalDensity.current
val screenHeight = LocalWindowInfo.current.containerSize.height
val screenWidth = LocalWindowInfo.current.containerSize.width
val maxPanX = screenWidth * .75f
val maxPanY = screenHeight * .75f
fun reset(resetRotate: Boolean) {
zoomFactor = 1f
panX = 0f
panY = 0f
if (resetRotate) rotation = 0f
}
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)
}
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),
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)
}
}
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 (presentationState.coverSurface) {
Box(
Modifier
.matchParentSize()
.background(Color.Black),
)
}
} else {
val colorFilter =
remember(imageState.id, imageFilter) {
if (imageFilter.hasImageFilter()) {
ColorMatrixColorFilter(imageFilter.colorMatrix)
} else {
null
}
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
}
}
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) }
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)
// 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 the image loading is large, show the thumbnail while waiting
// TODO
val showLoadingThumbnail = true
SubcomposeAsyncImage(
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 (loadingState) {
ImageLoadingState.Error -> {
ErrorMessage("Error loading image", null, 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
}
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
}
}
// 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(
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()
},
)
}
}
}
}
AnimatedVisibility(
showOverlay,
enter = slideInVertically { it },
exit = slideOutVertically { it },
modifier = Modifier.align(Alignment.BottomStart),
) {
imageState?.let { imageState ->
ImageOverlay(
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(
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()
.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()
},
)
}
}
}

View file

@ -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<ImageState>()
val image: LiveData<ImageState> = _image
val loading = MutableStateFlow<LoadingState>(LoadingState.Pending)
val loadingState = MutableLiveData<ImageLoadingState>(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()
}
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
}
if (this@SlideshowViewModel::player.isInitialized) {
player.removeListener(this@SlideshowViewModel)
player.release()
}
}
viewModelScope.launchIO {
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() }
}
}

View file

@ -312,7 +312,7 @@
<string name="check_for_updates">Check for updates</string>
<string name="combine_continue_next_summary">Applies to TV Series only</string>
<string name="combine_continue_next"><![CDATA[Combine Continue Watching & Next Up]]></string>
<string name="direct_play_ass">Direct play ASS subtitles</string>
<string name="direct_play_ass">Use libass for ASS subtitles</string>
<string name="direct_play_pgs">Direct play PGS subtitles</string>
<string name="downmix_stereo">Always downmix to stereo</string>
<string name="ffmpeg_extension_pref">Use FFmpeg decoder module</string>