Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,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>(),
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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)
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,26 +1,41 @@
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
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() {
Expand All @@ -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 {
Expand Down Expand Up @@ -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")
}
}
}
}

}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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],
)

Expand Down
4 changes: 4 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -618,4 +618,8 @@
<item quantity="one">%1$s album</item>
<item quantity="other">%1$s albums</item>
</plurals>
<string name="enable_libass_subtitles">Enable libass subtitles</string>
<string name="enable_libass_subtitles_description">Use libass for advanced subtitle rendering</string>
<string name="libass_glyph_size">Libass glyph size</string>
<string name="libass_cache_size">Libass cache size (MB)</string>
</resources>
2 changes: 2 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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" }
Expand Down
1 change: 1 addition & 0 deletions playback/media3/exoplayer/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
38 changes: 36 additions & 2 deletions playback/media3/exoplayer/src/main/kotlin/ExoPlayerBackend.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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(
Expand All @@ -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)
Expand All @@ -106,6 +132,10 @@ class ExoPlayerBackend(
if (exoPlayerOptions.enableDebugLogging) {
player.addAnalyticsListener(EventLogger())
}

if(exoPlayerOptions.enableLibass){
assHandler.init(player)
}
}
}

Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
)