Add ass/ssa subtitle support (#2136)

* Add ass/ssa subtitle support

Create renderers factory using constructor

Init libass when exoPlayerDirectPlayAss is true

Replace MediaSource.Factory singleton with factory

Fix linter errors

* Update SSA/ASS direct play description
This commit is contained in:
Rogal
2026-08-27 19:20:07 +02:00
committed by GitHub
parent b9cd75464d
commit 640e1c3e43
7 changed files with 36 additions and 8 deletions
+1
View File
@@ -172,6 +172,7 @@ dependencies {
implementation(libs.bundles.androidx.media3)
proprietaryImplementation(libs.androidx.media3.cast)
proprietaryImplementation(libs.bundles.playservices)
implementation(libs.libass.media)
// Room
implementation(libs.bundles.androidx.room)
@@ -14,15 +14,16 @@ import androidx.media3.datasource.cache.Cache
import androidx.media3.datasource.cache.CacheDataSource
import androidx.media3.datasource.cache.NoOpCacheEvictor
import androidx.media3.datasource.cache.SimpleCache
import androidx.media3.exoplayer.hls.HlsMediaSource
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.exoplayer.source.ProgressiveMediaSource
import androidx.media3.exoplayer.source.SingleSampleMediaSource
import androidx.media3.extractor.DefaultExtractorsFactory
import androidx.media3.extractor.ts.TsExtractor
import androidx.work.WorkManager
import coil3.ImageLoader
import io.github.peerless2012.ass.media.AssHandler
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.channels.Channel
import okhttp3.OkHttpClient
import org.jellyfin.mobile.MainViewModel
@@ -68,6 +69,7 @@ val applicationModule = module {
single { RemoteVolumeProvider(get()) }
single(named(PLAYER_EVENT_CHANNEL)) { Channel<PlayerEvent>() }
factory { WorkManager.getInstance(get()) }
single { AssHandler(AssRenderType.OVERLAY_OPEN_GL) }
// Controllers
single { ApiClientController(get(), get(), get(), get(), get()) }
@@ -155,7 +157,7 @@ val applicationModule = module {
}
}
single<MediaSource.Factory> {
factory<MediaSource.Factory> {
val context: Context = get()
val extractorsFactory = DefaultExtractorsFactory().apply {
// https://github.com/google/ExoPlayer/issues/8571
@@ -166,8 +168,18 @@ val applicationModule = module {
},
)
}
val appPreferences: AppPreferences = get()
if (appPreferences.exoPlayerDirectPlayAss) {
val assHandler: AssHandler = get()
val assSubtitleParserFactory = AssSubtitleParserFactory(assHandler)
val assExtractorsFactory = extractorsFactory.withAssMkvSupport(assSubtitleParserFactory, assHandler)
DefaultMediaSourceFactory(get<CacheDataSource.Factory>(), assExtractorsFactory)
.setSubtitleParserFactory(assSubtitleParserFactory)
} else {
DefaultMediaSourceFactory(get<CacheDataSource.Factory>(), extractorsFactory)
}
}
single(createdAtStart = true) { StorageManager(get(), get()) }
single { DownloadManager(get(), get(), get(), get(), get()) }
@@ -27,6 +27,8 @@ import androidx.media3.exoplayer.mediacodec.MediaCodecSelector
import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
import androidx.media3.exoplayer.util.EventLogger
import io.github.peerless2012.ass.media.AssHandler
import io.github.peerless2012.ass.media.factory.AssRenderersFactory
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -239,6 +241,7 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application),
.build()
else -> DefaultLoadControl()
}
val appPreferences: AppPreferences = get()
val renderersFactory = DefaultRenderersFactory(getApplication()).apply {
setEnableDecoderFallback(true) // Fallback only works if initialization fails, not decoding at playback time
val rendererMode = when {
@@ -272,7 +275,7 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application),
filteredDecoderList
}
}
}.let { if (appPreferences.exoPlayerDirectPlayAss) AssRenderersFactory(get(), it) else it }
_player.value = ExoPlayer.Builder(getApplication(), renderersFactory, get()).apply {
setUsePlatformDiagnostics(false)
setTrackSelector(trackSelector)
@@ -282,6 +285,7 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application),
}.build().apply {
addListener(this@PlayerViewModel)
applyDefaultAudioAttributes(C.AUDIO_CONTENT_TYPE_MOVIE)
if (appPreferences.exoPlayerDirectPlayAss) get<AssHandler>().init(this)
}
}
@@ -27,6 +27,8 @@ import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import androidx.media3.common.Player
import androidx.media3.ui.PlayerView
import io.github.peerless2012.ass.media.AssHandler
import io.github.peerless2012.ass.media.widget.AssSubtitleView
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import org.jellyfin.mobile.R
@@ -59,6 +61,7 @@ import androidx.media3.ui.R as Media3R
@Suppress("TooManyFunctions")
class PlayerFragment : Fragment(), BackPressInterceptor {
private val appPreferences: AppPreferences by inject()
private val assHandler: AssHandler by inject()
private val viewModel: PlayerViewModel by viewModels()
private var _playerBinding: FragmentPlayerBinding? = null
private val playerBinding: FragmentPlayerBinding get() = _playerBinding!!
@@ -209,6 +212,12 @@ class PlayerFragment : Fragment(), BackPressInterceptor {
// Disable controller animations
playerView.setControllerAnimationEnabled(false)
if (appPreferences.exoPlayerDirectPlayAss) {
playerView.subtitleView?.apply {
addView(AssSubtitleView(this.context, assHandler))
}
}
playerLockScreenHelper = PlayerLockScreenHelper(this, playerBinding, orientationListener)
playerGestureHelper = PlayerGestureHelper(this, playerBinding, playerLockScreenHelper)
@@ -170,7 +170,7 @@ class SettingsFragment : Fragment(), BackPressInterceptor {
}
directPlayAssPreference = checkBox(Constants.PREF_EXOPLAYER_DIRECT_PLAY_ASS) {
titleRes = R.string.pref_exoplayer_direct_play_ass
summaryRes = R.string.pref_exoplayer_direct_play_ass_summary
summaryRes = R.string.pref_exoplayer_direct_play_ass_description
enabled = appPreferences.videoPlayerType == VideoPlayerType.EXO_PLAYER
}
val networkBufferOptions = listOf(
+1 -1
View File
@@ -111,7 +111,7 @@
<string name="pref_exoplayer_allow_horizontal_gesture">Horizontal gestures</string>
<string name="pref_exoplayer_allow_horizontal_gesture_summary">Allow horizontal gesture control for video seeking</string>
<string name="pref_exoplayer_direct_play_ass">Allow SSA/ASS subtitles in direct play</string>
<string name="pref_exoplayer_direct_play_ass_summary">Prevent transcoding and show subtitles with basic styling only. Advanced subtitle styling will not be available if enabled.</string>
<string name="pref_exoplayer_direct_play_ass_description">Use experimental client-side subtitle rendering, may not always work</string>
<string name="pref_exoplayer_network_buffer">Playback buffer size</string>
<string name="network_buffer_auto">Auto</string>
<string name="network_buffer_auto_description">Default buffer, suitable for most playback scenarios</string>
+2
View File
@@ -52,6 +52,7 @@ androidx-mediarouter = "1.8.1"
androidx-media3 = "1.11.0"
jellyfin-androidx-media = "1.9.0+1"
playservices = "22.3.1"
libass-media = "0.5.1"
# Room
androidx-room = "2.8.4"
@@ -138,6 +139,7 @@ androidx-media3-cast = { module = "androidx.media3:media3-cast", version.ref = "
playservices-cast = { group = "com.google.android.gms", name = "play-services-cast", version.ref = "playservices" }
playservices-castframework = { group = "com.google.android.gms", name = "play-services-cast-framework", version.ref = "playservices" }
jellyfin-androidx-media3-ffmpeg-decoder = { module = "org.jellyfin.media3:media3-ffmpeg-decoder", version.ref = "jellyfin-androidx-media" }
libass-media = { module = "io.github.peerless2012:ass-media", version.ref = "libass-media" }
# Room
androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "androidx-room" }