Not One Character of Subtitle, and Every Layer Reports Fine
Tracks enumerate. sid sets and reads back. sub-visibility is yes. sub-text holds the decoded line. The screen shows nothing. Two holes stacked: the libass I cross-compiled has no fontconfig (HarmonyOS doesn't have it), leaving font-provider at none — and mpv's one remaining route, subfont.ttf in the config directory, is closed because mpv_create() defaults libmpv to config=no. The fix is one symlink and two explicit options, plus why --sub-fonts-dir is deliberately not used.
Vela Player is a private media library for HarmonyOS with two playback engines: the system AVPlayer first, falling back to libmpv software decoding for whatever it can’t handle. Once that fallback path worked, a symptom appeared:
Pick a subtitle track, and the screen shows nothing.
Every layer I could inspect looked correct:
- Track enumeration works; the subtitle track is in the list
sidsets successfully and reads back the same valuesub-visibilityisyes- The
sub-textproperty contains the decoded line, verbatim
So mpv is decoding the subtitles, and the decoded text is retrievable. It just never reaches the screen. No error, no warning, not one non-zero return code anywhere.
It only shows up at verbose
For this class of failure — every layer reporting healthy — there’s no alternative to digging out the engine’s own log. On a real device, with mpv’s [sub/ass] output at verbose:
[sub/ass] Setting up fonts...
[sub/ass] can't find selected font provider
[sub/ass] fontselect: failed to find any fallback with glyph 0x0 for font: (sans-serif, 400, 0)
Line two is the cause; line three is its direct consequence. libass has no font provider at all, so it can’t produce a face for any glyph — not even a fallback. It has the words. It has no pen.
Two holes, stacked
Following it down, this turned out to be two problems, and fixing either one alone changes nothing.
Hole one: the libass I built has no fontconfig.
libass locates fonts through a font provider, which on Linux defaults to fontconfig. HarmonyOS doesn’t have fontconfig, so cross-compiling libass naturally left it out. The only available provider is none.
none does not mean “use the system default.” It means literally none — libass will not go looking through system directories on its own. This differs from how a lot of libraries behave: most fall back to some built-in default when configuration is missing. What libass falls back to here is nothing.
Hole two: mpv’s one remaining route is also closed.
Without fontconfig, mpv still leaves one path open: subfont.ttf in the config directory. Put a font file there and libass uses it.
But libmpv is not mpv. mpv_create() defaults config to no — as an embedded library, libmpv doesn’t read user config files. That’s its documented, intended behaviour. With config off, the config directory doesn’t exist as a concept, and the subfont.ttf route is closed too.
Together: libass has exactly zero ways to find a font, and nothing along the chain considers that an error.
The fix
common/MpvFonts.ets does two things — stage a subfont.ttf in the sandbox, and turn config on explicitly.
const dir = `${context.filesDir}/mpv`;
if (!fs.accessSync(dir)) {
fs.mkdirSync(dir, true);
}
const link = `${dir}/subfont.ttf`;
The font comes from the system, in priority order by coverage:
private static readonly CANDIDATES: string[] = [
'/system/fonts/HarmonyOS_Sans_SC.ttf',
'/system/fonts/NotoSansCJK-Regular.ttc',
'/system/fonts/HYQiHeiL3.ttf',
'/system/fonts/NotoSans[wdth,wght].ttf'
];
The first three each cover CJK plus Latin; the last only catches foreign-language subtitles on an aggressively stripped system.
Then config and config-dir are passed to mpv explicitly. This step can’t be skipped — staging the file without enabling config is the same as not staging it.
Why a symlink, not a copy
await fs.symlink(src, dest);
System CJK fonts are in the 20MB range. Copying one into the sandbox costs that space permanently, and creates a second problem: after a system update replaces the font, the copy is stale.
A symlink hands libass a path, and FreeType opens it lazily, reading only the tables it needs.
One detail worth noting: fs.accessSync follows symlinks. So “the link exists but its target is gone” — a system update swapped the font out — reads as non-existent and triggers a rebuild. That behaviour is exactly what’s wanted here. If accessSync only checked the link itself, an update would leave a dangling link behind and the symptom would be right back to “no subtitles.”
Why --sub-fonts-dir is deliberately avoided
mpv has an option that looks more direct: --sub-fonts-dir, pointing at a font directory. The obvious move is to point it at /system/fonts and be done.
You can’t. libass’s load_fonts_from_dir does fopen and read every file in the directory entirely into memory. Pointing it at /system/fonts swallows well over a hundred megabytes in one go — a video player taking a hundred-plus MB of resident memory in order to draw subtitles is not an acceptable trade on a phone.
The symlink route exposes exactly one file, read on demand. So the more roundabout-looking path is the one that shipped.
The .ttc extension trap
The second candidate is NotoSansCJK-Regular.ttc — .ttc is a TrueType Collection, not a single font. And the link name is fixed at subfont.ttf.
These don’t conflict: FreeType determines format from file content, not from the extension. A .ttc linked under a .ttf name still parses correctly, resolving to the first face in the collection. mpv’s path requires the filename subfont.ttf; give it what it asks for.
Fallback, and leaving a trace
try {
await fs.symlink(src, dest);
return;
} catch (e) {
hilog.warn(..., 'MpvFonts: symlink failed (code=%{public}d), copying instead', ...);
}
fs.copyFileSync(src, dest);
Creating a symlink into a read-only system partition from inside the sandbox is expected to work, but that dependency has no written guarantee. Whether subtitles render should not rest on undocumented behaviour, so a failed link falls back to a copy. Twenty megabytes is an ugly fallback; it beats the whole subtitle path silently failing a second time.
And when ensure() fails outright:
} catch (e) {
/*
* Staying silent here is precisely the bug this file exists to fix —
* subtitles that quietly don't render. Failing to stage a font must not
* block playback, but it must leave a trace.
*/
hilog.error(..., 'MpvFonts: staging failed code=%{public}d msg=%{public}s', ...);
return '';
}
No font doesn’t block playback — the film still plays, just without subtitles. But it must log an error. The reason this bug was expensive to find is that nothing along the chain ever spoke up. Fixing it was the moment to install that voice.
The rest of the subtitle work
With fonts working, the remaining subtitle items got finished together. Two of them left marks.
Secondary subtitles (bilingual, stacked) use mpv’s secondary-sid, drawn at the top of the frame so it naturally clears the primary. The candidate list has to exclude two things: bitmap tracks (PGS/VobSub — they set without error and then don’t render) and whichever track is currently primary (mpv rejects it outright). In both cases the user experiences “I tapped it and nothing happened,” so it’s better to withhold the option than to offer one that does nothing. The feature exists only under the mpv engine: AVPlayer emits text for one track at a time, so there’s no source for a second.
Drawing subtitles onto the letterbox bars pulled a chain behind it. XComponent used to be laid out at the video’s aspect ratio, which made the bars an ArkUI background — outside mpv’s canvas entirely (osd-dimensions’ mt/mb/ml/mr were all 0), so no value of sub-pos could ever reach them. Now, under software decoding, the whole stage is handed to mpv, which letterboxes internally; sub-pos past 100 lands on the bars.
Two consequences appeared immediately:
- PiP got stretched. The floating window opens at the video’s aspect ratio, so a surface at stage ratio no longer matches. While
pipActive, the old “surface exactly equals the picture” rule is restored. - Subtitles became enormous. mpv’s
sub-font-sizeis measured in “pixels at a canvas height of 720.” Nearly double the canvas height, nearly double the type size. The hard-coded ×2.2 factor had been tuned against the old canvas. It’s now derived from canvas height, so on-screen size depends only onsubtitleSize(in vp) and is recomputed whenever the canvas changes — rotation, unfolding, entering or leaving PiP.
That second one is textbook magic-number debt: a coefficient tuned to look right at one canvas size quietly turns that size into an unstated precondition. Change the canvas and the coefficient is wrong, and the way it’s wrong — “text is twice as big” — looks like a styling issue while the cause lives in the coordinate system.
What this taught me
“Every layer reports fine” is the most expensive failure mode there is. Every state I could query in this bug was correct: track present, sid right, visibility on, text available. Every assertion passes, which means automated tests go green too. The only thing that punctured it was the engine’s own verbose log. When integrating a third-party engine, working out how to extract its internal logs ranks above the integration itself.
Defaults are invisible dependencies. mpv_create() setting config=no is documented and is reasonable library behaviour. But it removes the entire concept of a config directory, and the feature that depends on that concept (subfont.ttf) doesn’t complain — it just stops working. Every time you swap a standalone tool for its embedded library form, ask which of the standalone defaults got turned off.
Dropping a dependency means auditing everything downstream of it. Skipping fontconfig was a one-line cross-compile decision — “HarmonyOS doesn’t have it, skip” — and it was correct at the time. But libass’s entire font lookup is built on providers, and with no provider its fallback is nothing rather than a default. The moment you drop a dependency, the question to ask is: who used it, and what does their behaviour degrade to without it.
Comments