Skip to content

feat: render inline markup in subtitle cue text - #958

Open
Ortes wants to merge 1 commit into
fluttercommunity:masterfrom
Ortes:feat/subtitle-markup-rendering
Open

feat: render inline markup in subtitle cue text#958
Ortes wants to merge 1 commit into
fluttercommunity:masterfrom
Ortes:feat/subtitle-markup-rendering

Conversation

@Ortes

@Ortes Ortes commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

The problem

Cue text extracted from WebVTT and SubRip files carries inline markup. Chewie renders cues with Text(text.toString()), so a viewer reads

<i>The law is the law, Mr. Hancock.</i>

instead of italics. Measured on a real library of 51 films: 126 <i></i> pairs across 3 539 cues (~4 %), 37 of them spanning two lines inside a single cue.

<i> is not stray junk — WebVTT specifies a cue text tag grammar (<b>, <i>, <u>, <c.class>, <v Speaker>, <lang xx>, <ruby>/<rt>, timestamp tags, and &amp;-style escapes), and SubRip conventionally uses <b>, <i>, <u> and <font color>. Every other player handles this in the player: ExoPlayer's Cue.text is a Spanned produced by its Subrip/WebVTT decoders, and browsers render WebVTT natively.

There is a second problem behind it. subtitleBuilder is currently the only way to change how a subtitle looks, and it replaces the renderer wholesale — so styling subtitles has always been all-or-nothing, and it is also the only place markup could ever have been handled.

What this adds

parseSubtitleMarkup(String cueText, {TextStyle style})TextSpan, exported from chewie.dart.

  • <b>, <i>, <u> and <font color="#rrggbb"> merge onto the caller's style, so a custom font size and colour survive.
  • The remaining WebVTT cue tags — and any unknown tag — are dropped while their text is kept, so a raw tag never reaches the screen.
  • &amp; &lt; &gt; &nbsp; &lrm; &rlm; and numeric escapes are decoded, in text only, so &lt;i&gt; shows literal angle brackets rather than turning into a tag.
  • The whole cue is parsed at once, so a tag that opens on one line and closes on the next works.
  • Parsing is lenient: a < only starts a tag when a well-formed tag follows on the same line, so 5 < 10 and <3 come out verbatim. An unclosed tag runs to the end of the cue and a stray closing tag is ignored; neither throws.

SubtitleStyle, a new ChewieController.subtitleStyle field covering textStyle, textAlign, padding, decoration and a renderMarkup opt-out. It changes how the default box looks without giving up markup rendering. Its defaults reproduce the current box exactly, so setting nothing changes nothing.

SubtitleOverlay, an internal widget the three control skins now share. They duplicated the same Padding/Container/Text block; they now differ only in the margin they pass in.

The three tiers end up as: default (markup, zero config) → subtitleStyle (presentation, markup kept) → subtitleBuilder (everything yours).

Backward compatibility

  • subtitleBuilder is untouched — same signature, same precedence, and it still receives the cue exactly as supplied, tags and all, before any parsing. There is a test asserting precisely that.
  • SubtitleStyle's defaults are the current hard-coded literals, including leaving textStyle.color unset so it keeps inheriting from the ambient DefaultTextStyle.
  • For a cue with no markup the parser is a passthrough.
  • For a cue with markup the rendering changes — from visibly broken text to the intended formatting. SubtitleStyle(renderMarkup: false) restores the literal output for anyone who wants it.
  • Text.rich rather than a bare RichText, so text scaling keeps working as it does with Text today.

Considered and left out

A richSubtitleBuilder(BuildContext, InlineSpan) hook. It would add a second builder with its own precedence rules to an already large controller, for a case the exported parser covers in one line inside the existing subtitleBuilder. Happy to add it as a follow-up if you'd rather have it.

Tests

45 new tests across test/subtitle_markup_test.dart, test/subtitle_style_test.dart and test/subtitle_overlay_test.dart, covering each tag, nesting, style composition, the multi-line case, every escape, all the leniency cases, and subtitleBuilder back-compat. dart format, flutter analyze lib and flutter test are clean.

README's Subtitles section and the example app are updated. CHANGELOG is left alone since entries are added at release time.

Cue text extracted from WebVTT and SubRip files carries inline markup, and
chewie rendered it with Text(text.toString()), so viewers read
"<i>The law is the law.</i>" instead of italics.

parseSubtitleMarkup turns a cue into an InlineSpan: <b>, <i>, <u> and
<font color> are applied on top of the caller's style, the remaining WebVTT
cue tags are dropped while their text is kept, and character escapes are
decoded. It parses the whole cue at once so a tag may span a line break, and
it is lenient — "5 < 10" and "<3" are left alone, an unclosed tag runs to the
end of the cue, and a stray closing tag is ignored.

Restyling subtitles used to mean replacing the renderer through
subtitleBuilder, which is also the only place markup could ever have been
handled. SubtitleStyle now covers text style, alignment, padding and the box
itself while chewie keeps rendering the cue, so presentation no longer costs
you semantics. subtitleBuilder is untouched: it still receives the cue exactly
as supplied, and parseSubtitleMarkup is exported so it can opt back in.

The three control skins duplicated the subtitle box; they now share
SubtitleOverlay and differ only in the margin they pass it.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant