diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d18508cbb..890efd749 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) diff --git a/app/src/main/java/org/jellyfin/androidtv/di/PlaybackModule.kt b/app/src/main/java/org/jellyfin/androidtv/di/PlaybackModule.kt index d3810687f..8541a774e 100644 --- a/app/src/main/java/org/jellyfin/androidtv/di/PlaybackModule.kt +++ b/app/src/main/java/org/jellyfin/androidtv/di/PlaybackModule.kt @@ -72,6 +72,7 @@ fun Scope.createPlaybackManager() = playbackManager(androidContext()) { val userPreferences = get() val exoPlayerOptions = ExoPlayerOptions( preferFfmpeg = userPreferences[UserPreferences.preferExoPlayerFfmpeg], + enableLibass = userPreferences[UserPreferences.assDirectPlay], enableDebugLogging = userPreferences[UserPreferences.debuggingEnabled], baseDataSourceFactory = get(), ) diff --git a/app/src/main/java/org/jellyfin/androidtv/preference/UserPreferences.kt b/app/src/main/java/org/jellyfin/androidtv/preference/UserPreferences.kt index b194b3ed7..4f8622bea 100644 --- a/app/src/main/java/org/jellyfin/androidtv/preference/UserPreferences.kt +++ b/app/src/main/java/org/jellyfin/androidtv/preference/UserPreferences.kt @@ -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. */ diff --git a/app/src/main/java/org/jellyfin/androidtv/ui/playback/VideoManager.java b/app/src/main/java/org/jellyfin/androidtv/ui/playback/VideoManager.java index 3db00871b..ced3883c9 100644 --- a/app/src/main/java/org/jellyfin/androidtv/ui/playback/VideoManager.java +++ b/app/src/main/java/org/jellyfin/androidtv/ui/playback/VideoManager.java @@ -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) diff --git a/app/src/main/java/org/jellyfin/androidtv/ui/settings/screen/SettingsDeveloperScreen.kt b/app/src/main/java/org/jellyfin/androidtv/ui/settings/screen/SettingsDeveloperScreen.kt index 05db31484..576221af9 100644 --- a/app/src/main/java/org/jellyfin/androidtv/ui/settings/screen/SettingsDeveloperScreen.kt +++ b/app/src/main/java/org/jellyfin/androidtv/ui/settings/screen/SettingsDeveloperScreen.kt @@ -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 { diff --git a/app/src/main/java/org/jellyfin/androidtv/util/profile/deviceProfile.kt b/app/src/main/java/org/jellyfin/androidtv/util/profile/deviceProfile.kt index d9365244f..50bc66034 100644 --- a/app/src/main/java/org/jellyfin/androidtv/util/profile/deviceProfile.kt +++ b/app/src/main/java/org/jellyfin/androidtv/util/profile/deviceProfile.kt @@ -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], ) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index fa78219bc..3e6ccfaa7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" } diff --git a/playback/media3/exoplayer/build.gradle.kts b/playback/media3/exoplayer/build.gradle.kts index 73af56e73..2ebe753e8 100644 --- a/playback/media3/exoplayer/build.gradle.kts +++ b/playback/media3/exoplayer/build.gradle.kts @@ -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) diff --git a/playback/media3/exoplayer/src/main/kotlin/ExoPlayerBackend.kt b/playback/media3/exoplayer/src/main/kotlin/ExoPlayerBackend.kt index 242c8b31f..4b366639f 100644 --- a/playback/media3/exoplayer/src/main/kotlin/ExoPlayerBackend.kt +++ b/playback/media3/exoplayer/src/main/kotlin/ExoPlayerBackend.kt @@ -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) diff --git a/playback/media3/exoplayer/src/main/kotlin/ExoPlayerOptions.kt b/playback/media3/exoplayer/src/main/kotlin/ExoPlayerOptions.kt index 22f147709..5bdeedbca 100644 --- a/playback/media3/exoplayer/src/main/kotlin/ExoPlayerOptions.kt +++ b/playback/media3/exoplayer/src/main/kotlin/ExoPlayerOptions.kt @@ -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(), )