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

## 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
Replaces reverted #569
Fixes #1107
This commit is contained in:
Ray 2026-04-15 17:50:34 -04:00 committed by GitHub
parent 95859be3c6
commit b18416c954
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 626 additions and 521 deletions

View file

@ -171,6 +171,12 @@ android {
isUniversalApk = true isUniversalApk = true
} }
} }
packaging {
jniLibs {
// Work around because libass-android & wholphin-mpv both (incorrectly) package libc++_shared.so
pickFirsts += "lib/*/libc++_shared.so"
}
}
sourceSets { sourceSets {
getByName("main") { getByName("main") {
@ -269,6 +275,7 @@ dependencies {
implementation(libs.androidx.media3.exoplayer.dash) implementation(libs.androidx.media3.exoplayer.dash)
implementation(libs.androidx.media3.ui) implementation(libs.androidx.media3.ui)
implementation(libs.androidx.media3.ui.compose) implementation(libs.androidx.media3.ui.compose)
implementation(libs.ass.media)
implementation(libs.coil.core) implementation(libs.coil.core)
implementation(libs.coil.compose) implementation(libs.coil.compose)

View file

@ -431,16 +431,18 @@ sealed interface AppPreference<Pref, T> {
summaryOn = R.string.enabled, summaryOn = R.string.enabled,
summaryOff = R.string.disabled, summaryOff = R.string.disabled,
) )
val DirectPlayAss = val AssSubtitleMode =
AppSwitchPreference<AppPreferences>( AppChoicePreference<AppPreferences, AssPlaybackMode>(
title = R.string.direct_play_ass, title = R.string.ass_subtitle_playback,
defaultValue = true, defaultValue = AssPlaybackMode.ASS_LIBASS,
getter = { it.playbackPreferences.overrides.directPlayAss }, getter = { it.playbackPreferences.overrides.assPlaybackMode },
setter = { prefs, value -> setter = { prefs, value ->
prefs.updatePlaybackOverrides { directPlayAss = value } prefs.updatePlaybackOverrides { assPlaybackMode = value }
}, },
summaryOn = R.string.enabled, displayValues = R.array.ass_subtitle_modes,
summaryOff = R.string.disabled, 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 = val DirectPlayPgs =
AppSwitchPreference<AppPreferences>( AppSwitchPreference<AppPreferences>(
@ -1100,7 +1102,7 @@ private val ExoPlayerSettings =
AppPreference.FfmpegPreference, AppPreference.FfmpegPreference,
AppPreference.DownMixStereo, AppPreference.DownMixStereo,
AppPreference.Ac3Supported, AppPreference.Ac3Supported,
AppPreference.DirectPlayAss, AppPreference.AssSubtitleMode,
AppPreference.DirectPlayPgs, AppPreference.DirectPlayPgs,
AppPreference.DirectPlayDoviProfile7, AppPreference.DirectPlayDoviProfile7,
AppPreference.DecodeAv1, AppPreference.DecodeAv1,

View file

@ -61,10 +61,11 @@ class AppPreferencesSerializer
.apply { .apply {
ac3Supported = AppPreference.Ac3Supported.defaultValue ac3Supported = AppPreference.Ac3Supported.defaultValue
downmixStereo = AppPreference.DownMixStereo.defaultValue downmixStereo = AppPreference.DownMixStereo.defaultValue
directPlayAss = AppPreference.DirectPlayAss.defaultValue // directPlayAss = AppPreference.DirectPlayAss.defaultValue
directPlayPgs = AppPreference.DirectPlayPgs.defaultValue directPlayPgs = AppPreference.DirectPlayPgs.defaultValue
mediaExtensionsEnabled = mediaExtensionsEnabled =
AppPreference.FfmpegPreference.defaultValue AppPreference.FfmpegPreference.defaultValue
assPlaybackMode = AppPreference.AssSubtitleMode.defaultValue
}.build() }.build()
mpvOptions = mpvOptions =

View file

@ -141,7 +141,7 @@ class AppUpgradeHandler
it.updatePlaybackOverrides { it.updatePlaybackOverrides {
ac3Supported = AppPreference.Ac3Supported.defaultValue ac3Supported = AppPreference.Ac3Supported.defaultValue
downmixStereo = AppPreference.DownMixStereo.defaultValue downmixStereo = AppPreference.DownMixStereo.defaultValue
directPlayAss = AppPreference.DirectPlayAss.defaultValue // directPlayAss = AppPreference.DirectPlayAss.defaultValue
directPlayPgs = AppPreference.DirectPlayPgs.defaultValue directPlayPgs = AppPreference.DirectPlayPgs.defaultValue
} }
} }
@ -319,5 +319,13 @@ class AppUpgradeHandler
} }
} }
} }
if (previous.isEqualOrBefore(Version.fromString("0.6.2-1-g0"))) {
appPreferences.updateData {
it.updatePlaybackOverrides {
assPlaybackMode = AppPreference.AssSubtitleMode.defaultValue
}
}
}
} }
} }

View file

@ -1,6 +1,7 @@
package com.github.damontecres.wholphin.services package com.github.damontecres.wholphin.services
import android.content.Context import android.content.Context
import com.github.damontecres.wholphin.preferences.AssPlaybackMode
import com.github.damontecres.wholphin.preferences.PlaybackPreferences import com.github.damontecres.wholphin.preferences.PlaybackPreferences
import com.github.damontecres.wholphin.util.profile.MediaCodecCapabilitiesTest import com.github.damontecres.wholphin.util.profile.MediaCodecCapabilitiesTest
import com.github.damontecres.wholphin.util.profile.createDeviceProfile import com.github.damontecres.wholphin.util.profile.createDeviceProfile
@ -43,7 +44,7 @@ class DeviceProfileService
maxBitrate = prefs.maxBitrate.toInt(), maxBitrate = prefs.maxBitrate.toInt(),
isAC3Enabled = prefs.overrides.ac3Supported, isAC3Enabled = prefs.overrides.ac3Supported,
downMixAudio = prefs.overrides.downmixStereo, downMixAudio = prefs.overrides.downmixStereo,
assDirectPlay = prefs.overrides.directPlayAss, assPlaybackMode = prefs.overrides.assPlaybackMode,
pgsDirectPlay = prefs.overrides.directPlayPgs, pgsDirectPlay = prefs.overrides.directPlayPgs,
dolbyVisionELDirectPlay = prefs.overrides.directPlayDolbyVisionEL, dolbyVisionELDirectPlay = prefs.overrides.directPlayDolbyVisionEL,
decodeAv1 = prefs.overrides.decodeAv1, decodeAv1 = prefs.overrides.decodeAv1,
@ -58,7 +59,7 @@ class DeviceProfileService
maxBitrate = newConfig.maxBitrate, maxBitrate = newConfig.maxBitrate,
isAC3Enabled = newConfig.isAC3Enabled, isAC3Enabled = newConfig.isAC3Enabled,
downMixAudio = newConfig.downMixAudio, downMixAudio = newConfig.downMixAudio,
assDirectPlay = newConfig.assDirectPlay, assDirectPlay = newConfig.assPlaybackMode != AssPlaybackMode.ASS_TRANSCODE,
pgsDirectPlay = newConfig.pgsDirectPlay, pgsDirectPlay = newConfig.pgsDirectPlay,
dolbyVisionELDirectPlay = newConfig.dolbyVisionELDirectPlay, dolbyVisionELDirectPlay = newConfig.dolbyVisionELDirectPlay,
decodeAv1 = prefs.overrides.decodeAv1, decodeAv1 = prefs.overrides.decodeAv1,
@ -77,7 +78,7 @@ data class DeviceProfileConfiguration(
val maxBitrate: Int, val maxBitrate: Int,
val isAC3Enabled: Boolean, val isAC3Enabled: Boolean,
val downMixAudio: Boolean, val downMixAudio: Boolean,
val assDirectPlay: Boolean, val assPlaybackMode: AssPlaybackMode,
val pgsDirectPlay: Boolean, val pgsDirectPlay: Boolean,
val dolbyVisionELDirectPlay: Boolean, val dolbyVisionELDirectPlay: Boolean,
val decodeAv1: Boolean, val decodeAv1: Boolean,

View file

@ -10,22 +10,29 @@ import androidx.datastore.core.DataStore
import androidx.media3.common.C import androidx.media3.common.C
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DefaultDataSource
import androidx.media3.exoplayer.DefaultRenderersFactory import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.Renderer import androidx.media3.exoplayer.Renderer
import androidx.media3.exoplayer.RenderersFactory
import androidx.media3.exoplayer.mediacodec.MediaCodecSelector import androidx.media3.exoplayer.mediacodec.MediaCodecSelector
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.exoplayer.video.MediaCodecVideoRenderer import androidx.media3.exoplayer.video.MediaCodecVideoRenderer
import androidx.media3.exoplayer.video.VideoRendererEventListener 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.AppPreferences
import com.github.damontecres.wholphin.preferences.AssPlaybackMode
import com.github.damontecres.wholphin.preferences.MediaExtensionStatus import com.github.damontecres.wholphin.preferences.MediaExtensionStatus
import com.github.damontecres.wholphin.preferences.PlaybackPreferences import com.github.damontecres.wholphin.preferences.PlaybackPreferences
import com.github.damontecres.wholphin.preferences.PlayerBackend import com.github.damontecres.wholphin.preferences.PlayerBackend
import com.github.damontecres.wholphin.util.mpv.MpvPlayer import com.github.damontecres.wholphin.util.mpv.MpvPlayer
import dagger.hilt.android.qualifiers.ApplicationContext 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.Dispatchers
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import timber.log.Timber import timber.log.Timber
import java.lang.reflect.Constructor import java.lang.reflect.Constructor
@ -46,71 +53,17 @@ class PlayerFactory
var currentPlayer: Player? = null var currentPlayer: Player? = null
private set 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( suspend fun createVideoPlayer(
backend: PlayerBackend, backend: PlayerBackend,
prefs: PlaybackPreferences, prefs: PlaybackPreferences,
): Player { ): PlayerCreation {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
if (currentPlayer?.isReleased == false) { if (currentPlayer?.isReleased == false) {
Timber.w("Player was not released before trying to create a new one!") Timber.w("Player was not released before trying to create a new one!")
currentPlayer?.release() currentPlayer?.release()
} }
} }
var assHandler: AssHandler? = null
val newPlayer = val newPlayer =
when (backend) { when (backend) {
PlayerBackend.PREFER_MPV, PlayerBackend.PREFER_MPV,
@ -125,8 +78,14 @@ class PlayerFactory
PlayerBackend.UNRECOGNIZED, PlayerBackend.UNRECOGNIZED,
-> { -> {
val extensions = prefs.overrides.mediaExtensionsEnabled val extensions = prefs.overrides.mediaExtensionsEnabled
val useLibAss =
prefs.overrides.assPlaybackMode == AssPlaybackMode.ASS_LIBASS
val decodeAv1 = prefs.overrides.decodeAv1 val decodeAv1 = prefs.overrides.decodeAv1
Timber.v("extensions=$extensions") Timber.v(
"extensions=%s, assPlaybackMode=%s",
extensions,
prefs.overrides.assPlaybackMode,
)
val rendererMode = val rendererMode =
when (extensions) { when (extensions) {
MediaExtensionStatus.MES_FALLBACK -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON MediaExtensionStatus.MES_FALLBACK -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON
@ -134,17 +93,42 @@ class PlayerFactory
MediaExtensionStatus.MES_DISABLED -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF MediaExtensionStatus.MES_DISABLED -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF
else -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON else -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON
} }
ExoPlayer val dataSourceFactory = DefaultDataSource.Factory(context)
.Builder(context) val extractorsFactory = DefaultExtractorsFactory()
.setRenderersFactory( var renderersFactory: RenderersFactory =
WholphinRenderersFactory(context, decodeAv1) WholphinRenderersFactory(context, decodeAv1)
.setEnableDecoderFallback(true) .setEnableDecoderFallback(true)
.setExtensionRendererMode(rendererMode), .setExtensionRendererMode(rendererMode)
).build() val mediaSourceFactory =
if (useLibAss) {
assHandler = AssHandler(AssRenderType.OVERLAY_OPEN_GL)
val assSubtitleParserFactory = AssSubtitleParserFactory(assHandler)
renderersFactory = AssRenderersFactory(assHandler, renderersFactory)
DefaultMediaSourceFactory(
dataSourceFactory,
extractorsFactory.withAssMkvSupport(
assSubtitleParserFactory,
assHandler,
),
).setSubtitleParserFactory(assSubtitleParserFactory)
} else {
DefaultMediaSourceFactory(
dataSourceFactory,
extractorsFactory,
)
}
ExoPlayer
.Builder(context)
.setMediaSourceFactory(mediaSourceFactory)
.setRenderersFactory(renderersFactory)
.build()
.apply {
assHandler?.init(this)
}
} }
} }
currentPlayer = newPlayer currentPlayer = newPlayer
return newPlayer return PlayerCreation(newPlayer, assHandler)
} }
} }
@ -157,6 +141,11 @@ val Player.isReleased: Boolean
} }
} }
data class PlayerCreation(
val player: Player,
val assHandler: AssHandler? = null,
)
// Code is adapted from https://github.com/androidx/media/blob/release/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/DefaultRenderersFactory.java#L436 // Code is adapted from https://github.com/androidx/media/blob/release/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/DefaultRenderersFactory.java#L436
class WholphinRenderersFactory( class WholphinRenderersFactory(
context: Context, context: Context,

View file

@ -1,5 +1,8 @@
package com.github.damontecres.wholphin.ui.playback 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.activity.compose.BackHandler
import androidx.annotation.Dimension import androidx.annotation.Dimension
import androidx.annotation.OptIn import androidx.annotation.OptIn
@ -40,16 +43,18 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.intl.Locale import androidx.compose.ui.text.intl.Locale
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import androidx.core.view.children
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.LifecycleStartEffect import androidx.lifecycle.compose.LifecycleStartEffect
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.SubtitleView import androidx.media3.ui.SubtitleView
import androidx.media3.ui.compose.PlayerSurface import androidx.media3.ui.compose.PlayerSurface
@ -61,6 +66,7 @@ import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.surfaceColorAtElevation import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.data.model.Playlist 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.PlayerBackend
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.preferences.skipBackOnResume import com.github.damontecres.wholphin.preferences.skipBackOnResume
@ -79,6 +85,7 @@ import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.Media3SubtitleOverride import com.github.damontecres.wholphin.util.Media3SubtitleOverride
import com.github.damontecres.wholphin.util.mpv.MpvPlayer import com.github.damontecres.wholphin.util.mpv.MpvPlayer
import io.github.peerless2012.ass.media.widget.AssSubtitleView
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -123,8 +130,7 @@ fun PlaybackPage(
LoadingState.Success -> { LoadingState.Success -> {
val playerState by viewModel.currentPlayer.collectAsState() val playerState by viewModel.currentPlayer.collectAsState()
PlaybackPageContent( PlaybackPageContent(
player = playerState!!.player, playerState = playerState!!,
playerBackend = playerState!!.backend,
preferences = preferences, preferences = preferences,
destination = destination, destination = destination,
viewModel = viewModel, viewModel = viewModel,
@ -137,13 +143,15 @@ fun PlaybackPage(
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
@Composable @Composable
fun PlaybackPageContent( fun PlaybackPageContent(
player: Player, playerState: PlayerState,
playerBackend: PlayerBackend,
preferences: UserPreferences, preferences: UserPreferences,
destination: Destination, destination: Destination,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: PlaybackViewModel, viewModel: PlaybackViewModel,
) { ) {
val player = playerState.player
val playerBackend = playerState.backend
val prefs = preferences.appPreferences.playbackPreferences val prefs = preferences.appPreferences.playbackPreferences
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val configuration = LocalConfiguration.current val configuration = LocalConfiguration.current
@ -316,10 +324,14 @@ fun PlaybackPageContent(
.focusRequester(focusRequester) .focusRequester(focusRequester)
.focusable(), .focusable(),
) { ) {
var playerSurfaceSize by remember { mutableStateOf(IntSize.Zero) }
PlayerSurface( PlayerSurface(
player = player, player = player,
surfaceType = SURFACE_TYPE_SURFACE_VIEW, surfaceType = SURFACE_TYPE_SURFACE_VIEW,
modifier = scaledModifier, modifier =
scaledModifier.onSizeChanged {
playerSurfaceSize = it
},
) )
if (presentationState.coverSurface) { if (presentationState.coverSurface) {
Box( Box(
@ -415,7 +427,7 @@ fun PlaybackPageContent(
remember(subtitleSettings) { subtitleSettings.imageSubtitleOpacity / 100f } remember(subtitleSettings) { subtitleSettings.imageSubtitleOpacity / 100f }
// Subtitles // Subtitles
if (skipIndicatorDuration == 0L && currentItemPlayback.subtitleIndexEnabled) { if (skipIndicatorDuration == 0L && currentItemPlayback.subtitleIndexEnabled && !presentationState.coverSurface) {
val maxSize by animateFloatAsState(if (controllerViewState.controlsVisible) .7f else 1f) val maxSize by animateFloatAsState(if (controllerViewState.controlsVisible) .7f else 1f)
val isImageSubtitles = remember(cues) { cues.firstOrNull()?.bitmap != null } val isImageSubtitles = remember(cues) { cues.firstOrNull()?.bitmap != null }
AndroidView( AndroidView(
@ -426,12 +438,42 @@ fun PlaybackPageContent(
setFixedTextSize(Dimension.SP, it.fontSize.toFloat()) setFixedTextSize(Dimension.SP, it.fontSize.toFloat())
setBottomPaddingFraction(it.margin.toFloat() / 100f) setBottomPaddingFraction(it.margin.toFloat() / 100f)
} }
playerState.assHandler?.let { assHandler ->
if (prefs.overrides.assPlaybackMode == AssPlaybackMode.ASS_LIBASS) {
Timber.v("Adding AssSubtitleView")
addView(
AssSubtitleView(context, assHandler).apply {
layoutParams =
FrameLayout
.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
).apply { gravity = Gravity.CENTER }
},
)
}
}
} }
}, },
update = { update = {
it.setCues(cues) it.setCues(cues)
Media3SubtitleOverride(subtitleSettings.calculateEdgeSize(density)) Media3SubtitleOverride(subtitleSettings.calculateEdgeSize(density))
.apply(it) .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 = { onReset = {
it.setCues(null) it.setCues(null)

View file

@ -75,6 +75,7 @@ import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import io.github.peerless2012.ass.media.AssHandler
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@ -221,7 +222,8 @@ class PlaybackViewModel
isHdr: Boolean, isHdr: Boolean,
is4k: Boolean, is4k: Boolean,
) { ) {
val softwareDecoding = !preferences.appPreferences.playbackPreferences.mpvOptions.enableHardwareDecoding val softwareDecoding =
!preferences.appPreferences.playbackPreferences.mpvOptions.enableHardwareDecoding
val playerBackend = val playerBackend =
when (preferences.appPreferences.playbackPreferences.playerBackend) { when (preferences.appPreferences.playbackPreferences.playerBackend) {
PlayerBackend.UNRECOGNIZED, PlayerBackend.UNRECOGNIZED,
@ -240,13 +242,14 @@ class PlaybackViewModel
disconnectPlayer() disconnectPlayer()
} }
player = val playerCreation =
playerFactory.createVideoPlayer( playerFactory.createVideoPlayer(
playerBackend, playerBackend,
preferences.appPreferences.playbackPreferences, preferences.appPreferences.playbackPreferences,
) )
this.player = playerCreation.player
currentPlayer.update { currentPlayer.update {
PlayerState(player, playerBackend) PlayerState(playerCreation.player, playerBackend, playerCreation.assHandler)
} }
configurePlayer() configurePlayer()
} }
@ -1450,6 +1453,7 @@ class PlaybackViewModel
data class PlayerState( data class PlayerState(
val player: Player, val player: Player,
val backend: PlayerBackend, val backend: PlayerBackend,
val assHandler: AssHandler?,
) )
data class MediaSegmentState( data class MediaSegmentState(

View file

@ -69,6 +69,7 @@ import com.github.damontecres.wholphin.ui.playback.isDirectionalDpad
import com.github.damontecres.wholphin.ui.playback.isDpad import com.github.damontecres.wholphin.ui.playback.isDpad
import com.github.damontecres.wholphin.ui.playback.isEnterKey import com.github.damontecres.wholphin.ui.playback.isEnterKey
import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.LoadingState
import org.jellyfin.sdk.model.api.MediaType import org.jellyfin.sdk.model.api.MediaType
import timber.log.Timber import timber.log.Timber
import kotlin.math.abs import kotlin.math.abs
@ -92,7 +93,20 @@ fun SlideshowPage(
), ),
) { ) {
val context = LocalContext.current val context = LocalContext.current
val loading by viewModel.loading.collectAsState()
when (val st = loading) {
is LoadingState.Error -> {
ErrorMessage(st, modifier)
}
LoadingState.Loading,
LoadingState.Pending,
-> {
LoadingPage(modifier)
}
LoadingState.Success -> {
val loadingState by viewModel.loadingState.observeAsState(ImageLoadingState.Loading) val loadingState by viewModel.loadingState.observeAsState(ImageLoadingState.Loading)
val imageFilter by viewModel.imageFilter.observeAsState(VideoFilter()) val imageFilter by viewModel.imageFilter.observeAsState(VideoFilter())
val position by viewModel.position.observeAsState(0) val position by viewModel.position.observeAsState(0)
@ -389,15 +403,15 @@ fun SlideshowPage(
) )
} }
transformOrigin = TransformOrigin(xTransform, yTransform) transformOrigin =
TransformOrigin(xTransform, yTransform)
}.rotate(rotateAnimation), }.rotate(rotateAnimation),
model = model =
ImageRequest ImageRequest
.Builder(LocalContext.current) .Builder(LocalContext.current)
.data(imageState.url) .data(imageState.url)
.apply { .size(Size.ORIGINAL)
if (isZoomed) size(Size.ORIGINAL) .transitionFactory(CrossFadeFactory(750.milliseconds))
}.transitionFactory(CrossFadeFactory(750.milliseconds))
.useExistingImageAsPlaceholder(true) .useExistingImageAsPlaceholder(true)
.build(), .build(),
contentDescription = null, contentDescription = null,
@ -492,3 +506,5 @@ fun SlideshowPage(
} }
} }
} }
}
}

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.PlaybackEffect
import com.github.damontecres.wholphin.data.model.VideoFilter import com.github.damontecres.wholphin.data.model.VideoFilter
import com.github.damontecres.wholphin.preferences.AppPreference 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.ImageUrlService
import com.github.damontecres.wholphin.services.PlayerFactory import com.github.damontecres.wholphin.services.PlayerFactory
import com.github.damontecres.wholphin.services.ScreensaverService 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.ApiRequestPager
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.LoadingState
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
@ -77,9 +79,8 @@ class SlideshowViewModel
fun create(slideshow: Destination.Slideshow): SlideshowViewModel fun create(slideshow: Destination.Slideshow): SlideshowViewModel
} }
val player by lazy { lateinit var player: Player
playerFactory.createVideoPlayer() private set
}
private var saveFilters = true private var saveFilters = true
@ -96,7 +97,6 @@ class SlideshowViewModel
var slideshowDelay by Delegates.notNull<Long>() var slideshowDelay by Delegates.notNull<Long>()
// private val album = MutableLiveData<BaseItem>()
private val _pager = MutableLiveData<ApiRequestPager<GetItemsRequest>>() private val _pager = MutableLiveData<ApiRequestPager<GetItemsRequest>>()
val pager: LiveData<List<BaseItem?>> = _pager.map { it } val pager: LiveData<List<BaseItem?>> = _pager.map { it }
val position = MutableLiveData(0) val position = MutableLiveData(0)
@ -104,6 +104,8 @@ class SlideshowViewModel
private val _image = MutableLiveData<ImageState>() private val _image = MutableLiveData<ImageState>()
val image: LiveData<ImageState> = _image val image: LiveData<ImageState> = _image
val loading = MutableStateFlow<LoadingState>(LoadingState.Pending)
val loadingState = MutableLiveData<ImageLoadingState>(ImageLoadingState.Loading) val loadingState = MutableLiveData<ImageLoadingState>(ImageLoadingState.Loading)
private val _imageFilter = MutableLiveData(VideoFilter()) private val _imageFilter = MutableLiveData(VideoFilter())
val imageFilter = ThrottledLiveData(_imageFilter, 500L) val imageFilter = ThrottledLiveData(_imageFilter, 500L)
@ -113,22 +115,26 @@ class SlideshowViewModel
init { init {
addCloseable { addCloseable {
screensaverService.keepScreenOn(false) screensaverService.keepScreenOn(false)
if (this@SlideshowViewModel::player.isInitialized) {
player.removeListener(this@SlideshowViewModel) player.removeListener(this@SlideshowViewModel)
player.release() player.release()
} }
player.addListener(this@SlideshowViewModel) }
viewModelScope.launchIO { viewModelScope.launchIO {
val photoPrefs = userPreferencesService.getCurrent().appPreferences.photoPreferences 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 = slideshowDelay =
photoPrefs.slideshowDuration.takeIf { it >= AppPreference.SlideshowDuration.min } photoPrefs.slideshowDuration.takeIf { it >= AppPreference.SlideshowDuration.min }
?: AppPreference.SlideshowDuration.defaultValue ?: AppPreference.SlideshowDuration.defaultValue
// val album =
// api.userLibraryApi
// .getItem(
// itemId = slideshowSettings.parentId,
// ).content
// .let { BaseItem(it, false) }
// this@SlideshowViewModel.album.setValueOnMain(album)
val includeItemTypes = val includeItemTypes =
if (photoPrefs.slideshowPlayVideos) { if (photoPrefs.slideshowPlayVideos) {
listOf(BaseItemKind.PHOTO, BaseItemKind.VIDEO) listOf(BaseItemKind.PHOTO, BaseItemKind.VIDEO)
@ -163,8 +169,13 @@ class SlideshowViewModel
ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope) ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope)
.init(slideshowSettings.index) .init(slideshowSettings.index)
this@SlideshowViewModel._pager.setValueOnMain(pager) this@SlideshowViewModel._pager.setValueOnMain(pager)
loading.update { LoadingState.Success }
updatePosition(slideshowSettings.index)?.join() updatePosition(slideshowSettings.index)?.join()
if (slideshowSettings.startSlideshow) onMain { startSlideshow() } if (slideshowSettings.startSlideshow) onMain { startSlideshow() }
} catch (ex: Exception) {
Timber.e(ex, "Error")
loading.update { LoadingState.Error(ex) }
}
} }
} }

View file

@ -40,14 +40,21 @@ message MpvOptions{
bool use_gpu_next = 2; bool use_gpu_next = 2;
} }
enum AssPlaybackMode{
ASS_LIBASS = 0;
ASS_EXO_PLAYER = 1;
ASS_TRANSCODE = 2;
}
message PlaybackOverrides{ message PlaybackOverrides{
bool ac3_supported = 1; bool ac3_supported = 1;
bool downmix_stereo = 2; bool downmix_stereo = 2;
bool direct_play_ass = 3; bool direct_play_ass = 3 [deprecated = true];
bool direct_play_pgs = 4; bool direct_play_pgs = 4;
MediaExtensionStatus media_extensions_enabled = 5; MediaExtensionStatus media_extensions_enabled = 5;
bool direct_play_dolby_vision_e_l = 6; bool direct_play_dolby_vision_e_l = 6;
bool decode_av1 = 7; bool decode_av1 = 7;
AssPlaybackMode ass_playback_mode = 8;
} }
message PlaybackPreferences { message PlaybackPreferences {

View file

@ -312,7 +312,7 @@
<string name="check_for_updates">Check for updates</string> <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_summary">Applies to TV Series only</string>
<string name="combine_continue_next"><![CDATA[Combine Continue Watching & Next Up]]></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="direct_play_pgs">Direct play PGS subtitles</string>
<string name="downmix_stereo">Always downmix to stereo</string> <string name="downmix_stereo">Always downmix to stereo</string>
<string name="ffmpeg_extension_pref">Use FFmpeg decoder module</string> <string name="ffmpeg_extension_pref">Use FFmpeg decoder module</string>
@ -757,4 +757,18 @@
<string name="discover_movies">Discover Movies</string> <string name="discover_movies">Discover Movies</string>
<string name="studios_in">Studios in %1$s</string> <string name="studios_in">Studios in %1$s</string>
<string name="ass_subtitle_mode_libass">Direct play with libass</string>
<string name="ass_subtitle_mode_exoplayer">Direct play with ExoPlayer built-in</string>
<string name="ass_subtitle_mode_transcode">Burn in/transcode on server</string>
<string name="ass_subtitle_playback">SSA/ASS subtitle playback</string>
<array name="ass_subtitle_modes">
<item>@string/ass_subtitle_mode_libass</item>
<item>@string/ass_subtitle_mode_exoplayer</item>
<item>@string/ass_subtitle_mode_transcode</item>
</array>
<array name="ass_subtitle_modes_summary">
<item>@string/default_track</item>
<item></item><!-- Intentionally blank -->
<item></item><!-- Intentionally blank -->
</array>
</resources> </resources>

View file

@ -40,6 +40,7 @@ preferenceKtx = "1.2.1"
tvprovider = "1.1.0" tvprovider = "1.1.0"
workRuntimeKtx = "2.11.2" workRuntimeKtx = "2.11.2"
paletteKtx = "1.0.0" paletteKtx = "1.0.0"
assMedia = "0.4.0"
kotlinxCoroutinesTest = "1.10.2" kotlinxCoroutinesTest = "1.10.2"
coreTesting = "2.2.0" coreTesting = "2.2.0"
openapi-generator = "7.21.0" openapi-generator = "7.21.0"
@ -139,6 +140,8 @@ kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-t
androidx-core-testing = { module = "androidx.arch.core:core-testing", version.ref = "coreTesting" } androidx-core-testing = { module = "androidx.arch.core:core-testing", version.ref = "coreTesting" }
androidx-runner = { group = "androidx.test", name = "runner", version.ref = "runner" } androidx-runner = { group = "androidx.test", name = "runner", version.ref = "runner" }
ass-media = { group = "io.github.peerless2012", name = "ass-media", version.ref = "assMedia" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }