From 7f93b215d0fa210570e49e6a3606fab1aa678af1 Mon Sep 17 00:00:00 2001 From: Ortes Date: Fri, 29 May 2026 17:57:59 -0500 Subject: [PATCH 1/3] Add multi-track audio selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror the subtitle-track support for audio: the controller now holds a source-agnostic list of selectable AudioTracks, an active id and an onAudioTrackChanged callback. Unlike subtitles, audio is never "off" — one track is always active, and the menu entry only shows when more than one track exists. - ChewieController: audioTracks / activeAudioTrackId / onAudioTrackChanged / hasAudioTracks, setAudioTracks, selectAudioTrack; copyWith wired. - Material + Material desktop controls: an Audio entry in the options menu opening a track picker. - AudioTrackDialog widget + AudioTrack model. Co-Authored-By: Claude Opus 4.7 --- lib/src/chewie_player.dart | 45 +++++++++++++++++ lib/src/material/material_controls.dart | 33 ++++++++++++ .../material/material_desktop_controls.dart | 33 ++++++++++++ .../material/widgets/audio_track_dialog.dart | 50 +++++++++++++++++++ lib/src/models/audio_track.dart | 36 +++++++++++++ lib/src/models/index.dart | 1 + 6 files changed, 198 insertions(+) create mode 100644 lib/src/material/widgets/audio_track_dialog.dart create mode 100644 lib/src/models/audio_track.dart diff --git a/lib/src/chewie_player.dart b/lib/src/chewie_player.dart index 7ffa295b2..d8eb902dc 100644 --- a/lib/src/chewie_player.dart +++ b/lib/src/chewie_player.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:chewie/src/chewie_progress_colors.dart'; +import 'package:chewie/src/models/audio_track.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'; @@ -316,6 +317,9 @@ class ChewieController extends ChangeNotifier { this.subtitle, this.showSubtitles = false, this.subtitleBuilder, + this.audioTracks = const [], + this.activeAudioTrackId, + this.onAudioTrackChanged, this.customControls, this.errorBuilder, this.bufferingBuilder, @@ -369,6 +373,9 @@ class ChewieController extends ChangeNotifier { Subtitles? subtitle, bool? showSubtitles, Widget Function(BuildContext, dynamic)? subtitleBuilder, + List? audioTracks, + Object? activeAudioTrackId, + void Function(AudioTrack track)? onAudioTrackChanged, Widget? customControls, WidgetBuilder? bufferingBuilder, Widget Function(BuildContext, String)? errorBuilder, @@ -431,6 +438,9 @@ class ChewieController extends ChangeNotifier { showSubtitles: showSubtitles ?? this.showSubtitles, subtitle: subtitle ?? this.subtitle, subtitleBuilder: subtitleBuilder ?? this.subtitleBuilder, + audioTracks: audioTracks ?? this.audioTracks, + activeAudioTrackId: activeAudioTrackId ?? this.activeAudioTrackId, + onAudioTrackChanged: onAudioTrackChanged ?? this.onAudioTrackChanged, customControls: customControls ?? this.customControls, errorBuilder: errorBuilder ?? this.errorBuilder, bufferingBuilder: bufferingBuilder ?? this.bufferingBuilder, @@ -502,6 +512,24 @@ class ChewieController extends ChangeNotifier { /// begins playing. If set to `false`, subtitles will be hidden by default. bool showSubtitles; + /// Selectable audio tracks shown in the options menu. + /// + /// Source-agnostic: the host populates this (e.g. from an HLS manifest) and + /// reacts to selection via [onAudioTrackChanged]. May change after the video + /// loads — use [setAudioTracks] so the controls rebuild. Unlike subtitles, + /// audio is never "off": one track is always active. + List audioTracks; + + /// Id of the currently selected track in [audioTracks]. + Object? activeAudioTrackId; + + /// Called when the user picks an audio track from the menu. + final void Function(AudioTrack track)? onAudioTrackChanged; + + /// Whether more than one selectable audio track is available (a single track + /// offers nothing to choose, so the menu entry stays hidden). + bool get hasAudioTracks => audioTracks.length > 1; + /// The controller for the video you want to play final VideoPlayerController videoPlayerController; @@ -718,6 +746,23 @@ class ChewieController extends ChangeNotifier { void setSubtitle(List newSubtitle) { subtitle = Subtitles(newSubtitle); } + + /// Replaces the selectable [audioTracks] and rebuilds the controls. + /// + /// Use when tracks become known only after the media loads (e.g. once an + /// HLS manifest is parsed). + void setAudioTracks(List tracks) { + audioTracks = tracks; + notifyListeners(); + } + + /// Selects [track] as the active audio rendition, updating + /// [activeAudioTrackId] and notifying [onAudioTrackChanged]. + void selectAudioTrack(AudioTrack track) { + activeAudioTrackId = track.id; + onAudioTrackChanged?.call(track); + notifyListeners(); + } } class ChewieControllerProvider extends InheritedWidget { diff --git a/lib/src/material/material_controls.dart b/lib/src/material/material_controls.dart index 3d43a1ab8..85c062985 100644 --- a/lib/src/material/material_controls.dart +++ b/lib/src/material/material_controls.dart @@ -6,8 +6,10 @@ import 'package:chewie/src/chewie_player.dart'; import 'package:chewie/src/chewie_progress_colors.dart'; import 'package:chewie/src/helpers/utils.dart'; import 'package:chewie/src/material/material_progress_bar.dart'; +import 'package:chewie/src/material/widgets/audio_track_dialog.dart'; import 'package:chewie/src/material/widgets/options_dialog.dart'; import 'package:chewie/src/material/widgets/playback_speed_dialog.dart'; +import 'package:chewie/src/models/audio_track.dart'; import 'package:chewie/src/models/option_item.dart'; import 'package:chewie/src/models/subtitle_model.dart'; import 'package:chewie/src/notifiers/index.dart'; @@ -164,6 +166,15 @@ class _MaterialControlsState extends State chewieController.optionsTranslation?.playbackSpeedButtonText ?? 'Playback speed', ), + if (chewieController.hasAudioTracks) + OptionItem( + onTap: (context) async { + Navigator.pop(context); + await _onAudioTrackButtonTap(); + }, + iconData: Icons.audiotrack_outlined, + title: 'Audio', + ), ]; if (chewieController.additionalOptions != null && @@ -493,6 +504,28 @@ class _MaterialControlsState extends State }); } + Future _onAudioTrackButtonTap() async { + _hideTimer?.cancel(); + + final track = await showModalBottomSheet( + context: context, + isScrollControlled: true, + useRootNavigator: chewieController.useRootNavigator, + builder: (context) => AudioTrackDialog( + tracks: chewieController.audioTracks, + selectedId: chewieController.activeAudioTrackId, + ), + ); + + if (track != null) { + chewieController.selectAudioTrack(track); + } + + if (_latestValue.isPlaying) { + _startHideTimer(); + } + } + void _cancelAndRestartTimer() { _hideTimer?.cancel(); _startHideTimer(); diff --git a/lib/src/material/material_desktop_controls.dart b/lib/src/material/material_desktop_controls.dart index acdd4a8b0..0e98b5b20 100644 --- a/lib/src/material/material_desktop_controls.dart +++ b/lib/src/material/material_desktop_controls.dart @@ -6,8 +6,10 @@ import 'package:chewie/src/chewie_player.dart'; import 'package:chewie/src/chewie_progress_colors.dart'; import 'package:chewie/src/helpers/utils.dart'; import 'package:chewie/src/material/material_progress_bar.dart'; +import 'package:chewie/src/material/widgets/audio_track_dialog.dart'; import 'package:chewie/src/material/widgets/options_dialog.dart'; import 'package:chewie/src/material/widgets/playback_speed_dialog.dart'; +import 'package:chewie/src/models/audio_track.dart'; import 'package:chewie/src/models/option_item.dart'; import 'package:chewie/src/models/subtitle_model.dart'; import 'package:chewie/src/notifiers/index.dart'; @@ -182,6 +184,15 @@ class _MaterialDesktopControlsState extends State chewieController.optionsTranslation?.playbackSpeedButtonText ?? 'Playback speed', ), + if (chewieController.hasAudioTracks) + OptionItem( + onTap: (context) async { + Navigator.pop(context); + await _onAudioTrackButtonTap(); + }, + iconData: Icons.audiotrack_outlined, + title: 'Audio', + ), ]; if (chewieController.additionalOptions != null && @@ -458,6 +469,28 @@ class _MaterialDesktopControlsState extends State }); } + Future _onAudioTrackButtonTap() async { + _hideTimer?.cancel(); + + final track = await showModalBottomSheet( + context: context, + isScrollControlled: true, + useRootNavigator: chewieController.useRootNavigator, + builder: (context) => AudioTrackDialog( + tracks: chewieController.audioTracks, + selectedId: chewieController.activeAudioTrackId, + ), + ); + + if (track != null) { + chewieController.selectAudioTrack(track); + } + + if (_latestValue.isPlaying) { + _startHideTimer(); + } + } + void _cancelAndRestartTimer() { _hideTimer?.cancel(); _startHideTimer(); diff --git a/lib/src/material/widgets/audio_track_dialog.dart b/lib/src/material/widgets/audio_track_dialog.dart new file mode 100644 index 000000000..1b1012df1 --- /dev/null +++ b/lib/src/material/widgets/audio_track_dialog.dart @@ -0,0 +1,50 @@ +import 'package:chewie/src/models/audio_track.dart'; +import 'package:flutter/material.dart'; + +/// Lets the user pick one of the available [AudioTrack]s. Pops with the chosen +/// [AudioTrack], or `null` when dismissed. Unlike subtitles there is no "off" +/// option — one audio track is always active. +class AudioTrackDialog extends StatelessWidget { + const AudioTrackDialog({ + super.key, + required List tracks, + required Object? selectedId, + }) : _tracks = tracks, + _selectedId = selectedId; + + final List _tracks; + final Object? _selectedId; + + @override + Widget build(BuildContext context) { + final Color selectedColor = Theme.of(context).primaryColor; + + return ListView( + shrinkWrap: true, + physics: const ScrollPhysics(), + children: [ + for (final track in _tracks) + ListTile( + dense: true, + selected: track.id == _selectedId, + onTap: () => Navigator.of(context).pop(track), + title: Row( + children: [ + if (track.id == _selectedId) + Icon(Icons.check, size: 20.0, color: selectedColor) + else + const SizedBox(width: 20.0), + const SizedBox(width: 16.0), + Expanded(child: Text(track.label)), + if (track.language != null) + Text( + track.language!, + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + ], + ); + } +} diff --git a/lib/src/models/audio_track.dart b/lib/src/models/audio_track.dart new file mode 100644 index 000000000..a995a8f06 --- /dev/null +++ b/lib/src/models/audio_track.dart @@ -0,0 +1,36 @@ +/// A selectable audio track surfaced in the player's options menu. +/// +/// This is intentionally decoupled from any particular source (HLS, embedded +/// renditions...). The host supplies the tracks and reacts to the user's +/// selection through [ChewieController.onAudioTrackChanged]; switching the +/// playing rendition is left to the host. Unlike subtitles, audio is never +/// "off": one track is always active. +class AudioTrack { + const AudioTrack({ + 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 AudioTrack && + other.id == id && + other.label == label && + other.language == language; + + @override + int get hashCode => Object.hash(id, label, language); + + @override + String toString() => 'AudioTrack(id: $id, label: $label, language: $language)'; +} diff --git a/lib/src/models/index.dart b/lib/src/models/index.dart index a308c33db..081626767 100644 --- a/lib/src/models/index.dart +++ b/lib/src/models/index.dart @@ -1,3 +1,4 @@ +export 'audio_track.dart'; export 'option_item.dart'; export 'options_translation.dart'; export 'subtitle_model.dart'; From edc56b1cfef1ea60642d98cdd3f1f898e8ddc807 Mon Sep 17 00:00:00 2001 From: Ortes Date: Sun, 31 May 2026 20:24:23 -0500 Subject: [PATCH 2/3] style: apply dart format --- lib/src/material/widgets/audio_track_dialog.dart | 4 ++-- lib/src/models/audio_track.dart | 9 +++------ 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/lib/src/material/widgets/audio_track_dialog.dart b/lib/src/material/widgets/audio_track_dialog.dart index 1b1012df1..df4739a10 100644 --- a/lib/src/material/widgets/audio_track_dialog.dart +++ b/lib/src/material/widgets/audio_track_dialog.dart @@ -9,8 +9,8 @@ class AudioTrackDialog extends StatelessWidget { super.key, required List tracks, required Object? selectedId, - }) : _tracks = tracks, - _selectedId = selectedId; + }) : _tracks = tracks, + _selectedId = selectedId; final List _tracks; final Object? _selectedId; diff --git a/lib/src/models/audio_track.dart b/lib/src/models/audio_track.dart index a995a8f06..01a888bd1 100644 --- a/lib/src/models/audio_track.dart +++ b/lib/src/models/audio_track.dart @@ -6,11 +6,7 @@ /// playing rendition is left to the host. Unlike subtitles, audio is never /// "off": one track is always active. class AudioTrack { - const AudioTrack({ - required this.id, - required this.label, - this.language, - }); + const AudioTrack({required this.id, required this.label, this.language}); /// Opaque identifier the host uses to recognise the track on selection. final Object id; @@ -32,5 +28,6 @@ class AudioTrack { int get hashCode => Object.hash(id, label, language); @override - String toString() => 'AudioTrack(id: $id, label: $label, language: $language)'; + String toString() => + 'AudioTrack(id: $id, label: $label, language: $language)'; } From 98901d706129eb6ff0af05396517d9add7e74a74 Mon Sep 17 00:00:00 2001 From: Ortes Date: Mon, 1 Jun 2026 04:42:25 -0500 Subject: [PATCH 3/3] test: cover multi-track audio selection Add tests for the new audio-track feature so the patch is exercised and codecov/patch stops reporting 0% on the changed lines: - AudioTrack model: equality, hashCode, toString, optional language - ChewieController: hasAudioTracks, setAudioTracks, selectAudioTrack, copyWith propagation, onAudioTrackChanged callback - AudioTrackDialog widget: tile rendering, selection check icon, language hint, tap-to-pick and dismiss-with-null - MaterialControls & MaterialDesktopControls: options menu surfaces the Audio entry only when >1 track and drives selection end to end Co-Authored-By: Claude Opus 4.8 (1M context) --- test/audio_track_controller_test.dart | 108 +++++++++++++++++ test/material/audio_track_controls_test.dart | 115 +++++++++++++++++++ test/material/audio_track_dialog_test.dart | 93 +++++++++++++++ test/models/audio_track_test.dart | 53 +++++++++ 4 files changed, 369 insertions(+) create mode 100644 test/audio_track_controller_test.dart create mode 100644 test/material/audio_track_controls_test.dart create mode 100644 test/material/audio_track_dialog_test.dart create mode 100644 test/models/audio_track_test.dart diff --git a/test/audio_track_controller_test.dart b/test/audio_track_controller_test.dart new file mode 100644 index 000000000..4e1d4feb2 --- /dev/null +++ b/test/audio_track_controller_test.dart @@ -0,0 +1,108 @@ +import 'package:chewie/chewie.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:video_player/video_player.dart'; + +const _tracks = [ + AudioTrack(id: '0', label: 'English', language: 'en'), + AudioTrack(id: '1', label: 'French', language: 'fr'), +]; + +ChewieController _controllerWith({ + List audioTracks = const [], + Object? activeAudioTrackId, + void Function(AudioTrack track)? onAudioTrackChanged, +}) { + // autoInitialize/autoPlay stay false, so no platform plugin call is made. + return ChewieController( + videoPlayerController: VideoPlayerController.networkUrl( + Uri.parse('https://example.com/v.mp4'), + ), + audioTracks: audioTracks, + activeAudioTrackId: activeAudioTrackId, + onAudioTrackChanged: onAudioTrackChanged, + ); +} + +void main() { + // Needed so VideoPlayerController construction and setLooping (a no-op while + // uninitialized) don't trip over the missing test binding. + TestWidgetsFlutterBinding.ensureInitialized(); + + group('ChewieController.hasAudioTracks', () { + test('is false with no tracks', () { + expect(_controllerWith().hasAudioTracks, isFalse); + }); + + test('is false with a single track (nothing to choose)', () { + expect( + _controllerWith( + audioTracks: const [AudioTrack(id: '0', label: 'A')], + ).hasAudioTracks, + isFalse, + ); + }); + + test('is true with two or more tracks', () { + expect(_controllerWith(audioTracks: _tracks).hasAudioTracks, isTrue); + }); + }); + + test('setAudioTracks replaces the tracks and notifies listeners', () { + final controller = _controllerWith(); + var notified = 0; + controller.addListener(() => notified++); + + controller.setAudioTracks(_tracks); + + expect(controller.audioTracks, _tracks); + expect(controller.hasAudioTracks, isTrue); + expect(notified, 1); + }); + + test( + 'selectAudioTrack updates the active id, fires the callback and notifies', + () { + AudioTrack? selected; + final controller = _controllerWith( + audioTracks: _tracks, + onAudioTrackChanged: (track) => selected = track, + ); + var notified = 0; + controller.addListener(() => notified++); + + controller.selectAudioTrack(_tracks[1]); + + expect(controller.activeAudioTrackId, '1'); + expect(selected, _tracks[1]); + expect(notified, 1); + }, + ); + + test('selectAudioTrack works without an onAudioTrackChanged callback', () { + final controller = _controllerWith(audioTracks: _tracks); + expect(() => controller.selectAudioTrack(_tracks[0]), returnsNormally); + expect(controller.activeAudioTrackId, '0'); + }); + + test('copyWith carries the audio-track fields over', () { + final controller = _controllerWith(); + final copy = controller.copyWith( + audioTracks: _tracks, + activeAudioTrackId: '1', + ); + + expect(copy.audioTracks, _tracks); + expect(copy.activeAudioTrackId, '1'); + }); + + test('copyWith keeps the original audio-track fields when omitted', () { + final controller = _controllerWith( + audioTracks: _tracks, + activeAudioTrackId: '0', + ); + final copy = controller.copyWith(autoPlay: false); + + expect(copy.audioTracks, _tracks); + expect(copy.activeAudioTrackId, '0'); + }); +} diff --git a/test/material/audio_track_controls_test.dart b/test/material/audio_track_controls_test.dart new file mode 100644 index 000000000..7564ccbaf --- /dev/null +++ b/test/material/audio_track_controls_test.dart @@ -0,0 +1,115 @@ +import 'package:chewie/chewie.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:video_player/video_player.dart'; + +const _tracks = [ + AudioTrack(id: '0', label: 'English', language: 'en'), + AudioTrack(id: '1', label: 'French', language: 'fr'), +]; + +/// Builds a [ChewieController] that never touches the video_player platform +/// (autoInitialize/autoPlay stay false) but exposes the two audio tracks. +ChewieController _controller( + Widget controls, { + void Function(AudioTrack)? onChanged, +}) { + return ChewieController( + videoPlayerController: VideoPlayerController.networkUrl( + Uri.parse('https://example.com/v.mp4'), + ), + autoPlay: false, + autoInitialize: false, + audioTracks: _tracks, + activeAudioTrackId: '0', + onAudioTrackChanged: onChanged, + customControls: controls, + ); +} + +/// Opens the options menu via [optionsIcon], taps the "Audio" entry, then picks +/// "French" — exercising the controls' audio-track code path end to end. +Future _runFlow( + WidgetTester tester, { + required Widget controls, + required IconData optionsIcon, +}) async { + AudioTrack? chosen; + final controller = _controller(controls, onChanged: (t) => chosen = t); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: Chewie(controller: controller)), + ), + ); + // Let the show-controls-on-initialize timer fire so the controls become + // interactive (otherwise AbsorbPointer swallows the tap). + await tester.pump(const Duration(milliseconds: 300)); + + // Open the options bottom sheet. + await tester.tap(find.byIcon(optionsIcon)); + await tester.pumpAndSettle(); + expect(find.text('Audio'), findsOneWidget); + + // Open the audio-track dialog. + await tester.tap(find.text('Audio')); + await tester.pumpAndSettle(); + expect(find.text('English'), findsOneWidget); + expect(find.text('French'), findsOneWidget); + + // Pick a track. + await tester.tap(find.text('French')); + await tester.pumpAndSettle(); + + expect(chosen, _tracks[1]); + expect(controller.activeAudioTrackId, '1'); +} + +void main() { + testWidgets('MaterialControls: options menu exposes audio-track selection', ( + tester, + ) async { + await _runFlow( + tester, + controls: const MaterialControls(), + optionsIcon: Icons.more_vert, + ); + }); + + testWidgets( + 'MaterialDesktopControls: options menu exposes audio-track selection', + (tester) async { + await _runFlow( + tester, + controls: const MaterialDesktopControls(), + optionsIcon: Icons.settings, + ); + }, + ); + + testWidgets('the Audio entry is hidden when there is only one track', ( + tester, + ) async { + final controller = ChewieController( + videoPlayerController: VideoPlayerController.networkUrl( + Uri.parse('https://example.com/v.mp4'), + ), + autoPlay: false, + autoInitialize: false, + audioTracks: const [AudioTrack(id: '0', label: 'English')], + customControls: const MaterialControls(), + ); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: Chewie(controller: controller)), + ), + ); + await tester.pump(const Duration(milliseconds: 300)); + + await tester.tap(find.byIcon(Icons.more_vert)); + await tester.pumpAndSettle(); + + expect(find.text('Audio'), findsNothing); + }); +} diff --git a/test/material/audio_track_dialog_test.dart b/test/material/audio_track_dialog_test.dart new file mode 100644 index 000000000..df0388eb8 --- /dev/null +++ b/test/material/audio_track_dialog_test.dart @@ -0,0 +1,93 @@ +import 'package:chewie/chewie.dart'; +import 'package:chewie/src/material/widgets/audio_track_dialog.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +const _tracks = [ + AudioTrack(id: '0', label: 'English', language: 'en'), + AudioTrack(id: '1', label: 'French', language: 'fr'), + AudioTrack(id: '2', label: 'No language'), +]; + +/// Pumps a screen with a button that opens the [AudioTrackDialog] in a modal +/// bottom sheet, taps it, and lets the caller inspect/interact with the sheet. +/// The chosen track (or null) lands in [resultHolder] once the sheet closes. +Future _openDialog( + WidgetTester tester, { + required Object? selectedId, + required List resultHolder, +}) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) => Center( + child: ElevatedButton( + onPressed: () async { + final track = await showModalBottomSheet( + context: context, + builder: (_) => + AudioTrackDialog(tracks: _tracks, selectedId: selectedId), + ); + resultHolder.add(track); + }, + child: const Text('open'), + ), + ), + ), + ), + ), + ); + + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); +} + +void main() { + testWidgets('renders one tile per track with its label', (tester) async { + await _openDialog(tester, selectedId: '0', resultHolder: []); + + expect(find.byType(ListTile), findsNWidgets(_tracks.length)); + expect(find.text('English'), findsOneWidget); + expect(find.text('French'), findsOneWidget); + expect(find.text('No language'), findsOneWidget); + }); + + testWidgets('shows a check icon next to the selected track only', ( + tester, + ) async { + await _openDialog(tester, selectedId: '1', resultHolder: []); + + expect(find.byIcon(Icons.check), findsOneWidget); + }); + + testWidgets('shows the language code only for tracks that have one', ( + tester, + ) async { + await _openDialog(tester, selectedId: null, resultHolder: []); + + expect(find.text('en'), findsOneWidget); + expect(find.text('fr'), findsOneWidget); + }); + + testWidgets('tapping a track pops the sheet with that track', (tester) async { + final result = []; + await _openDialog(tester, selectedId: '0', resultHolder: result); + + await tester.tap(find.text('French')); + await tester.pumpAndSettle(); + + expect(result, [_tracks[1]]); + }); + + testWidgets('dismissing the sheet pops with null', (tester) async { + final result = []; + await _openDialog(tester, selectedId: '0', resultHolder: result); + + // Tap the scrim above the sheet to dismiss it. + await tester.tapAt(const Offset(10, 10)); + await tester.pumpAndSettle(); + + expect(result, [null]); + }); +} diff --git a/test/models/audio_track_test.dart b/test/models/audio_track_test.dart new file mode 100644 index 000000000..e3d767ae1 --- /dev/null +++ b/test/models/audio_track_test.dart @@ -0,0 +1,53 @@ +import 'package:chewie/chewie.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('AudioTrack equality', () { + test('two tracks with the same id, label and language are equal', () { + const a = AudioTrack(id: '0', label: 'English', language: 'en'); + const b = AudioTrack(id: '0', label: 'English', language: 'en'); + expect(a, equals(b)); + expect(a.hashCode, b.hashCode); + }); + + test('tracks differing in id are not equal', () { + const a = AudioTrack(id: '0', label: 'English', language: 'en'); + const b = AudioTrack(id: '1', label: 'English', language: 'en'); + expect(a, isNot(equals(b))); + }); + + test('tracks differing in label are not equal', () { + const a = AudioTrack(id: '0', label: 'English', language: 'en'); + const b = AudioTrack(id: '0', label: 'Anglais', language: 'en'); + expect(a, isNot(equals(b))); + }); + + test('tracks differing in language are not equal', () { + const a = AudioTrack(id: '0', label: 'English', language: 'en'); + const b = AudioTrack(id: '0', label: 'English', language: 'fr'); + expect(a, isNot(equals(b))); + }); + + test('an AudioTrack is not equal to another type', () { + const a = AudioTrack(id: '0', label: 'English'); + expect(a == Object(), isFalse); + }); + + test('the id may be any Object, not just a String', () { + const a = AudioTrack(id: 0, label: 'English'); + const b = AudioTrack(id: 0, label: 'English'); + expect(a, equals(b)); + expect(a.hashCode, b.hashCode); + }); + }); + + test('language is optional and defaults to null', () { + const track = AudioTrack(id: '0', label: 'English'); + expect(track.language, isNull); + }); + + test('toString lists the fields', () { + const track = AudioTrack(id: '0', label: 'English', language: 'en'); + expect(track.toString(), 'AudioTrack(id: 0, label: English, language: en)'); + }); +}