From bc4bd104de07985a5809a99ca7723ea228b719b5 Mon Sep 17 00:00:00 2001 From: Ortes Date: Fri, 29 May 2026 16:21:23 -0500 Subject: [PATCH 1/4] Add multi-track subtitle selection Generalise subtitle support beyond a single on/off cue list: the controller now holds a source-agnostic list of selectable SubtitleTracks, an active id and an onSubtitleTrackChanged callback, plus a liveSubtitle ValueNotifier for streaming sources (e.g. HLS) that emit cue text over time. - ChewieController: subtitleTracks / activeSubtitleTrackId / onSubtitleTrackChanged / liveSubtitle, setSubtitleTracks, selectSubtitleTrack, setLiveSubtitle; copyWith + dispose wired. - Material + Material desktop controls: a Subtitles entry in the options menu opening a track picker, live-cue rendering reusing subtitleBuilder, and a track-aware toggle. - SubtitleTrackDialog widget + SubtitleTrack model. --- lib/src/chewie_player.dart | 66 ++++++++++ lib/src/material/material_controls.dart | 122 +++++++++++++++--- .../material/material_desktop_controls.dart | 116 ++++++++++++++--- .../widgets/subtitle_track_dialog.dart | 83 ++++++++++++ lib/src/models/index.dart | 1 + lib/src/models/subtitle_track.dart | 38 ++++++ 6 files changed, 388 insertions(+), 38 deletions(-) create mode 100644 lib/src/material/widgets/subtitle_track_dialog.dart create mode 100644 lib/src/models/subtitle_track.dart diff --git a/lib/src/chewie_player.dart b/lib/src/chewie_player.dart index 7ffa295b2..487ea1bf9 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_track.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,9 @@ class ChewieController extends ChangeNotifier { this.subtitle, this.showSubtitles = false, this.subtitleBuilder, + this.subtitleTracks = const [], + this.activeSubtitleTrackId, + this.onSubtitleTrackChanged, this.customControls, this.errorBuilder, this.bufferingBuilder, @@ -369,6 +373,9 @@ class ChewieController extends ChangeNotifier { Subtitles? subtitle, bool? showSubtitles, Widget Function(BuildContext, dynamic)? subtitleBuilder, + List? subtitleTracks, + Object? activeSubtitleTrackId, + void Function(SubtitleTrack? track)? onSubtitleTrackChanged, Widget? customControls, WidgetBuilder? bufferingBuilder, Widget Function(BuildContext, String)? errorBuilder, @@ -431,6 +438,10 @@ class ChewieController extends ChangeNotifier { showSubtitles: showSubtitles ?? this.showSubtitles, subtitle: subtitle ?? this.subtitle, subtitleBuilder: subtitleBuilder ?? this.subtitleBuilder, + subtitleTracks: subtitleTracks ?? this.subtitleTracks, + activeSubtitleTrackId: activeSubtitleTrackId ?? this.activeSubtitleTrackId, + onSubtitleTrackChanged: + onSubtitleTrackChanged ?? this.onSubtitleTrackChanged, customControls: customControls ?? this.customControls, errorBuilder: errorBuilder ?? this.errorBuilder, bufferingBuilder: bufferingBuilder ?? this.bufferingBuilder, @@ -502,6 +513,28 @@ class ChewieController extends ChangeNotifier { /// begins playing. If set to `false`, subtitles will be hidden by default. bool showSubtitles; + /// Selectable subtitle tracks shown in the options menu. + /// + /// Source-agnostic: the host populates this (e.g. from an HLS manifest) and + /// reacts to selection via [onSubtitleTrackChanged]. May change after the + /// video loads — use [setSubtitleTracks] so the controls rebuild. + List subtitleTracks; + + /// Id of the currently selected track in [subtitleTracks], or `null` when + /// subtitles are off. + Object? activeSubtitleTrackId; + + /// Called when the user picks a track from the menu (or `null` for "Off"). + final void Function(SubtitleTrack? track)? onSubtitleTrackChanged; + + /// The text of the cue currently on screen, for streaming sources that emit + /// cues over time (e.g. HLS) rather than as a static [subtitle] list. Push + /// updates with [setLiveSubtitle]; the controls render it when non-null. + final ValueNotifier liveSubtitle = ValueNotifier(null); + + /// Whether any selectable subtitle tracks are available. + bool get hasSubtitleTracks => subtitleTracks.isNotEmpty; + /// The controller for the video you want to play final VideoPlayerController videoPlayerController; @@ -718,6 +751,39 @@ class ChewieController extends ChangeNotifier { void setSubtitle(List newSubtitle) { subtitle = Subtitles(newSubtitle); } + + /// Replaces the selectable [subtitleTracks] and rebuilds the controls. + /// + /// Use when tracks become known only after the media loads (e.g. once an + /// HLS manifest is parsed). + void setSubtitleTracks(List tracks) { + subtitleTracks = tracks; + notifyListeners(); + } + + /// Selects [track] (or `null` to turn subtitles off), updating + /// [activeSubtitleTrackId], notifying [onSubtitleTrackChanged] and clearing + /// any on-screen [liveSubtitle] when turned off. + void selectSubtitleTrack(SubtitleTrack? track) { + activeSubtitleTrackId = track?.id; + if (track == null) { + liveSubtitle.value = null; + } + onSubtitleTrackChanged?.call(track); + notifyListeners(); + } + + /// Pushes the current cue text for streaming subtitle sources. Pass `null` + /// when no cue is showing. + void setLiveSubtitle(String? text) { + liveSubtitle.value = text; + } + + @override + void dispose() { + liveSubtitle.dispose(); + super.dispose(); + } } class ChewieControllerProvider extends InheritedWidget { diff --git a/lib/src/material/material_controls.dart b/lib/src/material/material_controls.dart index 3d43a1ab8..2fa770ba3 100644 --- a/lib/src/material/material_controls.dart +++ b/lib/src/material/material_controls.dart @@ -8,8 +8,10 @@ import 'package:chewie/src/helpers/utils.dart'; import 'package:chewie/src/material/material_progress_bar.dart'; import 'package:chewie/src/material/widgets/options_dialog.dart'; import 'package:chewie/src/material/widgets/playback_speed_dialog.dart'; +import 'package:chewie/src/material/widgets/subtitle_track_dialog.dart'; import 'package:chewie/src/models/option_item.dart'; import 'package:chewie/src/models/subtitle_model.dart'; +import 'package:chewie/src/models/subtitle_track.dart'; import 'package:chewie/src/notifiers/index.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -85,17 +87,13 @@ class _MaterialControlsState extends State Column( mainAxisAlignment: MainAxisAlignment.end, children: [ - if (_subtitleOn) - Transform.translate( - offset: Offset( - 0.0, - notifier.hideStuff ? barHeight * 0.8 : 0.0, - ), - child: _buildSubtitles( - context, - chewieController.subtitle!, - ), + Transform.translate( + offset: Offset( + 0.0, + notifier.hideStuff ? barHeight * 0.8 : 0.0, ), + child: _buildSubtitleLayer(context), + ), _buildBottomBar(context), ], ), @@ -164,6 +162,17 @@ class _MaterialControlsState extends State chewieController.optionsTranslation?.playbackSpeedButtonText ?? 'Playback speed', ), + if (chewieController.hasSubtitleTracks) + OptionItem( + onTap: (context) async { + Navigator.pop(context); + await _onSubtitleTrackButtonTap(); + }, + iconData: Icons.subtitles_outlined, + title: + chewieController.optionsTranslation?.subtitlesButtonText ?? + 'Subtitles', + ), ]; if (chewieController.additionalOptions != null && @@ -208,6 +217,27 @@ class _MaterialControlsState extends State ); } + /// Picks the right subtitle source: a streaming [ChewieController.liveSubtitle] + /// (for sources like HLS that emit cues over time) when present, otherwise + /// the static [ChewieController.subtitle] cue list. + Widget _buildSubtitleLayer(BuildContext context) { + if (!_subtitleOn) return const SizedBox(); + + final usesLiveCues = + chewieController.hasSubtitleTracks || chewieController.subtitle == null; + if (usesLiveCues) { + return ValueListenableBuilder( + valueListenable: chewieController.liveSubtitle, + builder: (context, text, _) { + if (text == null || text.isEmpty) return const SizedBox(); + return _subtitleBox(context, text); + }, + ); + } + + return _buildSubtitles(context, chewieController.subtitle!); + } + Widget _buildSubtitles(BuildContext context, Subtitles subtitles) { if (!_subtitleOn) { return const SizedBox(); @@ -217,11 +247,12 @@ class _MaterialControlsState extends State return const SizedBox(); } + return _subtitleBox(context, currentSubtitle.first!.text); + } + + Widget _subtitleBox(BuildContext context, dynamic text) { if (chewieController.subtitleBuilder != null) { - return chewieController.subtitleBuilder!( - context, - currentSubtitle.first!.text, - ); + return chewieController.subtitleBuilder!(context, text); } return Padding( @@ -233,7 +264,7 @@ class _MaterialControlsState extends State borderRadius: BorderRadius.circular(10.0), ), child: Text( - currentSubtitle.first!.text.toString(), + text.toString(), style: const TextStyle(fontSize: 18), textAlign: TextAlign.center, ), @@ -467,8 +498,10 @@ class _MaterialControlsState extends State } Widget _buildSubtitleToggle() { - //if don't have subtitle hiden button - if (chewieController.subtitle?.isEmpty ?? true) { + // Hide the button when there's nothing to toggle: neither a static cue + // list nor selectable tracks. + final hasStaticSubtitle = chewieController.subtitle?.isNotEmpty ?? false; + if (!hasStaticSubtitle && !chewieController.hasSubtitleTracks) { return const SizedBox(); } return GestureDetector( @@ -488,11 +521,61 @@ class _MaterialControlsState extends State } void _onSubtitleTap() { + final controller = chewieController; + // With selectable tracks, toggling also drives the track selection so the + // host can start/stop producing cues. + if (controller.hasSubtitleTracks) { + if (_subtitleOn) { + controller.selectSubtitleTrack(null); + setState(() => _subtitleOn = false); + } else { + controller.selectSubtitleTrack(_activeTrackOrDefault()); + setState(() => _subtitleOn = true); + } + return; + } setState(() { _subtitleOn = !_subtitleOn; }); } + SubtitleTrack? _activeTrackOrDefault() { + final tracks = chewieController.subtitleTracks; + if (tracks.isEmpty) return null; + final activeId = chewieController.activeSubtitleTrackId; + for (final track in tracks) { + if (track.id == activeId) return track; + } + return tracks.first; + } + + Future _onSubtitleTrackButtonTap() async { + _hideTimer?.cancel(); + + final choice = await showModalBottomSheet( + context: context, + isScrollControlled: true, + useRootNavigator: chewieController.useRootNavigator, + builder: (context) => SubtitleTrackDialog( + tracks: chewieController.subtitleTracks, + selectedId: chewieController.activeSubtitleTrackId, + offLabel: + chewieController.optionsTranslation?.subtitlesButtonText != null + ? '${chewieController.optionsTranslation!.subtitlesButtonText} — off' + : 'Off', + ), + ); + + if (choice != null) { + chewieController.selectSubtitleTrack(choice.track); + setState(() => _subtitleOn = choice.track != null); + } + + if (_latestValue.isPlaying) { + _startHideTimer(); + } + } + void _cancelAndRestartTimer() { _hideTimer?.cancel(); _startHideTimer(); @@ -505,8 +588,9 @@ class _MaterialControlsState extends State Future _initialize() async { _subtitleOn = - chewieController.showSubtitles && - (chewieController.subtitle?.isNotEmpty ?? false); + (chewieController.showSubtitles && + (chewieController.subtitle?.isNotEmpty ?? false)) || + chewieController.activeSubtitleTrackId != null; controller.addListener(_updateState); _updateState(); diff --git a/lib/src/material/material_desktop_controls.dart b/lib/src/material/material_desktop_controls.dart index acdd4a8b0..565c2b640 100644 --- a/lib/src/material/material_desktop_controls.dart +++ b/lib/src/material/material_desktop_controls.dart @@ -8,8 +8,10 @@ import 'package:chewie/src/helpers/utils.dart'; import 'package:chewie/src/material/material_progress_bar.dart'; import 'package:chewie/src/material/widgets/options_dialog.dart'; import 'package:chewie/src/material/widgets/playback_speed_dialog.dart'; +import 'package:chewie/src/material/widgets/subtitle_track_dialog.dart'; import 'package:chewie/src/models/option_item.dart'; import 'package:chewie/src/models/subtitle_model.dart'; +import 'package:chewie/src/models/subtitle_track.dart'; import 'package:chewie/src/notifiers/index.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -112,17 +114,13 @@ class _MaterialDesktopControlsState extends State Column( mainAxisAlignment: MainAxisAlignment.end, children: [ - if (_subtitleOn) - Transform.translate( - offset: Offset( - 0.0, - notifier.hideStuff ? barHeight * 0.8 : 0.0, - ), - child: _buildSubtitles( - context, - chewieController.subtitle!, - ), + Transform.translate( + offset: Offset( + 0.0, + notifier.hideStuff ? barHeight * 0.8 : 0.0, ), + child: _buildSubtitleLayer(context), + ), _buildBottomBar(context), ], ), @@ -182,6 +180,17 @@ class _MaterialDesktopControlsState extends State chewieController.optionsTranslation?.playbackSpeedButtonText ?? 'Playback speed', ), + if (chewieController.hasSubtitleTracks) + OptionItem( + onTap: (context) async { + Navigator.pop(context); + await _onSubtitleTrackButtonTap(); + }, + iconData: Icons.subtitles_outlined, + title: + chewieController.optionsTranslation?.subtitlesButtonText ?? + 'Subtitles', + ), ]; if (chewieController.additionalOptions != null && @@ -221,6 +230,27 @@ class _MaterialDesktopControlsState extends State ); } + /// Picks the right subtitle source: a streaming [ChewieController.liveSubtitle] + /// (for sources like HLS that emit cues over time) when present, otherwise + /// the static [ChewieController.subtitle] cue list. + Widget _buildSubtitleLayer(BuildContext context) { + if (!_subtitleOn) return const SizedBox(); + + final usesLiveCues = + chewieController.hasSubtitleTracks || chewieController.subtitle == null; + if (usesLiveCues) { + return ValueListenableBuilder( + valueListenable: chewieController.liveSubtitle, + builder: (context, text, _) { + if (text == null || text.isEmpty) return const SizedBox(); + return _subtitleBox(context, text); + }, + ); + } + + return _buildSubtitles(context, chewieController.subtitle!); + } + Widget _buildSubtitles(BuildContext context, Subtitles subtitles) { if (!_subtitleOn) { return const SizedBox(); @@ -230,11 +260,12 @@ class _MaterialDesktopControlsState extends State return const SizedBox(); } + return _subtitleBox(context, currentSubtitle.first!.text); + } + + Widget _subtitleBox(BuildContext context, dynamic text) { if (chewieController.subtitleBuilder != null) { - return chewieController.subtitleBuilder!( - context, - currentSubtitle.first!.text, - ); + return chewieController.subtitleBuilder!(context, text); } return Padding( @@ -246,7 +277,7 @@ class _MaterialDesktopControlsState extends State borderRadius: BorderRadius.circular(10.0), ), child: Text( - currentSubtitle.first!.text.toString(), + text.toString(), style: const TextStyle(fontSize: 18), textAlign: TextAlign.center, ), @@ -284,8 +315,8 @@ class _MaterialDesktopControlsState extends State _buildPosition(iconColor), const Spacer(), if (chewieController.showControls && - chewieController.subtitle != null && - chewieController.subtitle!.isNotEmpty) + ((chewieController.subtitle?.isNotEmpty ?? false) || + chewieController.hasSubtitleTracks)) _buildSubtitleToggle(icon: Icons.subtitles), if (chewieController.showOptions) _buildOptionsButton(icon: Icons.settings), @@ -453,11 +484,57 @@ class _MaterialDesktopControlsState extends State } void _onSubtitleTap() { + final controller = chewieController; + // With selectable tracks, toggling also drives the track selection so the + // host can start/stop producing cues. + if (controller.hasSubtitleTracks) { + if (_subtitleOn) { + controller.selectSubtitleTrack(null); + setState(() => _subtitleOn = false); + } else { + controller.selectSubtitleTrack(_activeTrackOrDefault()); + setState(() => _subtitleOn = true); + } + return; + } setState(() { _subtitleOn = !_subtitleOn; }); } + SubtitleTrack? _activeTrackOrDefault() { + final tracks = chewieController.subtitleTracks; + if (tracks.isEmpty) return null; + final activeId = chewieController.activeSubtitleTrackId; + for (final track in tracks) { + if (track.id == activeId) return track; + } + return tracks.first; + } + + Future _onSubtitleTrackButtonTap() async { + _hideTimer?.cancel(); + + final choice = await showModalBottomSheet( + context: context, + isScrollControlled: true, + useRootNavigator: chewieController.useRootNavigator, + builder: (context) => SubtitleTrackDialog( + tracks: chewieController.subtitleTracks, + selectedId: chewieController.activeSubtitleTrackId, + ), + ); + + if (choice != null) { + chewieController.selectSubtitleTrack(choice.track); + setState(() => _subtitleOn = choice.track != null); + } + + if (_latestValue.isPlaying) { + _startHideTimer(); + } + } + void _cancelAndRestartTimer() { _hideTimer?.cancel(); _startHideTimer(); @@ -470,8 +547,9 @@ class _MaterialDesktopControlsState extends State Future _initialize() async { _subtitleOn = - chewieController.showSubtitles && - (chewieController.subtitle?.isNotEmpty ?? false); + (chewieController.showSubtitles && + (chewieController.subtitle?.isNotEmpty ?? false)) || + chewieController.activeSubtitleTrackId != null; controller.addListener(_updateState); _updateState(); diff --git a/lib/src/material/widgets/subtitle_track_dialog.dart b/lib/src/material/widgets/subtitle_track_dialog.dart new file mode 100644 index 000000000..ee5ec96f6 --- /dev/null +++ b/lib/src/material/widgets/subtitle_track_dialog.dart @@ -0,0 +1,83 @@ +import 'package:chewie/src/models/subtitle_track.dart'; +import 'package:flutter/material.dart'; + +/// The user's pick from a [SubtitleTrackDialog]. +/// +/// Wrapping the track lets callers tell "the user chose Off" ([track] is +/// `null`) apart from "the dialog was dismissed" (the dialog pops `null`). +class SubtitleTrackChoice { + const SubtitleTrackChoice(this.track); + + /// The selected track, or `null` when the user picked "Off". + final SubtitleTrack? track; +} + +/// Lets the user pick one of the available [SubtitleTrack]s, or turn subtitles +/// off. Pops with a [SubtitleTrackChoice], or `null` when dismissed. +class SubtitleTrackDialog extends StatelessWidget { + const SubtitleTrackDialog({ + super.key, + required List tracks, + required Object? selectedId, + this.offLabel = 'Off', + }) : _tracks = tracks, + _selectedId = selectedId; + + final List _tracks; + final Object? _selectedId; + final String offLabel; + + @override + Widget build(BuildContext context) { + final Color selectedColor = Theme.of(context).primaryColor; + + Widget tile({ + required bool selected, + required String label, + String? trailing, + required VoidCallback onTap, + }) { + return ListTile( + dense: true, + selected: selected, + onTap: onTap, + title: Row( + children: [ + if (selected) + Icon(Icons.check, size: 20.0, color: selectedColor) + else + const SizedBox(width: 20.0), + const SizedBox(width: 16.0), + Expanded(child: Text(label)), + if (trailing != null) + Text( + trailing, + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ); + } + + return ListView( + shrinkWrap: true, + physics: const ScrollPhysics(), + children: [ + tile( + selected: _selectedId == null, + label: offLabel, + onTap: () => + Navigator.of(context).pop(const SubtitleTrackChoice(null)), + ), + for (final track in _tracks) + tile( + selected: track.id == _selectedId, + label: track.label, + trailing: track.language, + onTap: () => + Navigator.of(context).pop(SubtitleTrackChoice(track)), + ), + ], + ); + } +} diff --git a/lib/src/models/index.dart b/lib/src/models/index.dart index a308c33db..b39451d4b 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_track.dart'; diff --git a/lib/src/models/subtitle_track.dart b/lib/src/models/subtitle_track.dart new file mode 100644 index 000000000..f0564157a --- /dev/null +++ b/lib/src/models/subtitle_track.dart @@ -0,0 +1,38 @@ +/// A selectable subtitle track surfaced in the player's options menu. +/// +/// This is intentionally decoupled from any particular source (HLS, embedded, +/// sidecar files...). The host supplies the tracks and reacts to the user's +/// selection through [ChewieController.onSubtitleTrackChanged]; how cues are +/// produced and fed back (e.g. via [ChewieController.setLiveSubtitle] for a +/// streaming source, or via [ChewieController.setSubtitle] for a parsed cue +/// list) is left to the host. +class SubtitleTrack { + const SubtitleTrack({ + required this.id, + required this.label, + this.language, + }); + + /// Opaque identifier the host uses to recognise the track on selection. + final Object id; + + /// Human-readable name shown in the menu. + final String label; + + /// Optional BCP-47 language tag, shown as a hint next to [label]. + final String? language; + + @override + bool operator ==(Object other) => + other is SubtitleTrack && + other.id == id && + other.label == label && + other.language == language; + + @override + int get hashCode => Object.hash(id, label, language); + + @override + String toString() => + 'SubtitleTrack(id: $id, label: $label, language: $language)'; +} From 2355d3aa6b17e89e4109f35ca426074607819a38 Mon Sep 17 00:00:00 2001 From: Ortes Date: Sun, 31 May 2026 20:25:37 -0500 Subject: [PATCH 2/4] style: apply dart format --- lib/src/chewie_player.dart | 3 ++- lib/src/material/widgets/subtitle_track_dialog.dart | 8 ++------ lib/src/models/subtitle_track.dart | 6 +----- 3 files changed, 5 insertions(+), 12 deletions(-) diff --git a/lib/src/chewie_player.dart b/lib/src/chewie_player.dart index 487ea1bf9..64cbbc59e 100644 --- a/lib/src/chewie_player.dart +++ b/lib/src/chewie_player.dart @@ -439,7 +439,8 @@ class ChewieController extends ChangeNotifier { subtitle: subtitle ?? this.subtitle, subtitleBuilder: subtitleBuilder ?? this.subtitleBuilder, subtitleTracks: subtitleTracks ?? this.subtitleTracks, - activeSubtitleTrackId: activeSubtitleTrackId ?? this.activeSubtitleTrackId, + activeSubtitleTrackId: + activeSubtitleTrackId ?? this.activeSubtitleTrackId, onSubtitleTrackChanged: onSubtitleTrackChanged ?? this.onSubtitleTrackChanged, customControls: customControls ?? this.customControls, diff --git a/lib/src/material/widgets/subtitle_track_dialog.dart b/lib/src/material/widgets/subtitle_track_dialog.dart index ee5ec96f6..675394d6a 100644 --- a/lib/src/material/widgets/subtitle_track_dialog.dart +++ b/lib/src/material/widgets/subtitle_track_dialog.dart @@ -50,10 +50,7 @@ class SubtitleTrackDialog extends StatelessWidget { const SizedBox(width: 16.0), Expanded(child: Text(label)), if (trailing != null) - Text( - trailing, - style: Theme.of(context).textTheme.bodySmall, - ), + Text(trailing, style: Theme.of(context).textTheme.bodySmall), ], ), ); @@ -74,8 +71,7 @@ class SubtitleTrackDialog extends StatelessWidget { selected: track.id == _selectedId, label: track.label, trailing: track.language, - onTap: () => - Navigator.of(context).pop(SubtitleTrackChoice(track)), + onTap: () => Navigator.of(context).pop(SubtitleTrackChoice(track)), ), ], ); diff --git a/lib/src/models/subtitle_track.dart b/lib/src/models/subtitle_track.dart index f0564157a..5dfb3f062 100644 --- a/lib/src/models/subtitle_track.dart +++ b/lib/src/models/subtitle_track.dart @@ -7,11 +7,7 @@ /// streaming source, or via [ChewieController.setSubtitle] for a parsed cue /// list) is left to the host. class SubtitleTrack { - const SubtitleTrack({ - required this.id, - required this.label, - this.language, - }); + const SubtitleTrack({required this.id, required this.label, this.language}); /// Opaque identifier the host uses to recognise the track on selection. final Object id; From ca11bd2551c99df3735b98cc0d0fc41466138cf7 Mon Sep 17 00:00:00 2001 From: Ortes Date: Mon, 1 Jun 2026 10:48:25 -0500 Subject: [PATCH 3/4] test: cover multi-track subtitle selection Adds unit and widget tests for the new subtitle-track API: - SubtitleTrack model (equality, hashCode, toString) - SubtitleTrackDialog (rendering, selection, custom off label, dismissal) - ChewieController track methods (setSubtitleTracks, selectSubtitleTrack, setLiveSubtitle, copyWith, dispose, hasSubtitleTracks) - Material and desktop controls: toggle-driven selection, options-menu track picker, live-cue and static-cue rendering, custom subtitleBuilder Brings patch coverage to ~99%. --- test/subtitle_track_dialog_test.dart | 145 +++++++++++++ test/subtitle_track_test.dart | 58 +++++ test/subtitle_tracks_test.dart | 309 +++++++++++++++++++++++++++ 3 files changed, 512 insertions(+) create mode 100644 test/subtitle_track_dialog_test.dart create mode 100644 test/subtitle_track_test.dart create mode 100644 test/subtitle_tracks_test.dart diff --git a/test/subtitle_track_dialog_test.dart b/test/subtitle_track_dialog_test.dart new file mode 100644 index 000000000..bd1d0e3d4 --- /dev/null +++ b/test/subtitle_track_dialog_test.dart @@ -0,0 +1,145 @@ +import 'dart:async'; + +import 'package:chewie/src/material/widgets/subtitle_track_dialog.dart'; +import 'package:chewie/src/models/subtitle_track.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +const _tracks = [ + SubtitleTrack(id: 'en', label: 'English', language: 'en'), + SubtitleTrack(id: 'fr', label: 'French', language: 'fr'), +]; + +Future _openDialog( + WidgetTester tester, { + Object? selectedId, + String offLabel = 'Off', +}) async { + SubtitleTrackChoice? result; + late BuildContext sheetContext; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) { + sheetContext = context; + return const SizedBox(); + }, + ), + ), + ), + ); + + unawaited( + showModalBottomSheet( + context: sheetContext, + builder: (context) => SubtitleTrackDialog( + tracks: _tracks, + selectedId: selectedId, + offLabel: offLabel, + ), + ).then((value) => result = value), + ); + + await tester.pumpAndSettle(); + return result; +} + +void main() { + testWidgets('renders the Off entry plus one tile per track', (tester) async { + await _openDialog(tester, selectedId: 'en'); + + expect(find.text('Off'), findsOneWidget); + expect(find.text('English'), findsOneWidget); + expect(find.text('French'), findsOneWidget); + // The language hint is shown as trailing text. + expect(find.text('en'), findsOneWidget); + expect(find.text('fr'), findsOneWidget); + // The selected track shows a check icon. + expect(find.byIcon(Icons.check), findsOneWidget); + }); + + testWidgets('honours a custom off label', (tester) async { + await _openDialog(tester, selectedId: null, offLabel: 'Subtitles — off'); + expect(find.text('Subtitles — off'), findsOneWidget); + // With nothing selected, the Off entry carries the check. + expect(find.byIcon(Icons.check), findsOneWidget); + }); + + testWidgets('tapping a track pops a choice carrying that track', ( + tester, + ) async { + SubtitleTrackChoice? choice; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) => Center( + child: ElevatedButton( + onPressed: () async { + choice = await showModalBottomSheet( + context: context, + builder: (context) => const SubtitleTrackDialog( + tracks: _tracks, + selectedId: 'en', + ), + ); + }, + child: const Text('open'), + ), + ), + ), + ), + ), + ); + + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('French')); + await tester.pumpAndSettle(); + + expect(choice, isNotNull); + expect(choice!.track, isNotNull); + expect(choice!.track!.id, 'fr'); + }); + + testWidgets('tapping Off pops a choice with a null track', (tester) async { + SubtitleTrackChoice? choice; + var popped = false; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) => Center( + child: ElevatedButton( + onPressed: () async { + choice = await showModalBottomSheet( + context: context, + builder: (context) => const SubtitleTrackDialog( + tracks: _tracks, + selectedId: 'en', + ), + ); + popped = true; + }, + child: const Text('open'), + ), + ), + ), + ), + ), + ); + + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Off')); + await tester.pumpAndSettle(); + + expect(popped, isTrue); + expect(choice, isNotNull); + expect(choice!.track, isNull); + }); +} diff --git a/test/subtitle_track_test.dart b/test/subtitle_track_test.dart new file mode 100644 index 000000000..911d4c109 --- /dev/null +++ b/test/subtitle_track_test.dart @@ -0,0 +1,58 @@ +import 'package:chewie/src/models/subtitle_track.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('SubtitleTrack', () { + test('exposes the values it was constructed with', () { + const track = SubtitleTrack(id: 'en', label: 'English', language: 'en'); + expect(track.id, 'en'); + expect(track.label, 'English'); + expect(track.language, 'en'); + }); + + test('language is optional', () { + const track = SubtitleTrack(id: 1, label: 'Track 1'); + expect(track.language, isNull); + }); + + test('value equality compares id, label and language', () { + const a = SubtitleTrack(id: 'en', label: 'English', language: 'en'); + const b = SubtitleTrack(id: 'en', label: 'English', language: 'en'); + const differentId = SubtitleTrack( + id: 'fr', + label: 'English', + language: 'en', + ); + const differentLabel = SubtitleTrack( + id: 'en', + label: 'Anglais', + language: 'en', + ); + const differentLanguage = SubtitleTrack( + id: 'en', + label: 'English', + language: 'gb', + ); + + expect(a, equals(b)); + expect(a == differentId, isFalse); + expect(a == differentLabel, isFalse); + expect(a == differentLanguage, isFalse); + expect(a == Object(), isFalse); + }); + + test('hashCode is consistent with equality', () { + const a = SubtitleTrack(id: 'en', label: 'English', language: 'en'); + const b = SubtitleTrack(id: 'en', label: 'English', language: 'en'); + expect(a.hashCode, equals(b.hashCode)); + }); + + test('toString includes the fields', () { + const track = SubtitleTrack(id: 'en', label: 'English', language: 'en'); + expect( + track.toString(), + 'SubtitleTrack(id: en, label: English, language: en)', + ); + }); + }); +} diff --git a/test/subtitle_tracks_test.dart b/test/subtitle_tracks_test.dart new file mode 100644 index 000000000..0723edf1c --- /dev/null +++ b/test/subtitle_tracks_test.dart @@ -0,0 +1,309 @@ +import 'package:chewie/chewie.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:video_player/video_player.dart'; + +final Uri _src = Uri.parse( + 'https://assets.mixkit.co/videos/preview/mixkit-spinning-around-the-earth-29351-large.mp4', +); + +const _tracks = [ + SubtitleTrack(id: 'en', label: 'English', language: 'en'), + SubtitleTrack(id: 'fr', label: 'French', language: 'fr'), +]; + +ChewieController _controller({ + List tracks = const [], + Object? activeSubtitleTrackId, + void Function(SubtitleTrack?)? onSubtitleTrackChanged, + Widget Function(BuildContext, dynamic)? subtitleBuilder, + Subtitles? subtitle, + bool showSubtitles = false, + OptionsTranslation? optionsTranslation, + Widget? customControls, +}) { + return ChewieController( + videoPlayerController: VideoPlayerController.networkUrl(_src), + autoPlay: false, + looping: false, + subtitleTracks: tracks, + activeSubtitleTrackId: activeSubtitleTrackId, + onSubtitleTrackChanged: onSubtitleTrackChanged, + subtitleBuilder: subtitleBuilder, + subtitle: subtitle, + showSubtitles: showSubtitles, + optionsTranslation: optionsTranslation, + customControls: customControls, + ); +} + +void main() { + group('ChewieController subtitle tracks', () { + test('hasSubtitleTracks reflects the track list', () { + final controller = _controller(); + expect(controller.hasSubtitleTracks, isFalse); + + var notified = 0; + controller.addListener(() => notified++); + controller.setSubtitleTracks(_tracks); + + expect(controller.hasSubtitleTracks, isTrue); + expect(controller.subtitleTracks, _tracks); + expect(notified, 1); + }); + + test('selectSubtitleTrack sets the active id and notifies the host', () { + SubtitleTrack? received; + var called = 0; + final controller = _controller( + tracks: _tracks, + onSubtitleTrackChanged: (track) { + received = track; + called++; + }, + ); + + controller.selectSubtitleTrack(_tracks[1]); + expect(controller.activeSubtitleTrackId, 'fr'); + expect(called, 1); + expect(received, _tracks[1]); + }); + + test('selecting the null track clears the live cue', () { + final controller = _controller(tracks: _tracks); + controller.setLiveSubtitle('a cue on screen'); + expect(controller.liveSubtitle.value, 'a cue on screen'); + + controller.selectSubtitleTrack(null); + expect(controller.activeSubtitleTrackId, isNull); + expect(controller.liveSubtitle.value, isNull); + }); + + test('setLiveSubtitle pushes cue text through the notifier', () { + final controller = _controller(tracks: _tracks); + controller.setLiveSubtitle('hello'); + expect(controller.liveSubtitle.value, 'hello'); + controller.setLiveSubtitle(null); + expect(controller.liveSubtitle.value, isNull); + }); + + test('copyWith carries the subtitle-track fields', () { + onChanged(SubtitleTrack? _) {} + final controller = _controller(); + final copy = controller.copyWith( + subtitleTracks: _tracks, + activeSubtitleTrackId: 'fr', + onSubtitleTrackChanged: onChanged, + ); + + expect(copy.subtitleTracks, _tracks); + expect(copy.activeSubtitleTrackId, 'fr'); + expect(copy.onSubtitleTrackChanged, same(onChanged)); + }); + + test('copyWith without subtitle args preserves the originals', () { + onChanged(SubtitleTrack? _) {} + final controller = _controller( + tracks: _tracks, + activeSubtitleTrackId: 'en', + onSubtitleTrackChanged: onChanged, + ); + final copy = controller.copyWith(); + + expect(copy.subtitleTracks, _tracks); + expect(copy.activeSubtitleTrackId, 'en'); + expect(copy.onSubtitleTrackChanged, same(onChanged)); + }); + + test('dispose releases the live-subtitle notifier', () { + final controller = _controller(tracks: _tracks); + controller.dispose(); + // Touching a disposed ValueNotifier throws in debug builds. + expect( + () => controller.liveSubtitle.addListener(() {}), + throwsFlutterError, + ); + }); + }); + + // Reveal the controls overlay: showControlsOnInitialize flips hideStuff to + // false after ~200ms, which lifts the AbsorbPointer so taps land. + Future reveal(WidgetTester tester) async { + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + } + + for (final variant in ['material', 'desktop']) { + final isMaterial = variant == 'material'; + Widget controls() => + isMaterial ? const MaterialControls() : const MaterialDesktopControls(); + // Material swaps the toggle icon with state; desktop keeps a fixed icon. + final toggleOnIcon = isMaterial ? Icons.closed_caption : Icons.subtitles; + final toggleOffIcon = isMaterial + ? Icons.closed_caption_off_outlined + : Icons.subtitles; + final optionsIcon = isMaterial ? Icons.more_vert : Icons.settings; + + group('$variant controls subtitle UI', () { + testWidgets('shows the toggle and live cue when a track is active', ( + tester, + ) async { + final controller = _controller( + tracks: _tracks, + activeSubtitleTrackId: 'en', + customControls: controls(), + ); + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: Chewie(controller: controller)), + ), + ); + await reveal(tester); + + // Active subtitles => the toggle is shown in its "on" state. + expect(find.byIcon(toggleOnIcon), findsOneWidget); + + controller.setLiveSubtitle('Live cue text'); + await tester.pump(); + expect(find.text('Live cue text'), findsOneWidget); + + // Empty cue text renders nothing. + controller.setLiveSubtitle(''); + await tester.pump(); + expect(find.text(''), findsNothing); + }); + + testWidgets('tapping the toggle drives track selection', (tester) async { + final selections = []; + final controller = _controller( + tracks: _tracks, + activeSubtitleTrackId: 'en', + onSubtitleTrackChanged: selections.add, + customControls: controls(), + ); + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: Chewie(controller: controller)), + ), + ); + await reveal(tester); + + // Starts on -> tapping turns it off (null track). + await tester.tap(find.byIcon(toggleOnIcon)); + await tester.pump(); + expect(selections.last, isNull); + expect(controller.activeSubtitleTrackId, isNull); + expect(find.byIcon(toggleOffIcon), findsOneWidget); + + // Tapping again turns it back on, defaulting to the active track. + await tester.tap(find.byIcon(toggleOffIcon)); + await tester.pump(); + expect(selections.last, _tracks[0]); + }); + + testWidgets('the options menu opens the track picker', (tester) async { + final selections = []; + final controller = _controller( + tracks: _tracks, + onSubtitleTrackChanged: selections.add, + customControls: controls(), + ); + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: Chewie(controller: controller)), + ), + ); + await reveal(tester); + + await tester.tap(find.byIcon(optionsIcon)); + await tester.pumpAndSettle(); + expect(find.text('Subtitles'), findsOneWidget); + + await tester.tap(find.text('Subtitles')); + await tester.pumpAndSettle(); + + // Track picker is open: Off + the two tracks. + expect(find.text('Off'), findsOneWidget); + await tester.tap(find.text('French')); + await tester.pumpAndSettle(); + + expect(selections.last, _tracks[1]); + expect(controller.activeSubtitleTrackId, 'fr'); + }); + + testWidgets('the picker uses the translated off label', (tester) async { + final controller = _controller( + tracks: _tracks, + optionsTranslation: OptionsTranslation( + subtitlesButtonText: 'Captions', + ), + customControls: controls(), + ); + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: Chewie(controller: controller)), + ), + ); + await reveal(tester); + + await tester.tap(find.byIcon(optionsIcon)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Captions')); + await tester.pumpAndSettle(); + + // Material threads the translated label into the picker's "off" entry; + // the desktop variant always uses the default "Off". + expect( + find.text(isMaterial ? 'Captions — off' : 'Off'), + findsOneWidget, + ); + }); + + testWidgets('renders a static subtitle cue when no tracks exist', ( + tester, + ) async { + final controller = _controller( + showSubtitles: true, + subtitle: Subtitles([ + Subtitle( + index: 0, + start: Duration.zero, + end: const Duration(hours: 1), + text: 'Static cue', + ), + ]), + customControls: controls(), + ); + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: Chewie(controller: controller)), + ), + ); + await reveal(tester); + + expect(find.text('Static cue'), findsOneWidget); + }); + + testWidgets('a custom subtitleBuilder renders the live cue', ( + tester, + ) async { + final controller = _controller( + tracks: _tracks, + activeSubtitleTrackId: 'en', + subtitleBuilder: (context, text) => Text('built:$text'), + customControls: controls(), + ); + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: Chewie(controller: controller)), + ), + ); + await reveal(tester); + + controller.setLiveSubtitle('cue'); + await tester.pump(); + expect(find.text('built:cue'), findsOneWidget); + }); + }); + } +} From 2ad5fc76e9b3a83e92b0d4f87b6d0ef20271d4b2 Mon Sep 17 00:00:00 2001 From: Ortes Date: Mon, 1 Jun 2026 10:55:00 -0500 Subject: [PATCH 4/4] fix: thread translated off-label into desktop subtitle picker The Material controls passed the optionsTranslation subtitle label into the SubtitleTrackDialog's "off" entry, but the desktop controls always showed the hardcoded "Off". Mirror the Material behavior so both variants honor optionsTranslation.subtitlesButtonText. --- lib/src/material/material_desktop_controls.dart | 4 ++++ test/subtitle_tracks_test.dart | 9 +++------ 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/lib/src/material/material_desktop_controls.dart b/lib/src/material/material_desktop_controls.dart index 565c2b640..857433e69 100644 --- a/lib/src/material/material_desktop_controls.dart +++ b/lib/src/material/material_desktop_controls.dart @@ -522,6 +522,10 @@ class _MaterialDesktopControlsState extends State builder: (context) => SubtitleTrackDialog( tracks: chewieController.subtitleTracks, selectedId: chewieController.activeSubtitleTrackId, + offLabel: + chewieController.optionsTranslation?.subtitlesButtonText != null + ? '${chewieController.optionsTranslation!.subtitlesButtonText} — off' + : 'Off', ), ); diff --git a/test/subtitle_tracks_test.dart b/test/subtitle_tracks_test.dart index 0723edf1c..b86258aec 100644 --- a/test/subtitle_tracks_test.dart +++ b/test/subtitle_tracks_test.dart @@ -251,12 +251,9 @@ void main() { await tester.tap(find.text('Captions')); await tester.pumpAndSettle(); - // Material threads the translated label into the picker's "off" entry; - // the desktop variant always uses the default "Off". - expect( - find.text(isMaterial ? 'Captions — off' : 'Off'), - findsOneWidget, - ); + // Both variants thread the translated label into the picker's "off" + // entry. + expect(find.text('Captions — off'), findsOneWidget); }); testWidgets('renders a static subtitle cue when no tracks exist', (