diff --git a/lib/src/chewie_player.dart b/lib/src/chewie_player.dart index 7ffa295b2..64cbbc59e 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,11 @@ 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 +514,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 +752,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..857433e69 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,61 @@ 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, + 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(); @@ -470,8 +551,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..675394d6a --- /dev/null +++ b/lib/src/material/widgets/subtitle_track_dialog.dart @@ -0,0 +1,79 @@ +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..5dfb3f062 --- /dev/null +++ b/lib/src/models/subtitle_track.dart @@ -0,0 +1,34 @@ +/// 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)'; +} 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..b86258aec --- /dev/null +++ b/test/subtitle_tracks_test.dart @@ -0,0 +1,306 @@ +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(); + + // 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', ( + 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); + }); + }); + } +}