From 750cd50c0832d6af833fd3a687143d6a02e60130 Mon Sep 17 00:00:00 2001 From: Ortes Date: Sat, 25 Jul 2026 14:32:41 +0200 Subject: [PATCH] feat: render inline markup in subtitle cue text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cue text extracted from WebVTT and SubRip files carries inline markup, and chewie rendered it with Text(text.toString()), so viewers read "The law is the law." instead of italics. parseSubtitleMarkup turns a cue into an InlineSpan: , , and 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. --- README.md | 53 +++- example/lib/app/app.dart | 35 ++- lib/chewie.dart | 1 + lib/src/chewie_player.dart | 17 ++ lib/src/cupertino/cupertino_controls.dart | 26 +- lib/src/material/material_controls.dart | 26 +- .../material/material_desktop_controls.dart | 26 +- lib/src/models/index.dart | 1 + lib/src/models/subtitle_style.dart | 105 +++++++ lib/src/subtitle_markup.dart | 263 ++++++++++++++++ lib/src/subtitle_overlay.dart | 58 ++++ test/subtitle_markup_test.dart | 281 ++++++++++++++++++ test/subtitle_overlay_test.dart | 141 +++++++++ test/subtitle_style_test.dart | 64 ++++ 14 files changed, 1024 insertions(+), 73 deletions(-) create mode 100644 lib/src/models/subtitle_style.dart create mode 100644 lib/src/subtitle_markup.dart create mode 100644 lib/src/subtitle_overlay.dart create mode 100644 test/subtitle_markup_test.dart create mode 100644 test/subtitle_overlay_test.dart create mode 100644 test/subtitle_style_test.dart diff --git a/README.md b/README.md index 0d7d0dc5d..bae494eea 100644 --- a/README.md +++ b/README.md @@ -168,7 +168,7 @@ optionsTranslation: OptionsTranslation( > Since version 1.1.0, Chewie supports subtitles. -Chewie allows you to enhance the video playback experience with text overlays. You can add a `List` to your `ChewieController` and fully customize their appearance using the `subtitleBuilder` function. +Chewie allows you to enhance the video playback experience with text overlays. You can add a `List` to your `ChewieController`, restyle the default subtitle box with `subtitleStyle`, or replace it entirely with the `subtitleBuilder` function. ### Showing Subtitles by Default @@ -228,9 +228,58 @@ Subtitle( ), ``` +### Markup in Subtitle Text + +Cue text extracted from WebVTT or SubRip files often carries inline markup, and Chewie renders it for you: + +```dart +Subtitle( + index: 0, + start: Duration.zero, + end: const Duration(seconds: 10), + text: 'The law is the law, Mr. Hancock.', +), +``` + +``, ``, `` and `` are applied on top of your text style. The other WebVTT cue tags — ``, ``, ``, ``/`` and timestamp tags — are dropped while their text is kept, and escapes such as `&` are decoded. A tag that opens on one line and closes on the next works too. + +Parsing is lenient, so cue text is never mangled: `5 < 10` and `<3` are shown as written, an unclosed tag simply runs to the end of the cue, and a stray closing tag is ignored. If you would rather show cue text exactly as it arrives, set `subtitleStyle: SubtitleStyle(renderMarkup: false)`. + +### Styling Subtitles + +`subtitleStyle` changes how the default subtitle box looks without giving up markup rendering: + +```dart +ChewieController( + videoPlayerController: _videoPlayerController, + subtitleStyle: const SubtitleStyle( + textStyle: TextStyle(fontSize: 22, color: Colors.amber), + textAlign: TextAlign.center, + padding: EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration(color: Colors.black54), + ), +); +``` + +Leaving `textStyle.color` unset inherits the colour from the surrounding `DefaultTextStyle`, which is what Chewie does by default. + ### Customizing Subtitles -Use the `subtitleBuilder` function to customize how subtitles are rendered, allowing you to modify text styles, add padding, or apply other customizations to your subtitles. +Reach for `subtitleBuilder` when you need to build the whole widget yourself. It receives the cue exactly as you supplied it — markup and all — and Chewie's own rendering, including `subtitleStyle`, is skipped. Run the cue through `parseSubtitleMarkup` to keep markup working: + +```dart +subtitleBuilder: (context, subtitle) => Container( + padding: const EdgeInsets.all(10.0), + child: Text.rich( + subtitle is String + ? parseSubtitleMarkup( + subtitle, + style: const TextStyle(color: Colors.white), + ) + : TextSpan(text: subtitle.toString()), + ), +), +``` ## 🧪 Example diff --git a/example/lib/app/app.dart b/example/lib/app/app.dart index b28a3a411..54c9fc0dc 100644 --- a/example/lib/app/app.dart +++ b/example/lib/app/app.dart @@ -104,6 +104,16 @@ class _ChewieDemoState extends State { // style: TextStyle(color: Colors.amber, fontSize: 22, fontStyle: FontStyle.italic), // ), ), + Subtitle( + index: 0, + start: const Duration(seconds: 20), + end: const Duration(seconds: 30), + // Markup like this turns up in real WebVTT and SubRip files, and is + // rendered without any help from a subtitleBuilder. + text: + 'Cue text can be italic, bold or ' + 'coloured.', + ), ]; _chewieController = ChewieController( @@ -126,15 +136,24 @@ class _ChewieDemoState extends State { }, subtitle: Subtitles(subtitles), showSubtitles: true, - subtitleBuilder: (context, dynamic subtitle) => Container( - padding: const EdgeInsets.all(10.0), - child: subtitle is InlineSpan - ? RichText(text: subtitle) - : Text( - subtitle.toString(), - style: const TextStyle(color: Colors.black), - ), + subtitleStyle: const SubtitleStyle( + textStyle: TextStyle(fontSize: 20, color: Colors.white), ), + // subtitleBuilder replaces the default box entirely, so subtitleStyle no + // longer applies and cue markup is yours to handle — parseSubtitleMarkup + // is the same parser chewie uses: + // + // subtitleBuilder: (context, dynamic subtitle) => Container( + // padding: const EdgeInsets.all(10.0), + // child: Text.rich( + // subtitle is InlineSpan + // ? subtitle + // : parseSubtitleMarkup( + // subtitle.toString(), + // style: const TextStyle(color: Colors.white), + // ), + // ), + // ), hideControlsTimer: const Duration(seconds: 1), diff --git a/lib/chewie.dart b/lib/chewie.dart index 7061505ad..e162785b6 100644 --- a/lib/chewie.dart +++ b/lib/chewie.dart @@ -7,3 +7,4 @@ export 'src/material/material_controls.dart'; export 'src/material/material_desktop_controls.dart'; export 'src/material/material_progress_bar.dart'; export 'src/models/index.dart'; +export 'src/subtitle_markup.dart'; diff --git a/lib/src/chewie_player.dart b/lib/src/chewie_player.dart index 7ffa295b2..7d2686293 100644 --- a/lib/src/chewie_player.dart +++ b/lib/src/chewie_player.dart @@ -4,6 +4,7 @@ import 'package:chewie/src/chewie_progress_colors.dart'; import 'package:chewie/src/models/option_item.dart'; import 'package:chewie/src/models/options_translation.dart'; import 'package:chewie/src/models/subtitle_model.dart'; +import 'package:chewie/src/models/subtitle_style.dart'; import 'package:chewie/src/notifiers/player_notifier.dart'; import 'package:chewie/src/player_with_controls.dart'; import 'package:flutter/foundation.dart'; @@ -316,6 +317,7 @@ class ChewieController extends ChangeNotifier { this.subtitle, this.showSubtitles = false, this.subtitleBuilder, + this.subtitleStyle = const SubtitleStyle(), this.customControls, this.errorBuilder, this.bufferingBuilder, @@ -369,6 +371,7 @@ class ChewieController extends ChangeNotifier { Subtitles? subtitle, bool? showSubtitles, Widget Function(BuildContext, dynamic)? subtitleBuilder, + SubtitleStyle? subtitleStyle, Widget? customControls, WidgetBuilder? bufferingBuilder, Widget Function(BuildContext, String)? errorBuilder, @@ -431,6 +434,7 @@ class ChewieController extends ChangeNotifier { showSubtitles: showSubtitles ?? this.showSubtitles, subtitle: subtitle ?? this.subtitle, subtitleBuilder: subtitleBuilder ?? this.subtitleBuilder, + subtitleStyle: subtitleStyle ?? this.subtitleStyle, customControls: customControls ?? this.customControls, errorBuilder: errorBuilder ?? this.errorBuilder, bufferingBuilder: bufferingBuilder ?? this.bufferingBuilder, @@ -491,11 +495,24 @@ class ChewieController extends ChangeNotifier { final List Function(BuildContext context)? additionalOptions; /// Define here your own Widget on how your n'th subtitle will look like + /// + /// Receives the cue exactly as it was supplied, markup and all. Chewie's own + /// rendering — including [SubtitleStyle] and markup parsing — is skipped + /// entirely. To keep markup while building your own widget, run the cue + /// through `parseSubtitleMarkup` yourself. Widget Function(BuildContext context, dynamic subtitle)? subtitleBuilder; /// Add a List of Subtitles here in `Subtitles.subtitle` Subtitles? subtitle; + /// How the default subtitle box looks: text style, alignment, padding and + /// the box behind the text. + /// + /// Cue markup such as `` is rendered whatever this is set to, so styling + /// subtitles does not cost you italics. Ignored when [subtitleBuilder] is + /// set. + SubtitleStyle subtitleStyle; + /// Determines whether subtitles should be shown by default when the video starts. /// /// If set to `true`, subtitles will be displayed automatically when the video diff --git a/lib/src/cupertino/cupertino_controls.dart b/lib/src/cupertino/cupertino_controls.dart index 201f236f0..251700799 100644 --- a/lib/src/cupertino/cupertino_controls.dart +++ b/lib/src/cupertino/cupertino_controls.dart @@ -12,6 +12,7 @@ import 'package:chewie/src/helpers/utils.dart'; import 'package:chewie/src/models/option_item.dart'; import 'package:chewie/src/models/subtitle_model.dart'; import 'package:chewie/src/notifiers/index.dart'; +import 'package:chewie/src/subtitle_overlay.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -205,27 +206,10 @@ class _CupertinoControlsState extends State return const SizedBox(); } - if (chewieController.subtitleBuilder != null) { - return chewieController.subtitleBuilder!( - context, - currentSubtitle.first!.text, - ); - } - - return Padding( - padding: EdgeInsets.only(left: marginSize, right: marginSize), - child: Container( - padding: const EdgeInsets.all(5), - decoration: BoxDecoration( - color: const Color(0x96000000), - borderRadius: BorderRadius.circular(10.0), - ), - child: Text( - currentSubtitle.first!.text.toString(), - style: const TextStyle(fontSize: 18), - textAlign: TextAlign.center, - ), - ), + return SubtitleOverlay( + chewieController: chewieController, + margin: EdgeInsets.only(left: marginSize, right: marginSize), + text: currentSubtitle.first!.text, ); } diff --git a/lib/src/material/material_controls.dart b/lib/src/material/material_controls.dart index 3d43a1ab8..d94db6d18 100644 --- a/lib/src/material/material_controls.dart +++ b/lib/src/material/material_controls.dart @@ -11,6 +11,7 @@ import 'package:chewie/src/material/widgets/playback_speed_dialog.dart'; import 'package:chewie/src/models/option_item.dart'; import 'package:chewie/src/models/subtitle_model.dart'; import 'package:chewie/src/notifiers/index.dart'; +import 'package:chewie/src/subtitle_overlay.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:video_player/video_player.dart'; @@ -217,27 +218,10 @@ class _MaterialControlsState extends State return const SizedBox(); } - if (chewieController.subtitleBuilder != null) { - return chewieController.subtitleBuilder!( - context, - currentSubtitle.first!.text, - ); - } - - return Padding( - padding: EdgeInsets.all(marginSize), - child: Container( - padding: const EdgeInsets.all(5), - decoration: BoxDecoration( - color: const Color(0x96000000), - borderRadius: BorderRadius.circular(10.0), - ), - child: Text( - currentSubtitle.first!.text.toString(), - style: const TextStyle(fontSize: 18), - textAlign: TextAlign.center, - ), - ), + return SubtitleOverlay( + chewieController: chewieController, + margin: EdgeInsets.all(marginSize), + text: currentSubtitle.first!.text, ); } diff --git a/lib/src/material/material_desktop_controls.dart b/lib/src/material/material_desktop_controls.dart index acdd4a8b0..c134de2e7 100644 --- a/lib/src/material/material_desktop_controls.dart +++ b/lib/src/material/material_desktop_controls.dart @@ -11,6 +11,7 @@ import 'package:chewie/src/material/widgets/playback_speed_dialog.dart'; import 'package:chewie/src/models/option_item.dart'; import 'package:chewie/src/models/subtitle_model.dart'; import 'package:chewie/src/notifiers/index.dart'; +import 'package:chewie/src/subtitle_overlay.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; @@ -230,27 +231,10 @@ class _MaterialDesktopControlsState extends State return const SizedBox(); } - if (chewieController.subtitleBuilder != null) { - return chewieController.subtitleBuilder!( - context, - currentSubtitle.first!.text, - ); - } - - return Padding( - padding: EdgeInsets.all(marginSize), - child: Container( - padding: const EdgeInsets.all(5), - decoration: BoxDecoration( - color: const Color(0x96000000), - borderRadius: BorderRadius.circular(10.0), - ), - child: Text( - currentSubtitle.first!.text.toString(), - style: const TextStyle(fontSize: 18), - textAlign: TextAlign.center, - ), - ), + return SubtitleOverlay( + chewieController: chewieController, + margin: EdgeInsets.all(marginSize), + text: currentSubtitle.first!.text, ); } diff --git a/lib/src/models/index.dart b/lib/src/models/index.dart index a308c33db..fdf88cbd9 100644 --- a/lib/src/models/index.dart +++ b/lib/src/models/index.dart @@ -1,3 +1,4 @@ export 'option_item.dart'; export 'options_translation.dart'; export 'subtitle_model.dart'; +export 'subtitle_style.dart'; diff --git a/lib/src/models/subtitle_style.dart b/lib/src/models/subtitle_style.dart new file mode 100644 index 000000000..890e27006 --- /dev/null +++ b/lib/src/models/subtitle_style.dart @@ -0,0 +1,105 @@ +import 'package:flutter/widgets.dart'; + +/// How the default subtitle box looks. +/// +/// Chewie renders cues itself unless a `subtitleBuilder` is given, and this is +/// the knob for that default rendering: text style, alignment, the padding +/// inside the box and the box itself. Inline markup in the cue text is parsed +/// either way, so restyling subtitles no longer means giving up italics. +/// +/// The defaults reproduce Chewie's long-standing look, so setting nothing +/// changes nothing. +/// +/// ```dart +/// ChewieController( +/// videoPlayerController: controller, +/// subtitleStyle: const SubtitleStyle( +/// textStyle: TextStyle(fontSize: 22, color: Color(0xFFFFC107)), +/// decoration: BoxDecoration(color: Color(0xCC000000)), +/// ), +/// ); +/// ``` +/// +/// Ignored when `subtitleBuilder` is set — that hook replaces the default +/// rendering wholesale. +class SubtitleStyle { + const SubtitleStyle({ + this.textStyle = const TextStyle(fontSize: 18), + this.textAlign = TextAlign.center, + this.padding = const EdgeInsets.all(5), + this.decoration = const BoxDecoration( + color: Color(0x96000000), + borderRadius: BorderRadius.all(Radius.circular(10.0)), + ), + this.renderMarkup = true, + }); + + /// Base style for the cue text. + /// + /// Markup merges onto it rather than replacing it, so `` inside a cue + /// bolds text that keeps this style's size and colour. Leaving `color` unset + /// inherits it from the surrounding [DefaultTextStyle], as Chewie has always + /// done. + final TextStyle textStyle; + + /// How cue lines are aligned against each other. + final TextAlign textAlign; + + /// Space between the text and the edge of the box drawn by [decoration]. + final EdgeInsetsGeometry padding; + + /// The box painted behind the cue text. + final Decoration decoration; + + /// Whether inline markup in the cue text is rendered. + /// + /// When `true` (the default), ``, ``, `` and `` are + /// applied, other WebVTT tags are dropped while keeping their text, and + /// escapes such as `&` are decoded. Text that only resembles a tag, like + /// `5 < 10`, is left alone. + /// + /// Set to `false` to display cue text exactly as it arrives, tags included. + final bool renderMarkup; + + SubtitleStyle copyWith({ + TextStyle? textStyle, + TextAlign? textAlign, + EdgeInsetsGeometry? padding, + Decoration? decoration, + bool? renderMarkup, + }) { + return SubtitleStyle( + textStyle: textStyle ?? this.textStyle, + textAlign: textAlign ?? this.textAlign, + padding: padding ?? this.padding, + decoration: decoration ?? this.decoration, + renderMarkup: renderMarkup ?? this.renderMarkup, + ); + } + + @override + String toString() => + 'SubtitleStyle(textStyle: $textStyle, textAlign: $textAlign, ' + 'padding: $padding, decoration: $decoration, ' + 'renderMarkup: $renderMarkup)'; + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + return other is SubtitleStyle && + other.textStyle == textStyle && + other.textAlign == textAlign && + other.padding == padding && + other.decoration == decoration && + other.renderMarkup == renderMarkup; + } + + @override + int get hashCode => + textStyle.hashCode ^ + textAlign.hashCode ^ + padding.hashCode ^ + decoration.hashCode ^ + renderMarkup.hashCode; +} diff --git a/lib/src/subtitle_markup.dart b/lib/src/subtitle_markup.dart new file mode 100644 index 000000000..ecbe5ed2d --- /dev/null +++ b/lib/src/subtitle_markup.dart @@ -0,0 +1,263 @@ +import 'package:flutter/painting.dart'; + +/// Parses the inline markup found in WebVTT and SubRip cue text into an +/// [InlineSpan] ready to hand to `Text.rich`. +/// +/// WebVTT specifies a cue text tag grammar — ``, ``, ``, ``, +/// ``, ``, ``/``, timestamp tags such as +/// `<00:00:01.000>`, and character escapes like `&`. SubRip has no formal +/// specification but conventionally uses ``, ``, `` and +/// ``. Both are handled here. +/// +/// Tag styles are merged onto [style], so a caller's font size and colour +/// survive: `` adds `FontWeight.bold` to the caller's style rather than +/// replacing it. +/// +/// Parsing covers the whole cue at once, so a tag that opens on one line and +/// closes on the next is honoured: +/// +/// ``` +/// un paquet de muscles +/// qui ne pense qu'à lui. +/// ``` +/// +/// Parsing is deliberately lenient, so cue text is never mangled by a `<` that +/// was never meant as markup: +/// +/// * A `<` only starts a tag when what follows is a well-formed tag on the same +/// line. `5 < 10` and `<3` come out verbatim. +/// * Tags outside the supported set are dropped but their content is kept, so a +/// raw tag never reaches the screen. +/// * An unclosed tag applies through the end of the cue, and a stray closing +/// tag is ignored. Neither throws. +/// +/// Chewie applies this by default when rendering cues; see +/// [ChewieController.subtitleStyle]. Call it directly to keep markup rendering +/// inside a custom [ChewieController.subtitleBuilder]: +/// +/// ```dart +/// subtitleBuilder: (context, dynamic subtitle) => Container( +/// padding: const EdgeInsets.all(10), +/// child: Text.rich( +/// subtitle is String +/// ? parseSubtitleMarkup(subtitle) +/// : TextSpan(text: subtitle.toString()), +/// ), +/// ), +/// ``` +TextSpan parseSubtitleMarkup( + String cueText, { + TextStyle style = const TextStyle(), +}) { + final builder = _SpanBuilder(style); + final buffer = StringBuffer(); + var i = 0; + + void flushText() { + if (buffer.isEmpty) return; + builder.addText(_decodeEntities(buffer.toString())); + buffer.clear(); + } + + while (i < cueText.length) { + final char = cueText[i]; + if (char == '<') { + final tag = _matchTag(cueText, i); + if (tag != null) { + flushText(); + if (tag.isTimestamp) { + // Karaoke-style progressive reveal needs the playhead, which the + // renderer does not have here. Drop the tag, keep the text. + } else if (tag.isClosing) { + builder.close(tag.name); + } else { + builder.open(tag.name, _styleForTag(tag)); + } + i = tag.end; + continue; + } + } + buffer.write(char); + i++; + } + flushText(); + + return builder.build(); +} + +/// A well-formed tag matched at some offset in the cue text. +class _Tag { + const _Tag({ + required this.name, + required this.attributes, + required this.isClosing, + required this.isTimestamp, + required this.end, + }); + + final String name; + final String attributes; + final bool isClosing; + final bool isTimestamp; + + /// Offset just past the closing `>`. + final int end; +} + +/// ``, `` or ``, all on one line — WebVTT tags never +/// span a line break, even though the content between them may. +final RegExp _tagPattern = RegExp(r'<(/?)([a-zA-Z][\w.-]*)((?:\s[^<>\n]*)?)>'); + +/// A WebVTT timestamp tag, e.g. `<00:00:01.000>` or `<01:23.456>`. +final RegExp _timestampPattern = RegExp(r'<(?:\d{2,}:)?\d{2}:\d{2}[.,]\d{3}>'); + +/// `&`-style escapes, including numeric ones. +final RegExp _entityPattern = RegExp(r'&(#\d+|#[xX][0-9a-fA-F]+|\w+);'); + +/// `color="#rrggbb"` on a `` tag, with or without quotes. +final RegExp _fontColorPattern = RegExp( + '''color\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s"'>]+))''', + caseSensitive: false, +); + +const Map _namedEntities = { + 'amp': '&', + 'lt': '<', + 'gt': '>', + 'quot': '"', + 'apos': "'", + 'nbsp': ' ', + 'lrm': '‎', + 'rlm': '‏', +}; + +_Tag? _matchTag(String source, int start) { + final timestamp = _timestampPattern.matchAsPrefix(source, start); + if (timestamp != null) { + return _Tag( + name: '', + attributes: '', + isClosing: false, + isTimestamp: true, + end: timestamp.end, + ); + } + + final match = _tagPattern.matchAsPrefix(source, start); + if (match == null) return null; + + return _Tag( + name: match.group(2)!.toLowerCase(), + attributes: match.group(3) ?? '', + isClosing: match.group(1) == '/', + isTimestamp: false, + end: match.end, + ); +} + +/// The style a tag contributes, or `null` when it only carries structure. +/// +/// Unsupported tags land here too: they are stripped without changing the +/// style, which keeps their text on screen and the tag itself off it. +TextStyle? _styleForTag(_Tag tag) { + switch (tag.name) { + case 'b': + return const TextStyle(fontWeight: FontWeight.bold); + case 'i': + return const TextStyle(fontStyle: FontStyle.italic); + case 'u': + return const TextStyle(decoration: TextDecoration.underline); + case 'font': + final color = _parseFontColor(tag.attributes); + return color == null ? null : TextStyle(color: color); + default: + return null; + } +} + +/// Reads `color="#rrggbb"` off a `` tag. `#rgb`, `#rrggbb` and +/// `#aarrggbb` are accepted; anything else yields `null`, which drops the tag +/// without touching the style. +Color? _parseFontColor(String attributes) { + final match = _fontColorPattern.firstMatch(attributes); + if (match == null) return null; + + final value = (match.group(1) ?? match.group(2) ?? match.group(3) ?? '') + .trim(); + if (!value.startsWith('#')) return null; + + final digits = value.substring(1); + if (!RegExp(r'^[0-9a-fA-F]+$').hasMatch(digits)) return null; + + switch (digits.length) { + case 3: + final r = digits[0]; + final g = digits[1]; + final b = digits[2]; + return Color(int.parse('ff$r$r$g$g$b$b', radix: 16)); + case 6: + return Color(int.parse('ff$digits', radix: 16)); + case 8: + return Color(int.parse(digits, radix: 16)); + default: + return null; + } +} + +/// Replaces character escapes in a run of text. +/// +/// Only applied to text, never across a tag, so `<b>` shows the literal +/// characters `` instead of turning into a bold tag. +String _decodeEntities(String text) { + if (!text.contains('&')) return text; + + return text.replaceAllMapped(_entityPattern, (match) { + final body = match.group(1)!; + if (body.startsWith('#')) { + final isHex = body[1] == 'x' || body[1] == 'X'; + final digits = isHex ? body.substring(2) : body.substring(1); + final code = int.tryParse(digits, radix: isHex ? 16 : 10); + if (code == null || code < 0 || code > 0x10FFFF) return match.group(0)!; + return String.fromCharCode(code); + } + return _namedEntities[body.toLowerCase()] ?? match.group(0)!; + }); +} + +/// Accumulates leaf spans while a stack of open tags tracks the style in +/// effect. +/// +/// The result is flat: every leaf carries its own fully merged style, which +/// renders identically to a nested tree and is far easier to reason about. +class _SpanBuilder { + _SpanBuilder(this.baseStyle) : _styles = [baseStyle]; + + final TextStyle baseStyle; + final List _styles; + final List _openTags = []; + final List _spans = []; + + void addText(String text) { + if (text.isEmpty) return; + _spans.add(TextSpan(text: text, style: _styles.last)); + } + + void open(String name, TextStyle? style) { + _openTags.add(name); + _styles.add(style == null ? _styles.last : _styles.last.merge(style)); + } + + /// Closes [name]. A tag that was never opened is ignored; a tag that was + /// opened below others closes those too, the way browsers repair overlapping + /// markup such as `x`. + void close(String name) { + final index = _openTags.lastIndexOf(name); + if (index < 0) return; + _openTags.removeRange(index, _openTags.length); + _styles.removeRange(index + 1, _styles.length); + } + + /// Unclosed tags need no unwinding: their style simply applied to every leaf + /// added while they were open. + TextSpan build() => TextSpan(style: baseStyle, children: _spans); +} diff --git a/lib/src/subtitle_overlay.dart b/lib/src/subtitle_overlay.dart new file mode 100644 index 000000000..fe77f1e42 --- /dev/null +++ b/lib/src/subtitle_overlay.dart @@ -0,0 +1,58 @@ +import 'package:chewie/src/chewie_player.dart'; +import 'package:chewie/src/subtitle_markup.dart'; +import 'package:flutter/widgets.dart'; + +/// Renders one subtitle cue. +/// +/// Shared by the Material, Material desktop and Cupertino controls so the three +/// skins can't drift on how a cue is turned into pixels. Each skin still owns +/// when a cue is shown and how far it sits from the player edge — that is +/// [margin], the one thing they legitimately disagree about. +class SubtitleOverlay extends StatelessWidget { + const SubtitleOverlay({ + super.key, + required this.chewieController, + required this.margin, + required this.text, + }); + + final ChewieController chewieController; + + /// Space between the subtitle box and the surrounding controls. + final EdgeInsetsGeometry margin; + + /// The cue payload: a `String` of cue text, a ready-made [InlineSpan], or + /// anything else, which falls back to `toString()`. + final dynamic text; + + @override + Widget build(BuildContext context) { + // The builder wins, and it sees the payload untouched — parsing it first + // would change what every existing implementation receives. + final subtitleBuilder = chewieController.subtitleBuilder; + if (subtitleBuilder != null) { + return subtitleBuilder(context, text); + } + + final style = chewieController.subtitleStyle; + final InlineSpan span = switch (text) { + final InlineSpan span => span, + final String cueText when style.renderMarkup => parseSubtitleMarkup( + cueText, + style: style.textStyle, + ), + _ => TextSpan(text: text.toString(), style: style.textStyle), + }; + + return Padding( + padding: margin, + child: Container( + padding: style.padding, + decoration: style.decoration, + // Text.rich rather than RichText: RichText opts out of text scaling, + // which would drop the user's font size preference. + child: Text.rich(span, textAlign: style.textAlign), + ), + ); + } +} diff --git a/test/subtitle_markup_test.dart b/test/subtitle_markup_test.dart new file mode 100644 index 000000000..4d8d93681 --- /dev/null +++ b/test/subtitle_markup_test.dart @@ -0,0 +1,281 @@ +import 'package:chewie/src/subtitle_markup.dart'; +import 'package:flutter/painting.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// The text a viewer would actually read. +String plainText(TextSpan span) => span.toPlainText(); + +/// The style in effect over [needle], which must appear in exactly one leaf. +TextStyle styleOf(TextSpan span, String needle) { + final matches = span.children! + .cast() + .where((child) => child.text!.contains(needle)) + .toList(); + expect( + matches, + hasLength(1), + reason: 'expected exactly one leaf containing "$needle"', + ); + return matches.single.style!; +} + +void main() { + group('parseSubtitleMarkup', () { + const base = TextStyle(fontSize: 18); + + test('passes tagless text through unchanged', () { + final span = parseSubtitleMarkup( + 'Tu comprends ce qu\'ils disent ?', + style: base, + ); + + expect(plainText(span), "Tu comprends ce qu'ils disent ?"); + expect(span.children, hasLength(1)); + expect(span.children!.first.style, base); + }); + + test('renders an empty cue without children', () { + expect(plainText(parseSubtitleMarkup('')), ''); + }); + + test('applies ', () { + final span = parseSubtitleMarkup( + 'La loi est la loi, M. Hancock.', + style: base, + ); + + expect(plainText(span), 'La loi est la loi, M. Hancock.'); + expect(styleOf(span, 'La loi').fontStyle, FontStyle.italic); + }); + + test('applies ', () { + final span = parseSubtitleMarkup('loud', style: base); + + expect(plainText(span), 'loud'); + expect(styleOf(span, 'loud').fontWeight, FontWeight.bold); + }); + + test('applies ', () { + final span = parseSubtitleMarkup('title', style: base); + + expect(plainText(span), 'title'); + expect(styleOf(span, 'title').decoration, TextDecoration.underline); + }); + + test('is case insensitive about tag names', () { + final span = parseSubtitleMarkup('oui', style: base); + + expect(plainText(span), 'oui'); + expect(styleOf(span, 'oui').fontStyle, FontStyle.italic); + }); + + test('merges nested tags', () { + final span = parseSubtitleMarkup('both', style: base); + + final style = styleOf(span, 'both'); + expect(style.fontWeight, FontWeight.bold); + expect(style.fontStyle, FontStyle.italic); + }); + + test('restores the outer style after a nested tag closes', () { + final span = parseSubtitleMarkup( + 'plain italic plain again', + style: base, + ); + + expect(plainText(span), 'plain italic plain again'); + expect(styleOf(span, 'italic').fontStyle, FontStyle.italic); + expect(styleOf(span, 'plain again').fontStyle, isNull); + }); + + test('merges tag styles onto the caller style instead of replacing it', () { + final span = parseSubtitleMarkup( + 'x', + style: const TextStyle(fontSize: 20, color: Color(0xFF0000FF)), + ); + + final style = styleOf(span, 'x'); + expect(style.fontSize, 20); + expect(style.color, const Color(0xFF0000FF)); + expect(style.fontWeight, FontWeight.bold); + }); + + test('keeps a tag styled across a line break inside one cue', () { + final span = parseSubtitleMarkup( + 'un paquet de muscles\nqui ne pense qu\'à lui.', + style: base, + ); + + expect(plainText(span), "un paquet de muscles\nqui ne pense qu'à lui."); + expect(span.children, hasLength(1)); + expect(styleOf(span, 'paquet').fontStyle, FontStyle.italic); + }); + + group('', () { + test('applies a #rrggbb colour', () { + final span = parseSubtitleMarkup( + 'red', + style: base, + ); + + expect(plainText(span), 'red'); + expect(styleOf(span, 'red').color, const Color(0xFFFF0000)); + }); + + test('applies a #rgb colour', () { + final span = parseSubtitleMarkup( + 'red', + style: base, + ); + + expect(styleOf(span, 'red').color, const Color(0xFFFF0000)); + }); + + test('applies an #aarrggbb colour', () { + final span = parseSubtitleMarkup( + 'red', + style: base, + ); + + expect(styleOf(span, 'red').color, const Color(0x80FF0000)); + }); + + test('accepts an unquoted colour', () { + final span = parseSubtitleMarkup( + 'green', + style: base, + ); + + expect(styleOf(span, 'green').color, const Color(0xFF00FF00)); + }); + + test('drops the tag but keeps the text when the colour is unusable', () { + final span = parseSubtitleMarkup( + 'x', + style: base, + ); + + expect(plainText(span), 'x'); + expect(styleOf(span, 'x').color, isNull); + }); + + test('drops a tag carrying no colour at all', () { + final span = parseSubtitleMarkup('x'); + + expect(plainText(span), 'x'); + }); + }); + + group('structural WebVTT tags', () { + test('keeps the text of a voice span', () { + final span = parseSubtitleMarkup('Hello'); + + expect(plainText(span), 'Hello'); + }); + + test('keeps the text of a class span', () { + expect(plainText(parseSubtitleMarkup('shout')), 'shout'); + }); + + test('keeps the text of a language span', () { + expect( + plainText(parseSubtitleMarkup('bonjour')), + 'bonjour', + ); + }); + + test('keeps the text of a ruby annotation', () { + expect( + plainText(parseSubtitleMarkup('baseann')), + 'baseann', + ); + }); + + test('drops timestamp tags', () { + expect( + plainText(parseSubtitleMarkup('<00:00:01.000>now<01:23.456>later')), + 'nowlater', + ); + }); + + test('drops an unknown tag but keeps its content', () { + expect(plainText(parseSubtitleMarkup('x')), 'x'); + }); + }); + + group('character escapes', () { + test('decodes the named escapes', () { + expect(plainText(parseSubtitleMarkup('&')), '&'); + expect(plainText(parseSubtitleMarkup('<')), '<'); + expect(plainText(parseSubtitleMarkup('>')), '>'); + expect(plainText(parseSubtitleMarkup('a b')), 'a b'); + expect(plainText(parseSubtitleMarkup('‎')), '‎'); + expect(plainText(parseSubtitleMarkup('‏')), '‏'); + }); + + test('decodes numeric escapes', () { + expect(plainText(parseSubtitleMarkup('é')), 'é'); + expect(plainText(parseSubtitleMarkup('é')), 'é'); + }); + + test('shows an escaped tag as literal characters', () { + final span = parseSubtitleMarkup('<i>x</i>', style: base); + + expect(plainText(span), 'x'); + expect(styleOf(span, 'x').fontStyle, isNull); + }); + + test('leaves an unknown escape alone', () { + expect(plainText(parseSubtitleMarkup('&fnord;')), '&fnord;'); + }); + + test('leaves a bare ampersand alone', () { + expect(plainText(parseSubtitleMarkup('Smith & Sons')), 'Smith & Sons'); + }); + }); + + group('leniency', () { + test('leaves a less-than sign that is not a tag', () { + expect(plainText(parseSubtitleMarkup('5 < 10')), '5 < 10'); + }); + + test('leaves an emoticon alone', () { + expect(plainText(parseSubtitleMarkup('<3')), '<3'); + }); + + test('leaves a trailing less-than sign alone', () { + expect(plainText(parseSubtitleMarkup('what <')), 'what <'); + }); + + test('leaves an unterminated tag alone', () { + expect( + plainText(parseSubtitleMarkup('runs to the end', style: base); + + expect(plainText(span), 'runs to the end'); + expect(styleOf(span, 'runs').fontStyle, FontStyle.italic); + }); + + test('ignores a closing tag that was never opened', () { + final span = parseSubtitleMarkup('plain', style: base); + + expect(plainText(span), 'plain'); + expect(styleOf(span, 'plain').fontStyle, isNull); + }); + + test('repairs overlapping tags without dropping text', () { + final span = parseSubtitleMarkup('bold both tail'); + + expect(plainText(span), 'bold both tail'); + expect(styleOf(span, 'both').fontWeight, FontWeight.bold); + expect(styleOf(span, 'both').fontStyle, FontStyle.italic); + expect(styleOf(span, 'tail').fontWeight, isNull); + }); + }); + }); +} diff --git a/test/subtitle_overlay_test.dart b/test/subtitle_overlay_test.dart new file mode 100644 index 000000000..5add3d289 --- /dev/null +++ b/test/subtitle_overlay_test.dart @@ -0,0 +1,141 @@ +import 'package:chewie/chewie.dart'; +import 'package:chewie/src/subtitle_overlay.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:video_player/video_player.dart'; + +ChewieController buildController({ + Widget Function(BuildContext, dynamic)? subtitleBuilder, + SubtitleStyle subtitleStyle = const SubtitleStyle(), +}) { + return ChewieController( + videoPlayerController: VideoPlayerController.networkUrl( + Uri.parse('https://example.com/video.mp4'), + ), + autoPlay: false, + looping: false, + subtitleBuilder: subtitleBuilder, + subtitleStyle: subtitleStyle, + ); +} + +extension on WidgetTester { + Future pumpOverlay(ChewieController controller, dynamic text) { + return pumpWidget( + MaterialApp( + home: Scaffold( + body: SubtitleOverlay( + chewieController: controller, + margin: const EdgeInsets.all(5), + text: text, + ), + ), + ), + ); + } + + /// The cue as a viewer would read it. + String renderedText() => + widget(find.byType(Text)).textSpan?.toPlainText() ?? + widget(find.byType(Text)).data!; +} + +void main() { + group('SubtitleOverlay', () { + testWidgets('renders markup instead of showing the tags', (tester) async { + await tester.pumpOverlay(buildController(), 'italique'); + + expect(tester.renderedText(), 'italique'); + expect(find.textContaining('', findRichText: true), findsNothing); + }); + + testWidgets('keeps markup while honouring a custom style', (tester) async { + final controller = buildController( + subtitleStyle: const SubtitleStyle( + textStyle: TextStyle(fontSize: 30, color: Color(0xFFFFC107)), + textAlign: TextAlign.left, + padding: EdgeInsets.all(12), + decoration: BoxDecoration(color: Color(0xFF123456)), + ), + ); + await tester.pumpOverlay(controller, 'plain bold'); + + expect(tester.renderedText(), 'plain bold'); + + final text = tester.widget(find.byType(Text)); + expect(text.textAlign, TextAlign.left); + + final span = text.textSpan! as TextSpan; + final bold = span.children!.cast().last; + expect(bold.text, 'bold'); + expect(bold.style!.fontWeight, FontWeight.bold); + expect(bold.style!.fontSize, 30); + expect(bold.style!.color, const Color(0xFFFFC107)); + + final container = tester.widget(find.byType(Container)); + expect(container.padding, const EdgeInsets.all(12)); + expect( + container.decoration, + const BoxDecoration(color: Color(0xFF123456)), + ); + }); + + testWidgets('shows the tags when markup rendering is off', (tester) async { + final controller = buildController( + subtitleStyle: const SubtitleStyle(renderMarkup: false), + ); + await tester.pumpOverlay(controller, 'italique'); + + expect(tester.renderedText(), 'italique'); + }); + + testWidgets('hands subtitleBuilder the untouched cue', (tester) async { + Object? received; + final controller = buildController( + subtitleBuilder: (context, dynamic subtitle) { + received = subtitle; + return Text('builder: $subtitle'); + }, + ); + await tester.pumpOverlay(controller, 'italique'); + + expect(received, 'italique'); + expect(find.text('builder: italique'), findsOneWidget); + }); + + testWidgets('subtitleBuilder wins over the default box', (tester) async { + final controller = buildController( + subtitleBuilder: (context, dynamic subtitle) => const Text('custom'), + ); + await tester.pumpOverlay(controller, 'ignored'); + + expect(find.byType(Container), findsNothing); + expect(find.text('custom'), findsOneWidget); + }); + + testWidgets('renders a ready-made span as-is', (tester) async { + const span = TextSpan( + text: 'pre-built', + style: TextStyle(fontStyle: FontStyle.italic), + ); + await tester.pumpOverlay(buildController(), span); + + expect(tester.widget(find.byType(Text)).textSpan, same(span)); + }); + + testWidgets('falls back to toString for other payloads', (tester) async { + await tester.pumpOverlay(buildController(), 42); + + expect(tester.renderedText(), '42'); + }); + + testWidgets('uses the margin supplied by the controls', (tester) async { + await tester.pumpOverlay(buildController(), 'x'); + + expect( + tester.widget(find.byType(Padding).first).padding, + const EdgeInsets.all(5), + ); + }); + }); +} diff --git a/test/subtitle_style_test.dart b/test/subtitle_style_test.dart new file mode 100644 index 000000000..a625b010c --- /dev/null +++ b/test/subtitle_style_test.dart @@ -0,0 +1,64 @@ +import 'package:chewie/src/models/subtitle_style.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('SubtitleStyle', () { + test('defaults match the box chewie has always drawn', () { + const style = SubtitleStyle(); + + expect(style.textStyle, const TextStyle(fontSize: 18)); + expect(style.textStyle.color, isNull, reason: 'colour is inherited'); + expect(style.textAlign, TextAlign.center); + expect(style.padding, const EdgeInsets.all(5)); + expect( + style.decoration, + const BoxDecoration( + color: Color(0x96000000), + borderRadius: BorderRadius.all(Radius.circular(10.0)), + ), + ); + expect(style.renderMarkup, isTrue); + }); + + test('copyWith replaces only what it is given', () { + const style = SubtitleStyle(); + final updated = style.copyWith(textAlign: TextAlign.left); + + expect(updated.textAlign, TextAlign.left); + expect(updated.textStyle, style.textStyle); + expect(updated.padding, style.padding); + expect(updated.decoration, style.decoration); + expect(updated.renderMarkup, style.renderMarkup); + }); + + test('copyWith can turn markup rendering off', () { + expect( + const SubtitleStyle().copyWith(renderMarkup: false).renderMarkup, + isFalse, + ); + }); + + test('value equality compares every field', () { + const a = SubtitleStyle(); + const b = SubtitleStyle(); + + expect(a, equals(b)); + expect(a.hashCode, b.hashCode); + expect(a == a.copyWith(renderMarkup: false), isFalse); + expect(a == a.copyWith(padding: EdgeInsets.zero), isFalse); + expect( + a == a.copyWith(textStyle: const TextStyle(fontSize: 30)), + isFalse, + ); + }); + + test('toString includes the fields', () { + final description = const SubtitleStyle().toString(); + + expect(description, startsWith('SubtitleStyle(')); + expect(description, contains('renderMarkup: true')); + expect(description, contains('textAlign: TextAlign.center')); + }); + }); +}