From 9643f01895e149ba482e92ebc0c4c03d0e4eaeda Mon Sep 17 00:00:00 2001 From: Patrick Cunniff Date: Wed, 5 Aug 2026 23:12:40 -0400 Subject: [PATCH] fix(Android): Render ASS subtitles on the native player MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit libass ships no font provider on Android, and ass-kt initialises the renderer with ass_set_fonts(renderer, NULL, "sans-serif", ASS_FONTPROVIDER_FONTCONFIG, NULL, 1) requesting a provider that does not exist in that build and passing no default font. libass therefore starts with zero fonts: every glyph lookup fails, renderFrame() returns a frame containing no images, and AssSubtitleParser only emits a cue from inside `frames?.images?.let { … }`. The result is a subtitle track that is listed, selectable and never drawn, with no error anywhere. SRT is unaffected because media3 renders it with Android's own Typeface stack instead of libass. Registering fonts is necessary but not sufficient. libass resolves a font in a fixed order: the family the script asks for, then family_default, then the provider's fallback hook, then a default font path. Script families such as Arial are not present on the device, there is no provider, and ass-kt cannot set a default font path, which leaves family_default as the only reachable slot -- and it is hardcoded to "sans-serif". AssFonts therefore rewrites the fallback font's sfnt name table to declare that family. The replacement is shorter than the original name, so the records are patched in place with no table resizing. The fallback is assets/mp-font.ttf (Droid Sans Fallback), already shipped for mpv's subtitleFontFile, so CJK is covered and the native player now falls back to the same font as every other platform. Roboto and DroidSans are registered under their real names so scripts naming them resolve directly. buildWithAssSupport() is inlined because it constructs the AssHandler internally and never returns it, and that handler owns the Ass instance fonts attach to. The wiring and render type are unchanged; a side benefit is that the helper no longer silently replaces the configured dataSourceFactory. ass-kt is declared explicitly because ass-media only depends on it at runtime scope, leaving Ass off the compile classpath. Verified on a Sony BRAVIA 4K VH2 (Android 12) with a direct-played mkv carrying an embedded ASS track and no font attachments: libass now reports `fontselect: (Arial, 700, 0) -> DroidSansFallback` and renders the subtitles with their ASS colours and outlines intact. Fixes #919 Co-Authored-By: Claude Opus 5 (1M context) --- android/app/build.gradle | 3 + .../nl/jknaapen/fladder/player/ExoPlayer.kt | 32 ++-- .../nl/jknaapen/fladder/utility/AssFonts.kt | 148 ++++++++++++++++++ 3 files changed, 174 insertions(+), 9 deletions(-) create mode 100644 android/app/src/main/kotlin/nl/jknaapen/fladder/utility/AssFonts.kt diff --git a/android/app/build.gradle b/android/app/build.gradle index fbf6c30db..b4e2b9d68 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -139,6 +139,9 @@ dependencies { implementation("androidx.media3:media3-exoplayer-hls:$media3_version") implementation("org.jellyfin.media3:media3-ffmpeg-decoder:$media3_version") implementation("io.github.peerless2012:ass-media:0.3.0") + // ass-media only depends on ass-kt at runtime scope, so Ass/AssRender are not on + // the compile classpath. We need Ass.addFont() to give libass a font (see AssFonts). + implementation("io.github.peerless2012:ass-kt:0.3.0") //UI implementation("io.github.rabehx:iconsax-compose:0.0.5") diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/player/ExoPlayer.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/player/ExoPlayer.kt index 44bb2a8d5..f8060452d 100644 --- a/android/app/src/main/kotlin/nl/jknaapen/fladder/player/ExoPlayer.kt +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/player/ExoPlayer.kt @@ -42,7 +42,10 @@ import androidx.media3.extractor.ts.TsExtractor import androidx.media3.ui.AspectRatioFrameLayout import androidx.media3.ui.CaptionStyleCompat import androidx.media3.ui.PlayerView -import io.github.peerless2012.ass.media.kt.buildWithAssSupport +import io.github.peerless2012.ass.media.AssHandler +import io.github.peerless2012.ass.media.kt.withAssMkvSupport +import io.github.peerless2012.ass.media.kt.withAssSupport +import io.github.peerless2012.ass.media.parser.AssSubtitleParserFactory import io.github.peerless2012.ass.media.type.AssRenderType import kotlinx.coroutines.delay @@ -52,6 +55,7 @@ import nl.jknaapen.fladder.messengers.properlySetSubAndAudioTracks import nl.jknaapen.fladder.objects.PlayerSettingsObject import nl.jknaapen.fladder.objects.VideoPlayerObject import nl.jknaapen.fladder.utility.AllowedOrientations +import nl.jknaapen.fladder.utility.AssFonts import nl.jknaapen.fladder.utility.conditional import nl.jknaapen.fladder.utility.getAudioTracks import nl.jknaapen.fladder.utility.getSubtitleTracks @@ -106,20 +110,30 @@ internal fun ExoPlayer( }) } + // libass has no font provider on Android and must be handed fonts explicitly, + // otherwise it renders nothing at all. See AssFonts. + val assHandler = remember { AssHandler(AssRenderType.LEGACY).also { AssFonts.install(context, it) } } + val exoPlayer = remember { - ExoPlayer.Builder(context, renderersFactory) + // This is what buildWithAssSupport() does internally, inlined for two reasons: + // it creates the AssHandler itself and never exposes it (we need it to push + // fonts into libass, see AssFonts), and it discards the caller's + // dataSourceFactory in favour of a plain DefaultDataSource.Factory. + val assParserFactory = AssSubtitleParserFactory(assHandler) + val mediaSourceFactory = DefaultMediaSourceFactory( + dataSourceFactory, + extractorsFactory.withAssMkvSupport(assParserFactory, assHandler), + ).setSubtitleParserFactory(assParserFactory) + + ExoPlayer.Builder(context, renderersFactory.withAssSupport(assHandler)) .setTrackSelector(trackSelector) - .setMediaSourceFactory(DefaultMediaSourceFactory(dataSourceFactory, extractorsFactory)) + .setMediaSourceFactory(mediaSourceFactory) .setAudioAttributes(audioAttributes, true) .setHandleAudioBecomingNoisy(true) .setPauseAtEndOfMediaItems(true) .setVideoScalingMode(C.VIDEO_SCALING_MODE_SCALE_TO_FIT) - .buildWithAssSupport( - context, - renderersFactory = renderersFactory, - extractorsFactory = extractorsFactory, - renderType = AssRenderType.LEGACY - ) + .build() + .also { player -> assHandler.init(player) } } fun updatePlaybackState() { diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/utility/AssFonts.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/utility/AssFonts.kt new file mode 100644 index 000000000..6971d7d3b --- /dev/null +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/utility/AssFonts.kt @@ -0,0 +1,148 @@ +package nl.jknaapen.fladder.utility + +import android.content.Context +import android.util.Log +import io.github.peerless2012.ass.media.AssHandler +import java.io.File +import java.util.Collections +import java.util.WeakHashMap + +/** + * Gives libass fonts to render ASS/SSA subtitles with. + * + * libass is built for Android without a font provider, and ass-kt hardcodes + * + * ass_set_fonts(renderer, NULL, "sans-serif", ASS_FONTPROVIDER_FONTCONFIG, NULL, 1) + * + * so with no fonts registered every glyph lookup fails, `renderFrame()` returns a frame + * with no images, and `AssSubtitleParser.parse()` never emits a cue. The subtitle track is + * selectable and nothing appears on screen, with no error. + * + * Registering fonts is necessary but not sufficient. libass picks a font in this order + * (`ass_font_select` in ass_fontselect.c): + * + * 1. the family the script asks for - "Arial", "Nirmala UI", … rarely present + * 2. `family_default` - "sans-serif" per the call above + * 3. the provider's `get_fallback` - no provider exists; fontconfig is absent + * 4. `path_default` - NULL, and ass-kt cannot set it + * + * Only step 2 is reachable from here, so the fallback font has to actually be named + * "sans-serif". [renameFontFamily] rewrites its name table to say so. + */ +object AssFonts { + + private const val TAG = "AssFonts" + + /** Droid Sans Fallback, already shipped for mpv's `subtitleFontFile`. Covers CJK. */ + private const val BUNDLED_FONT = "flutter_assets/assets/mp-font.ttf" + + /** Must match the `family_default` ass-kt passes to `ass_set_fonts`. */ + private const val DEFAULT_FAMILY = "sans-serif" + + /** + * Registered under their real names so a script naming one resolves at step 1. + * Skipped when missing or oversized: libass copies each buffer into native memory. + */ + private val SYSTEM_FONTS = listOf( + "/system/fonts/Roboto-Regular.ttf", + "/system/fonts/Roboto-Bold.ttf", + "/system/fonts/DroidSans.ttf", + "/system/fonts/DroidSans-Bold.ttf", + ) + + private const val MAX_SYSTEM_FONT_BYTES = 8L * 1024 * 1024 + + /** name table ids holding a family name, plus the full name that often shares its bytes. */ + private val FAMILY_NAME_IDS = setOf(1, 4, 16) + + /** Fonts live on the Ass instance, which outlives individual media items. */ + private val installed: MutableSet = + Collections.newSetFromMap(WeakHashMap()) + + @Synchronized + fun install(context: Context, handler: AssHandler) { + if (!installed.add(handler)) return + + try { + val bundled = context.assets.open(BUNDLED_FONT).use { it.readBytes() } + if (!renameFontFamily(bundled, DEFAULT_FAMILY)) { + Log.e(TAG, "could not rename bundled font to $DEFAULT_FAMILY; ASS subtitles will not render") + } + handler.ass.addFont("mp-font.ttf", bundled) + } catch (e: Exception) { + Log.e(TAG, "could not load $BUNDLED_FONT; ASS subtitles will not render", e) + } + + for (path in SYSTEM_FONTS) { + val file = File(path) + if (!file.isFile || file.length() !in 1..MAX_SYSTEM_FONT_BYTES) continue + try { + handler.ass.addFont(file.name, file.readBytes()) + } catch (e: Exception) { + Log.w(TAG, "could not add $path", e) + } + } + } + + /** + * Rewrites every family-name record in the sfnt `name` table to [newFamily], in place. + * + * Only shortens strings, never grows them, so the string storage keeps its layout and no + * offsets outside the name records change. Records are patched in their own encoding + * (UTF-16BE for Windows platform 3, single byte otherwise), and ids 1/4/16 are rewritten + * together because font compilers routinely point several records at the same bytes. + */ + internal fun renameFontFamily(font: ByteArray, newFamily: String): Boolean { + fun u16(offset: Int): Int = + ((font[offset].toInt() and 0xFF) shl 8) or (font[offset + 1].toInt() and 0xFF) + + fun u32(offset: Int): Long = (u16(offset).toLong() shl 16) or u16(offset + 2).toLong() + + fun setU16(offset: Int, value: Int) { + font[offset] = (value ushr 8).toByte() + font[offset + 1] = value.toByte() + } + + if (font.size < 12) return false + // Font collections have a different header; the bundled font is a plain sfnt. + if (String(font, 0, 4, Charsets.US_ASCII) == "ttcf") return false + + var nameTable = -1 + for (i in 0 until u16(4)) { + val record = 12 + i * 16 + if (record + 16 > font.size) return false + if (String(font, record, 4, Charsets.US_ASCII) == "name") { + nameTable = u32(record + 8).toInt() + break + } + } + if (nameTable < 0 || nameTable + 6 > font.size) return false + + val recordCount = u16(nameTable + 2) + val storage = nameTable + u16(nameTable + 4) + var patched = 0 + + for (i in 0 until recordCount) { + val record = nameTable + 6 + i * 12 + if (record + 12 > font.size) break + if (u16(record + 6) !in FAMILY_NAME_IDS) continue + + val length = u16(record + 8) + val offset = storage + u16(record + 10) + if (offset < 0 || offset + length > font.size) continue + + val replacement = if (u16(record) == 3) { + newFamily.toByteArray(Charsets.UTF_16BE) + } else { + newFamily.toByteArray(Charsets.US_ASCII) + } + if (replacement.size > length) continue + + replacement.copyInto(font, offset) + setU16(record + 8, replacement.size) + patched++ + } + + return patched > 0 + } +}