diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 213c65cb..3d621cd8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) diff --git a/app/src/main/java/org/jellyfin/mobile/app/AppModule.kt b/app/src/main/java/org/jellyfin/mobile/app/AppModule.kt index 6b6ab6c0..1085a4c3 100644 --- a/app/src/main/java/org/jellyfin/mobile/app/AppModule.kt +++ b/app/src/main/java/org/jellyfin/mobile/app/AppModule.kt @@ -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() } 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 { + factory { val context: Context = get() val extractorsFactory = DefaultExtractorsFactory().apply { // https://github.com/google/ExoPlayer/issues/8571 @@ -166,7 +168,17 @@ val applicationModule = module { }, ) } - DefaultMediaSourceFactory(get(), extractorsFactory) + + val appPreferences: AppPreferences = get() + if (appPreferences.exoPlayerDirectPlayAss) { + val assHandler: AssHandler = get() + val assSubtitleParserFactory = AssSubtitleParserFactory(assHandler) + val assExtractorsFactory = extractorsFactory.withAssMkvSupport(assSubtitleParserFactory, assHandler) + DefaultMediaSourceFactory(get(), assExtractorsFactory) + .setSubtitleParserFactory(assSubtitleParserFactory) + } else { + DefaultMediaSourceFactory(get(), extractorsFactory) + } } single(createdAtStart = true) { StorageManager(get(), get()) } diff --git a/app/src/main/java/org/jellyfin/mobile/player/PlayerViewModel.kt b/app/src/main/java/org/jellyfin/mobile/player/PlayerViewModel.kt index 7b843c70..94f304cc 100644 --- a/app/src/main/java/org/jellyfin/mobile/player/PlayerViewModel.kt +++ b/app/src/main/java/org/jellyfin/mobile/player/PlayerViewModel.kt @@ -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().init(this) } } diff --git a/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerFragment.kt b/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerFragment.kt index c2181aee..517ce92b 100644 --- a/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerFragment.kt +++ b/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerFragment.kt @@ -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) diff --git a/app/src/main/java/org/jellyfin/mobile/settings/SettingsFragment.kt b/app/src/main/java/org/jellyfin/mobile/settings/SettingsFragment.kt index 12753cc4..4f7fb9d7 100644 --- a/app/src/main/java/org/jellyfin/mobile/settings/SettingsFragment.kt +++ b/app/src/main/java/org/jellyfin/mobile/settings/SettingsFragment.kt @@ -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( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5c150722..2b1ec60e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -111,7 +111,7 @@ Horizontal gestures Allow horizontal gesture control for video seeking Allow SSA/ASS subtitles in direct play - Prevent transcoding and show subtitles with basic styling only. Advanced subtitle styling will not be available if enabled. + Use experimental client-side subtitle rendering, may not always work Playback buffer size Auto Default buffer, suitable for most playback scenarios diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d53b46db..a21c9d91 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" }