feat: libass

This commit is contained in:
tapframe 2026-04-08 15:39:40 +05:30
parent 35428ca5fd
commit bba83b13e4
10 changed files with 379 additions and 13 deletions

View file

@ -210,6 +210,7 @@ kotlin {
implementation("androidx.recyclerview:recyclerview:1.4.0")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("com.google.code.gson:gson:2.11.0")
implementation("io.github.peerless2012:ass-media:0.4.0-beta01")
implementation(libs.ktor.client.android)
implementation(libs.androidx.media3.exoplayer.hls)
implementation(libs.androidx.media3.exoplayer.dash)

View file

@ -120,24 +120,47 @@ actual fun PlatformPlayerSurface(
.setTsExtractorFlags(DefaultTsPayloadReaderFactory.FLAG_ENABLE_HDMV_DTS_AUDIO_STREAMS)
.setTsExtractorTimestampSearchBytes(1500 * TsExtractor.TS_PACKET_SIZE)
val mediaSourceFactory = DefaultMediaSourceFactory(
PlatformPlaybackDataSourceFactory.create(
val dataSourceFactory = PlatformPlaybackDataSourceFactory.create(
defaultRequestHeaders = sanitizedSourceHeaders,
defaultResponseHeaders = sanitizedSourceResponseHeaders,
useYoutubeChunkedPlayback = useYoutubeChunkedPlayback,
),
extractorsFactory,
)
)
ExoPlayer.Builder(context)
.setRenderersFactory(renderersFactory)
.setTrackSelector(trackSelector)
.setLoadControl(loadControl)
.setMediaSourceFactory(mediaSourceFactory)
.build().apply {
val useLibass = playerSettings.useLibass
val libassRenderType = runCatching {
LibassRenderType.valueOf(playerSettings.libassRenderType)
}.getOrDefault(LibassRenderType.CUES)
val player = if (useLibass) {
ExoPlayer.Builder(context)
.setTrackSelector(trackSelector)
.setLoadControl(loadControl)
.buildWithAssSupportCompat(
context = context,
renderType = libassRenderType.toAssRenderType(),
dataSourceFactory = dataSourceFactory,
extractorsFactory = extractorsFactory,
renderersFactory = renderersFactory
)
} else {
val mediaSourceFactory = DefaultMediaSourceFactory(
dataSourceFactory,
extractorsFactory,
)
ExoPlayer.Builder(context)
.setRenderersFactory(renderersFactory)
.setTrackSelector(trackSelector)
.setLoadControl(loadControl)
.setMediaSourceFactory(mediaSourceFactory)
.build()
}
player.apply {
if (!sourceAudioUrl.isNullOrBlank()) {
val videoSource = mediaSourceFactory.createMediaSource(MediaItem.fromUri(sourceUrl))
val audioSource = mediaSourceFactory.createMediaSource(MediaItem.fromUri(sourceAudioUrl))
val msf = DefaultMediaSourceFactory(dataSourceFactory, extractorsFactory)
val videoSource = msf.createMediaSource(MediaItem.fromUri(sourceUrl))
val audioSource = msf.createMediaSource(MediaItem.fromUri(sourceAudioUrl))
setMediaSource(MergingMediaSource(videoSource, audioSource))
} else {
setMediaItem(MediaItem.fromUri(sourceUrl))

View file

@ -0,0 +1,98 @@
package com.nuvio.app.features.player
import android.content.Context
import androidx.annotation.OptIn
import androidx.media3.common.Format
import androidx.media3.common.MimeTypes
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DataSource
import androidx.media3.datasource.DefaultDataSource
import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.RenderersFactory
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.extractor.DefaultExtractorsFactory
import androidx.media3.extractor.ExtractorsFactory
import androidx.media3.extractor.mkv.MatroskaExtractor
import androidx.media3.extractor.text.SubtitleParser
import io.github.peerless2012.ass.media.AssHandler
import io.github.peerless2012.ass.media.extractor.AssMatroskaExtractor
import io.github.peerless2012.ass.media.kt.withAssSupport
import io.github.peerless2012.ass.media.parser.AssSubtitleParserFactory
import io.github.peerless2012.ass.media.type.AssRenderType
@OptIn(UnstableApi::class)
internal fun ExoPlayer.Builder.buildWithAssSupportCompat(
context: Context,
renderType: AssRenderType = AssRenderType.CUES,
dataSourceFactory: DataSource.Factory = DefaultDataSource.Factory(context),
extractorsFactory: ExtractorsFactory = DefaultExtractorsFactory(),
renderersFactory: RenderersFactory = DefaultRenderersFactory(context)
): ExoPlayer {
val assHandler = AssHandler(renderType)
val assSubtitleParserFactory = CompatAssSubtitleParserFactory(assHandler)
val assExtractorsFactory = extractorsFactory.withAssMkvSupportCompat(
subtitleParserFactory = assSubtitleParserFactory,
assHandler = assHandler
)
val mediaSourceFactory = DefaultMediaSourceFactory(
dataSourceFactory,
assExtractorsFactory
)
mediaSourceFactory.setSubtitleParserFactory(assSubtitleParserFactory)
val player = this
.setMediaSourceFactory(mediaSourceFactory)
.setRenderersFactory(renderersFactory.withAssSupport(assHandler))
.build()
assHandler.init(player)
return player
}
@OptIn(UnstableApi::class)
private class CompatAssSubtitleParserFactory(
private val assHandler: AssHandler
) : SubtitleParser.Factory {
private val delegate = AssSubtitleParserFactory(assHandler)
override fun supportsFormat(format: Format): Boolean {
return delegate.supportsFormat(normalizeSsaFormat(format))
}
override fun getCueReplacementBehavior(format: Format): Int {
return delegate.getCueReplacementBehavior(normalizeSsaFormat(format))
}
override fun create(format: Format): SubtitleParser {
return delegate.create(normalizeSsaFormat(format))
}
private fun normalizeSsaFormat(format: Format): Format {
val isSsaByCodecs = format.codecs == MimeTypes.TEXT_SSA
val isSsaByMime = format.sampleMimeType == MimeTypes.TEXT_SSA
if (isSsaByCodecs && !isSsaByMime) {
return format.buildUpon()
.setSampleMimeType(MimeTypes.TEXT_SSA)
.build()
}
return format
}
}
@OptIn(UnstableApi::class)
private fun ExtractorsFactory.withAssMkvSupportCompat(
subtitleParserFactory: SubtitleParser.Factory,
assHandler: AssHandler
): ExtractorsFactory {
return ExtractorsFactory {
val extractors = createExtractors()
extractors.forEachIndexed { index, extractor ->
if (extractor is MatroskaExtractor) {
extractors[index] = AssMatroskaExtractor(subtitleParserFactory, assHandler)
}
}
extractors
}
}

View file

@ -0,0 +1,21 @@
package com.nuvio.app.features.player
import io.github.peerless2012.ass.media.type.AssRenderType
enum class LibassRenderType {
CUES,
EFFECTS_CANVAS,
EFFECTS_OPEN_GL,
OVERLAY_CANVAS,
OVERLAY_OPEN_GL
}
internal fun LibassRenderType.toAssRenderType(): AssRenderType {
return when (this) {
LibassRenderType.CUES -> AssRenderType.CUES
LibassRenderType.EFFECTS_CANVAS -> AssRenderType.EFFECTS_CANVAS
LibassRenderType.EFFECTS_OPEN_GL -> AssRenderType.EFFECTS_OPEN_GL
LibassRenderType.OVERLAY_CANVAS -> AssRenderType.OVERLAY_CANVAS
LibassRenderType.OVERLAY_OPEN_GL -> AssRenderType.OVERLAY_OPEN_GL
}
}

View file

@ -34,6 +34,8 @@ actual object PlayerSettingsStorage {
private const val nextEpisodeThresholdModeKey = "next_episode_threshold_mode"
private const val nextEpisodeThresholdPercentKey = "next_episode_threshold_percent_v2"
private const val nextEpisodeThresholdMinutesBeforeEndKey = "next_episode_threshold_minutes_before_end_v2"
private const val useLibassKey = "use_libass"
private const val libassRenderTypeKey = "libass_render_type"
private var preferences: SharedPreferences? = null
@ -460,4 +462,31 @@ actual object PlayerSettingsStorage {
?.putFloat(ProfileScopedKey.of(nextEpisodeThresholdMinutesBeforeEndKey), minutes)
?.apply()
}
actual fun loadUseLibass(): Boolean? =
preferences?.let { sharedPreferences ->
val key = ProfileScopedKey.of(useLibassKey)
if (sharedPreferences.contains(key)) {
sharedPreferences.getBoolean(key, false)
} else {
null
}
}
actual fun saveUseLibass(enabled: Boolean) {
preferences
?.edit()
?.putBoolean(ProfileScopedKey.of(useLibassKey), enabled)
?.apply()
}
actual fun loadLibassRenderType(): String? =
preferences?.getString(ProfileScopedKey.of(libassRenderTypeKey), null)
actual fun saveLibassRenderType(renderType: String) {
preferences
?.edit()
?.putString(ProfileScopedKey.of(libassRenderTypeKey), renderType)
?.apply()
}
}

View file

@ -34,6 +34,8 @@ data class PlayerSettingsUiState(
val nextEpisodeThresholdMode: NextEpisodeThresholdMode = NextEpisodeThresholdMode.PERCENTAGE,
val nextEpisodeThresholdPercent: Float = 99f,
val nextEpisodeThresholdMinutesBeforeEnd: Float = 2f,
val useLibass: Boolean = false,
val libassRenderType: String = "CUES",
)
object PlayerSettingsRepository {
@ -66,6 +68,8 @@ object PlayerSettingsRepository {
private var nextEpisodeThresholdMode = NextEpisodeThresholdMode.PERCENTAGE
private var nextEpisodeThresholdPercent = 99f
private var nextEpisodeThresholdMinutesBeforeEnd = 2f
private var useLibass = false
private var libassRenderType = "CUES"
fun ensureLoaded() {
if (hasLoaded) return
@ -103,6 +107,8 @@ object PlayerSettingsRepository {
nextEpisodeThresholdMode = NextEpisodeThresholdMode.PERCENTAGE
nextEpisodeThresholdPercent = 99f
nextEpisodeThresholdMinutesBeforeEnd = 2f
useLibass = false
libassRenderType = "CUES"
publish()
}
@ -165,6 +171,8 @@ object PlayerSettingsRepository {
?: NextEpisodeThresholdMode.PERCENTAGE
nextEpisodeThresholdPercent = PlayerSettingsStorage.loadNextEpisodeThresholdPercent() ?: 99f
nextEpisodeThresholdMinutesBeforeEnd = PlayerSettingsStorage.loadNextEpisodeThresholdMinutesBeforeEnd() ?: 2f
useLibass = PlayerSettingsStorage.loadUseLibass() ?: false
libassRenderType = PlayerSettingsStorage.loadLibassRenderType() ?: "CUES"
publish()
}
@ -377,6 +385,22 @@ object PlayerSettingsRepository {
PlayerSettingsStorage.saveNextEpisodeThresholdMinutesBeforeEnd(minutes)
}
fun setUseLibass(enabled: Boolean) {
ensureLoaded()
if (useLibass == enabled) return
useLibass = enabled
publish()
PlayerSettingsStorage.saveUseLibass(enabled)
}
fun setLibassRenderType(renderType: String) {
ensureLoaded()
if (libassRenderType == renderType) return
libassRenderType = renderType
publish()
PlayerSettingsStorage.saveLibassRenderType(renderType)
}
private fun publish() {
_uiState.value = PlayerSettingsUiState(
showLoadingOverlay = showLoadingOverlay,
@ -404,6 +428,8 @@ object PlayerSettingsRepository {
nextEpisodeThresholdMode = nextEpisodeThresholdMode,
nextEpisodeThresholdPercent = nextEpisodeThresholdPercent,
nextEpisodeThresholdMinutesBeforeEnd = nextEpisodeThresholdMinutesBeforeEnd,
useLibass = useLibass,
libassRenderType = libassRenderType,
)
}

View file

@ -57,4 +57,8 @@ internal expect object PlayerSettingsStorage {
fun saveNextEpisodeThresholdPercent(percent: Float)
fun loadNextEpisodeThresholdMinutesBeforeEnd(): Float?
fun saveNextEpisodeThresholdMinutesBeforeEnd(minutes: Float)
fun loadUseLibass(): Boolean?
fun saveUseLibass(enabled: Boolean)
fun loadLibassRenderType(): String?
fun saveLibassRenderType(renderType: String)
}

View file

@ -70,6 +70,8 @@ internal fun LazyListScope.playbackSettingsContent(
decoderPriority: Int,
mapDV7ToHevc: Boolean,
tunnelingEnabled: Boolean,
useLibass: Boolean,
libassRenderType: String,
) {
item {
PlaybackSettingsSection(
@ -84,6 +86,8 @@ internal fun LazyListScope.playbackSettingsContent(
decoderPriority = decoderPriority,
mapDV7ToHevc = mapDV7ToHevc,
tunnelingEnabled = tunnelingEnabled,
useLibass = useLibass,
libassRenderType = libassRenderType,
)
}
}
@ -101,6 +105,8 @@ private fun PlaybackSettingsSection(
decoderPriority: Int,
mapDV7ToHevc: Boolean,
tunnelingEnabled: Boolean,
useLibass: Boolean,
libassRenderType: String,
) {
var showPreferredAudioDialog by remember { mutableStateOf(false) }
var showSecondaryAudioDialog by remember { mutableStateOf(false) }
@ -108,6 +114,7 @@ private fun PlaybackSettingsSection(
var showSecondarySubtitleDialog by remember { mutableStateOf(false) }
var showReuseCacheDurationDialog by remember { mutableStateOf(false) }
var showDecoderPriorityDialog by remember { mutableStateOf(false) }
var showLibassRenderTypeDialog by remember { mutableStateOf(false) }
var showAutoPlayModeDialog by remember { mutableStateOf(false) }
var showAutoPlaySourceDialog by remember { mutableStateOf(false) }
var showAutoPlayAddonSelectionDialog by remember { mutableStateOf(false) }
@ -376,6 +383,39 @@ private fun PlaybackSettingsSection(
}
}
if (!isIos) {
SettingsSection(
title = "SUBTITLE RENDERING",
isTablet = isTablet,
) {
SettingsGroup(isTablet = isTablet) {
SettingsSwitchRow(
title = "Enable libass",
description = "Use libass for ASS/SSA subtitle rendering instead of the default renderer.",
checked = useLibass,
isTablet = isTablet,
onCheckedChange = PlayerSettingsRepository::setUseLibass,
)
if (useLibass) {
SettingsGroupDivider(isTablet = isTablet)
SettingsNavigationRow(
title = "Render Type",
description = when (libassRenderType) {
"OVERLAY_OPEN_GL" -> "Overlay OpenGL"
"OVERLAY_CANVAS" -> "Overlay Canvas"
"EFFECTS_OPEN_GL" -> "Effects OpenGL"
"EFFECTS_CANVAS" -> "Effects Canvas"
"CUES" -> "Standard (Cues)"
else -> "Standard (Cues)"
},
isTablet = isTablet,
onClick = { showLibassRenderTypeDialog = true },
)
}
}
}
}
SettingsSection(
title = "SKIP SEGMENTS",
isTablet = isTablet,
@ -673,6 +713,17 @@ private fun PlaybackSettingsSection(
)
}
if (showLibassRenderTypeDialog) {
LibassRenderTypeDialog(
selectedRenderType = libassRenderType,
onRenderTypeSelected = { renderType ->
PlayerSettingsRepository.setLibassRenderType(renderType)
showLibassRenderTypeDialog = false
},
onDismiss = { showLibassRenderTypeDialog = false },
)
}
if (showAutoPlayModeDialog) {
StreamAutoPlayModeDialog(
selectedMode = autoPlayPlayerSettings.streamAutoPlayMode,
@ -1029,6 +1080,99 @@ private fun DecoderPriorityDialog(
}
}
@Composable
@OptIn(ExperimentalMaterial3Api::class)
private fun LibassRenderTypeDialog(
selectedRenderType: String,
onRenderTypeSelected: (String) -> Unit,
onDismiss: () -> Unit,
) {
val options = listOf(
"OVERLAY_OPEN_GL" to "Overlay OpenGL",
"OVERLAY_CANVAS" to "Overlay Canvas",
"EFFECTS_OPEN_GL" to "Effects OpenGL",
"EFFECTS_CANVAS" to "Effects Canvas",
"CUES" to "Standard (Cues)",
)
BasicAlertDialog(
onDismissRequest = onDismiss,
) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(20.dp),
color = MaterialTheme.colorScheme.surface,
) {
Column(
modifier = Modifier.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = "Render Type",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold,
)
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
options.forEach { (value, label) ->
val isSelected = value == selectedRenderType
val containerColor = if (isSelected) {
MaterialTheme.colorScheme.primary.copy(alpha = 0.14f)
} else {
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f)
}
Surface(
modifier = Modifier
.fillMaxWidth()
.clickable { onRenderTypeSelected(value) },
shape = RoundedCornerShape(12.dp),
color = containerColor,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = label,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.weight(1f),
)
Box(
modifier = Modifier.size(24.dp),
contentAlignment = Alignment.Center,
) {
if (isSelected) {
Icon(
imageVector = Icons.Rounded.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
}
}
}
}
}
}
Spacer(modifier = Modifier.height(2.dp))
Text(
text = "Tap outside to close",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@Composable
@OptIn(ExperimentalMaterial3Api::class)
private fun StreamAutoPlayModeDialog(

View file

@ -161,6 +161,8 @@ fun SettingsScreen(
decoderPriority = playerSettingsUiState.decoderPriority,
mapDV7ToHevc = playerSettingsUiState.mapDV7ToHevc,
tunnelingEnabled = playerSettingsUiState.tunnelingEnabled,
useLibass = playerSettingsUiState.useLibass,
libassRenderType = playerSettingsUiState.libassRenderType,
selectedTheme = selectedTheme,
onThemeSelected = ThemeSettingsRepository::setTheme,
amoledEnabled = amoledEnabled,
@ -191,6 +193,8 @@ fun SettingsScreen(
decoderPriority = playerSettingsUiState.decoderPriority,
mapDV7ToHevc = playerSettingsUiState.mapDV7ToHevc,
tunnelingEnabled = playerSettingsUiState.tunnelingEnabled,
useLibass = playerSettingsUiState.useLibass,
libassRenderType = playerSettingsUiState.libassRenderType,
selectedTheme = selectedTheme,
onThemeSelected = ThemeSettingsRepository::setTheme,
amoledEnabled = amoledEnabled,
@ -231,6 +235,8 @@ private fun MobileSettingsScreen(
decoderPriority: Int,
mapDV7ToHevc: Boolean,
tunnelingEnabled: Boolean,
useLibass: Boolean,
libassRenderType: String,
selectedTheme: AppTheme,
onThemeSelected: (AppTheme) -> Unit,
amoledEnabled: Boolean,
@ -289,6 +295,8 @@ private fun MobileSettingsScreen(
decoderPriority = decoderPriority,
mapDV7ToHevc = mapDV7ToHevc,
tunnelingEnabled = tunnelingEnabled,
useLibass = useLibass,
libassRenderType = libassRenderType,
)
SettingsPage.Appearance -> appearanceSettingsContent(
isTablet = false,
@ -365,6 +373,8 @@ private fun TabletSettingsScreen(
decoderPriority: Int,
mapDV7ToHevc: Boolean,
tunnelingEnabled: Boolean,
useLibass: Boolean,
libassRenderType: String,
selectedTheme: AppTheme,
onThemeSelected: (AppTheme) -> Unit,
amoledEnabled: Boolean,
@ -486,6 +496,8 @@ private fun TabletSettingsScreen(
decoderPriority = decoderPriority,
mapDV7ToHevc = mapDV7ToHevc,
tunnelingEnabled = tunnelingEnabled,
useLibass = useLibass,
libassRenderType = libassRenderType,
)
SettingsPage.Appearance -> appearanceSettingsContent(
isTablet = true,

View file

@ -392,4 +392,12 @@ actual object PlayerSettingsStorage {
actual fun saveNextEpisodeThresholdMinutesBeforeEnd(minutes: Float) {
NSUserDefaults.standardUserDefaults.setFloat(minutes, forKey = ProfileScopedKey.of(nextEpisodeThresholdMinutesBeforeEndKey))
}
actual fun loadUseLibass(): Boolean? = null
actual fun saveUseLibass(enabled: Boolean) {}
actual fun loadLibassRenderType(): String? = null
actual fun saveLibassRenderType(renderType: String) {}
}