Skip to content
Open
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
3 changes: 3 additions & 0 deletions android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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() {
Expand Down
148 changes: 148 additions & 0 deletions android/app/src/main/kotlin/nl/jknaapen/fladder/utility/AssFonts.kt
Original file line number Diff line number Diff line change
@@ -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<AssHandler> =
Collections.newSetFromMap(WeakHashMap<AssHandler, Boolean>())

@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
}
}
Loading