diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d18508cbb0..890efd749a 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 c5ea8bba43..9934b5057e 100644 --- a/app/src/main/java/org/jellyfin/androidtv/di/PlaybackModule.kt +++ b/app/src/main/java/org/jellyfin/androidtv/di/PlaybackModule.kt @@ -71,6 +71,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 b194b3ed73..11688e0fa7 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,21 @@ class UserPreferences(context: Context) : SharedPreferenceStore( */ var trickPlayEnabled = booleanPreference("trick_play_enabled", false) + /** + * Enable libass. + */ + var assDirectPlay = booleanPreference( "libass_enabled", false ) + + /** + * The maximum amount of glyphs libass can cache. + */ + var libassGlyphSize = intPreference("libass_glyph_size", 10000) + + /** + * The cache size in MB for libass. + */ + var libassCacheSize = intPreference("libass_cache_size", 128) + /** * 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 3db00871bd..c484f2bc56 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.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.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,13 @@ public VideoManager(@NonNull Activity activity, @NonNull View view, @NonNull Pla _helper = helper; nightModeEnabled = userPreferences.get(UserPreferences.Companion.getAudioNightMode()); - mExoPlayer = configureExoplayerBuilder(activity).build(); + boolean assDirectPlay = userPreferences.get(UserPreferences.Companion.getAssDirectPlay()); + int libassGlypSize = userPreferences.get(UserPreferences.Companion.getLibassGlyphSize()); + int libassCacheSizeMB = userPreferences.get(UserPreferences.Companion.getLibassCacheSize()); + + AssHandler assHandler = assDirectPlay ? new AssHandler(AssRenderType.OVERLAY_CANVAS, new AssHandlerConfig(libassGlypSize, libassCacheSizeMB)) : null; + + mExoPlayer = configureExoplayerBuilder(activity, assHandler).build(); if (userPreferences.get(UserPreferences.Companion.getDebuggingEnabled())) { mExoPlayer.addAnalyticsListener(new EventLogger()); @@ -120,6 +134,11 @@ public void onAudioSessionIdChanged(AnalyticsListener.EventTime eventTime, int a 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 +217,7 @@ private int determineExoPlayerExtensionRendererMode() { * @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 +238,17 @@ private ExoPlayer.Builder configureExoplayerBuilder(Context context) { 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 05db31484e..3da48f6ef8 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 @@ -1,13 +1,23 @@ package org.jellyfin.androidtv.ui.settings.screen import android.text.format.Formatter +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp import coil3.ImageLoader import org.jellyfin.androidtv.BuildConfig import org.jellyfin.androidtv.R @@ -15,12 +25,17 @@ import org.jellyfin.androidtv.preference.SystemPreferences import org.jellyfin.androidtv.preference.UserPreferences import org.jellyfin.androidtv.ui.base.Text import org.jellyfin.androidtv.ui.base.form.Checkbox +import org.jellyfin.androidtv.ui.base.form.RangeControl import org.jellyfin.androidtv.ui.base.list.ListButton +import org.jellyfin.androidtv.ui.base.list.ListControl import org.jellyfin.androidtv.ui.base.list.ListSection import org.jellyfin.androidtv.ui.settings.compat.rememberPreference import org.jellyfin.androidtv.ui.settings.composable.SettingsColumn +import org.jellyfin.androidtv.util.dp import org.jellyfin.androidtv.util.isTvDevice +import org.jellyfin.design.Tokens import org.koin.compose.koinInject +import kotlin.math.roundToInt @Composable fun SettingsDeveloperScreen() { @@ -29,6 +44,10 @@ fun SettingsDeveloperScreen() { val context = LocalContext.current val isTvDevice = remember(context) { context.isTvDevice() } val isDeveloperBuild = BuildConfig.DEVELOPMENT + var libassEnabled by rememberPreference( + userPreferences, + UserPreferences.assDirectPlay + ) SettingsColumn { item { @@ -115,5 +134,95 @@ fun SettingsDeveloperScreen() { } ) } + + item { + ListButton( + headingContent = { Text(stringResource(R.string.enable_libass_subtitles)) }, + trailingContent = { Checkbox(checked = libassEnabled) }, + captionContent = { Text(stringResource(R.string.enable_libass_subtitles_description)) }, + onClick = { libassEnabled = !libassEnabled } + ) + } + + if(libassEnabled) { + item { + var glyphSize by rememberPreference( + userPreferences, + UserPreferences.libassGlyphSize + ) + + val interactionSource = remember { MutableInteractionSource() } + + ListControl( + headingContent = { Text(stringResource(R.string.libass_glyph_size)) }, + interactionSource = interactionSource, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + RangeControl( + modifier = Modifier + .height(4.dp) + .weight(1f), + interactionSource = interactionSource, + min = 0f, + max = 20_000f, + stepForward = 100f, + value = glyphSize.toFloat(), + onValueChange = { glyphSize = it.roundToInt() } + ) + + Spacer(Modifier.width(Tokens.Space.spaceSm)) + + Box( + modifier = Modifier.sizeIn(minWidth = 56.dp), + contentAlignment = Alignment.CenterEnd + ) { + Text(glyphSize.toString()) + } + } + } + } + + item { + var cacheSizeMb by rememberPreference( + userPreferences, + UserPreferences.libassCacheSize + ) + + val interactionSource = remember { MutableInteractionSource() } + + ListControl( + headingContent = { Text(stringResource(R.string.libass_cache_size)) }, + interactionSource = interactionSource, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + RangeControl( + modifier = Modifier + .height(4.dp) + .weight(1f), + interactionSource = interactionSource, + min = 0f, + max = 1024f, + stepForward = 16f, + value = cacheSizeMb.toFloat(), + onValueChange = { cacheSizeMb = it.roundToInt() } + ) + + Spacer(Modifier.width(Tokens.Space.spaceSm)) + + Box( + modifier = Modifier.sizeIn(minWidth = 56.dp), + contentAlignment = Alignment.CenterEnd + ) { + Text("$cacheSizeMb") + } + } + } + } + + } } } 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 d9365244fa..50bc66034a 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/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c9add5f780..a2c4a92912 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -618,4 +618,8 @@ %1$s album %1$s albums + Enable libass subtitles + Use libass for advanced subtitle rendering + Libass glyph size + Libass cache size (MB) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 009f681472..43ebbc81a0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -36,6 +36,7 @@ kotest = "6.1.3" kotlin = "2.3.10" kotlinx-coroutines = "1.10.2" kotlinx-serialization = "1.10.0" +libass-android = "0.4.0-beta01" 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 73af56e735..2ebe753e83 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 f7686577df..4e59302853 100644 --- a/playback/media3/exoplayer/src/main/kotlin/ExoPlayerBackend.kt +++ b/playback/media3/exoplayer/src/main/kotlin/ExoPlayerBackend.kt @@ -22,6 +22,13 @@ 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.AssHandlerConfig +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 @@ -57,6 +64,15 @@ class ExoPlayerBackend( private val audioPipeline = ExoPlayerAudioPipeline() private val audioAttributeState = AudioAttributeState() + private val assHandler by lazy{ + AssHandler(AssRenderType.OVERLAY_CANVAS, + config = AssHandlerConfig( + glyphSize = exoPlayerOptions.libassGlyphSize, + cacheSize = exoPlayerOptions.libassCacheSizeMB + ) + ) + } + private val exoPlayer by lazy { val dataSourceFactory = DefaultDataSource.Factory( context, @@ -74,7 +90,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( @@ -83,6 +106,9 @@ class ExoPlayerBackend( false -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON } ) + }.let { rendersFactory -> + if (exoPlayerOptions.enableLibass) AssRenderersFactory(assHandler, rendersFactory) + else rendersFactory } ExoPlayer.Builder(context) @@ -106,6 +132,10 @@ class ExoPlayerBackend( if (exoPlayerOptions.enableDebugLogging) { player.addAnalyticsListener(EventLogger()) } + + if(exoPlayerOptions.enableLibass){ + assHandler.init(player) + } } } @@ -164,7 +194,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 22f1477093..d8b8eff419 100644 --- a/playback/media3/exoplayer/src/main/kotlin/ExoPlayerOptions.kt +++ b/playback/media3/exoplayer/src/main/kotlin/ExoPlayerOptions.kt @@ -6,5 +6,8 @@ import androidx.media3.datasource.DefaultHttpDataSource data class ExoPlayerOptions( val preferFfmpeg: Boolean = false, val enableDebugLogging: Boolean = false, + val enableLibass: Boolean = false, + val libassGlyphSize: Int = 0, + val libassCacheSizeMB: Int = 0, val baseDataSourceFactory: DataSource.Factory = DefaultHttpDataSource.Factory(), )