Support Advanced SubStation Alpha direct play

This commit is contained in:
Niels van Velzen
2025-11-08 12:01:12 +01:00
committed by Niels van Velzen
parent e3062edc91
commit aa964647b5
10 changed files with 79 additions and 7 deletions

View File

@@ -138,6 +138,7 @@ dependencies {
implementation(libs.androidx.media3.exoplayer.hls)
implementation(libs.androidx.media3.ui)
implementation(libs.jellyfin.androidx.media3.ffmpeg.decoder)
implementation(libs.libass.media3)
// Markdown
implementation(libs.bundles.markwon)

View File

@@ -72,6 +72,7 @@ fun Scope.createPlaybackManager() = playbackManager(androidContext()) {
val userPreferences = get<UserPreferences>()
val exoPlayerOptions = ExoPlayerOptions(
preferFfmpeg = userPreferences[UserPreferences.preferExoPlayerFfmpeg],
enableLibass = userPreferences[UserPreferences.assDirectPlay],
enableDebugLogging = userPreferences[UserPreferences.debuggingEnabled],
baseDataSourceFactory = get<HttpDataSource.Factory>(),
)

View File

@@ -226,6 +226,11 @@ class UserPreferences(context: Context) : SharedPreferenceStore(
*/
var trickPlayEnabled = booleanPreference("trick_play_enabled", false)
/**
* Enable libass.
*/
var assDirectPlay = booleanPreference("libass_enabled", false)
/**
* Enable PGS subtitle direct-play.
*/

View File

@@ -39,6 +39,7 @@ import androidx.media3.exoplayer.source.DefaultMediaSourceFactory;
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector;
import androidx.media3.exoplayer.util.EventLogger;
import androidx.media3.extractor.DefaultExtractorsFactory;
import androidx.media3.extractor.ExtractorsFactory;
import androidx.media3.extractor.ts.TsExtractor;
import androidx.media3.ui.AspectRatioFrameLayout;
import androidx.media3.ui.CaptionStyleCompat;
@@ -59,6 +60,13 @@ import java.util.Collections;
import java.util.List;
import java.util.Optional;
import io.github.peerless2012.ass.media.AssHandler;
import io.github.peerless2012.ass.media.AssHandlerConfig;
import io.github.peerless2012.ass.media.factory.AssRenderersFactory;
import io.github.peerless2012.ass.media.kt.AssPlayerKt;
import io.github.peerless2012.ass.media.parser.AssSubtitleParserFactory;
import io.github.peerless2012.ass.media.type.AssRenderType;
import io.github.peerless2012.ass.media.widget.AssSubtitleView;
import timber.log.Timber;
@OptIn(markerClass = UnstableApi.class)
@@ -88,7 +96,10 @@ public class VideoManager {
_helper = helper;
nightModeEnabled = userPreferences.get(UserPreferences.Companion.getAudioNightMode());
mExoPlayer = configureExoplayerBuilder(activity).build();
boolean assDirectPlay = userPreferences.get(UserPreferences.Companion.getAssDirectPlay());
AssHandler assHandler = assDirectPlay ? new AssHandler(AssRenderType.OVERLAY_OPEN_GL, new AssHandlerConfig()) : null;
mExoPlayer = configureExoplayerBuilder(activity, assHandler).build();
if (userPreferences.get(UserPreferences.Companion.getDebuggingEnabled())) {
mExoPlayer.addAnalyticsListener(new EventLogger());
@@ -120,6 +131,11 @@ public class VideoManager {
mExoPlayerView.getSubtitleView().setBottomPaddingFraction(userPreferences.get(UserPreferences.Companion.getSubtitlesOffsetPosition()));
mExoPlayerView.getSubtitleView().setStyle(subtitleStyle);
if (assHandler != null) {
assHandler.init(mExoPlayer);
mExoPlayerView.getSubtitleView().addView(new AssSubtitleView(mActivity, assHandler));
}
mExoPlayer.addListener(new Player.Listener() {
@Override
public void onPlayerError(@NonNull PlaybackException error) {
@@ -198,7 +214,7 @@ public class VideoManager {
* @param context The associated context
* @return A configured builder for Exoplayer
*/
private ExoPlayer.Builder configureExoplayerBuilder(Context context) {
private ExoPlayer.Builder configureExoplayerBuilder(Context context, AssHandler assHandler) {
ExoPlayer.Builder exoPlayerBuilder = new ExoPlayer.Builder(context);
DefaultRenderersFactory defaultRendererFactory = new DefaultRenderersFactory(context);
defaultRendererFactory.setEnableDecoderFallback(true);
@@ -219,8 +235,17 @@ public class VideoManager {
extractorsFactory.setConstantBitrateSeekingEnabled(true);
extractorsFactory.setConstantBitrateSeekingAlwaysEnabled(true);
DefaultDataSource.Factory dataSourceFactory = new DefaultDataSource.Factory(context, exoPlayerHttpDataSourceFactory);
exoPlayerBuilder.setRenderersFactory(defaultRendererFactory);
exoPlayerBuilder.setMediaSourceFactory(new DefaultMediaSourceFactory(dataSourceFactory, extractorsFactory));
if (assHandler != null) {
AssSubtitleParserFactory assSubtitleParserFactory = new AssSubtitleParserFactory(assHandler);
ExtractorsFactory assExtractorsFactory = AssPlayerKt.withAssMkvSupport(extractorsFactory, assSubtitleParserFactory, assHandler);
DefaultMediaSourceFactory mediaSourceFactory = new DefaultMediaSourceFactory(dataSourceFactory, assExtractorsFactory);
mediaSourceFactory.setSubtitleParserFactory(assSubtitleParserFactory);
exoPlayerBuilder.setMediaSourceFactory(mediaSourceFactory);
exoPlayerBuilder.setRenderersFactory(new AssRenderersFactory(assHandler, defaultRendererFactory));
} else {
exoPlayerBuilder.setRenderersFactory(defaultRendererFactory);
exoPlayerBuilder.setMediaSourceFactory(new DefaultMediaSourceFactory(dataSourceFactory, extractorsFactory));
}
exoPlayerBuilder.setAudioAttributes(new AudioAttributes.Builder()
.setUsage(C.USAGE_MEDIA)

View File

@@ -70,6 +70,14 @@ fun SettingsDeveloperScreen() {
captionContent = { Text(stringResource(R.string.enable_playback_module_description)) },
onClick = { playbackRewriteVideoEnabled = !playbackRewriteVideoEnabled }
)
var assDirectPlay by rememberPreference(userPreferences, UserPreferences.assDirectPlay)
ListButton(
headingContent = { Text(stringResource(R.string.preference_enable_libass)) },
trailingContent = { Checkbox(checked = assDirectPlay) },
captionContent = { Text(stringResource(R.string.enable_playback_module_description)) },
onClick = { assDirectPlay = !assDirectPlay }
)
}
item {

View File

@@ -83,7 +83,7 @@ fun createDeviceProfile(
maxBitrate = userPreferences.getMaxBitrate(),
isAC3Enabled = userPreferences[UserPreferences.ac3Enabled],
downMixAudio = userPreferences[UserPreferences.audioBehaviour] == AudioBehavior.DOWNMIX_TO_STEREO,
assDirectPlay = false,
assDirectPlay = userPreferences[UserPreferences.assDirectPlay],
pgsDirectPlay = userPreferences[UserPreferences.pgsDirectPlay],
)

View File

@@ -36,6 +36,7 @@ kotest = "6.1.4"
kotlin = "2.3.10"
kotlinx-coroutines = "1.10.2"
kotlinx-serialization = "1.10.0"
libass-android = "0.4.0"
markwon = "4.6.2"
mockk = "1.14.9"
slf4j-timber = "0.0.4"
@@ -100,6 +101,7 @@ androidx-media3-exoplayer-hls = { module = "androidx.media3:media3-exoplayer-hls
androidx-media3-session = { module = "androidx.media3:media3-session", version.ref = "androidx-media3" }
androidx-media3-ui = { module = "androidx.media3:media3-ui", version.ref = "androidx-media3" }
jellyfin-androidx-media3-ffmpeg-decoder = { module = "org.jellyfin.media3:media3-ffmpeg-decoder", version.ref = "jellyfin-androidx-media" }
libass-media3 = { module = "io.github.peerless2012:ass-media", version.ref = "libass-android" }
# Markwon
markwon-core = { module = "io.noties.markwon:core", version.ref = "markwon" }

View File

@@ -37,6 +37,7 @@ dependencies {
implementation(libs.androidx.media3.exoplayer.hls)
implementation(libs.jellyfin.androidx.media3.ffmpeg.decoder)
implementation(libs.androidx.media3.ui)
implementation(libs.libass.media3)
// Logging
implementation(libs.timber)

View File

@@ -23,6 +23,12 @@ import androidx.media3.exoplayer.util.EventLogger
import androidx.media3.extractor.DefaultExtractorsFactory
import androidx.media3.extractor.ts.TsExtractor
import androidx.media3.ui.SubtitleView
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 io.github.peerless2012.ass.media.widget.AssSubtitleView
import org.jellyfin.playback.core.backend.BasePlayerBackend
import org.jellyfin.playback.core.mediastream.MediaStream
import org.jellyfin.playback.core.mediastream.PlayableMediaStream
@@ -61,6 +67,10 @@ class ExoPlayerBackend(
private val timedEventState = TimedEventState()
private var lastKnownDuration: Duration? = null
private val assHandler by lazy {
AssHandler(AssRenderType.OVERLAY_OPEN_GL)
}
private val exoPlayer by lazy {
val dataSourceFactory = DefaultDataSource.Factory(
context,
@@ -78,7 +88,14 @@ class ExoPlayerBackend(
setConstantBitrateSeekingAlwaysEnabled(true)
}
val mediaSourceFactory = DefaultMediaSourceFactory(dataSourceFactory, extractorsFactory)
val mediaSourceFactory = if (exoPlayerOptions.enableLibass) {
val assSubtitleParserFactory = AssSubtitleParserFactory(assHandler)
val assExtractorsFactory = extractorsFactory.withAssMkvSupport(assSubtitleParserFactory, assHandler)
DefaultMediaSourceFactory(dataSourceFactory, assExtractorsFactory).apply {
setSubtitleParserFactory(assSubtitleParserFactory)
}
} else DefaultMediaSourceFactory(dataSourceFactory, extractorsFactory)
val renderersFactory = DefaultRenderersFactory(context).apply {
setEnableDecoderFallback(true)
setExtensionRendererMode(
@@ -87,6 +104,9 @@ class ExoPlayerBackend(
false -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON
}
)
}.let { renderersFactory ->
if (exoPlayerOptions.enableLibass) AssRenderersFactory(assHandler, renderersFactory)
else renderersFactory
}
ExoPlayer.Builder(context)
@@ -110,6 +130,10 @@ class ExoPlayerBackend(
if (exoPlayerOptions.enableDebugLogging) {
player.addAnalyticsListener(EventLogger())
}
if (exoPlayerOptions.enableLibass) {
assHandler.init(player)
}
}
}
@@ -179,7 +203,11 @@ class ExoPlayerBackend(
override fun setSubtitleView(surfaceView: PlayerSubtitleView?) {
if (surfaceView != null) {
if (subtitleView == null) {
subtitleView = SubtitleView(surfaceView.context)
subtitleView = SubtitleView(surfaceView.context).apply {
if (exoPlayerOptions.enableLibass) {
addView(AssSubtitleView(surfaceView.context, assHandler))
}
}
}
surfaceView.addView(subtitleView)

View File

@@ -6,5 +6,6 @@ import androidx.media3.datasource.DefaultHttpDataSource
data class ExoPlayerOptions(
val preferFfmpeg: Boolean = false,
val enableDebugLogging: Boolean = false,
val enableLibass: Boolean = false,
val baseDataSourceFactory: DataSource.Factory = DefaultHttpDataSource.Factory(),
)