Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions lib/src/chewie_player.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -316,6 +317,9 @@ class ChewieController extends ChangeNotifier {
this.subtitle,
this.showSubtitles = false,
this.subtitleBuilder,
this.audioTracks = const <AudioTrack>[],
this.activeAudioTrackId,
this.onAudioTrackChanged,
this.customControls,
this.errorBuilder,
this.bufferingBuilder,
Expand Down Expand Up @@ -369,6 +373,9 @@ class ChewieController extends ChangeNotifier {
Subtitles? subtitle,
bool? showSubtitles,
Widget Function(BuildContext, dynamic)? subtitleBuilder,
List<AudioTrack>? audioTracks,
Object? activeAudioTrackId,
void Function(AudioTrack track)? onAudioTrackChanged,
Widget? customControls,
WidgetBuilder? bufferingBuilder,
Widget Function(BuildContext, String)? errorBuilder,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<AudioTrack> 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;

Expand Down Expand Up @@ -718,6 +746,23 @@ class ChewieController extends ChangeNotifier {
void setSubtitle(List<Subtitle> 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<AudioTrack> 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 {
Expand Down
33 changes: 33 additions & 0 deletions lib/src/material/material_controls.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -164,6 +166,15 @@ class _MaterialControlsState extends State<MaterialControls>
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 &&
Expand Down Expand Up @@ -493,6 +504,28 @@ class _MaterialControlsState extends State<MaterialControls>
});
}

Future<void> _onAudioTrackButtonTap() async {
_hideTimer?.cancel();

final track = await showModalBottomSheet<AudioTrack>(
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();
Expand Down
33 changes: 33 additions & 0 deletions lib/src/material/material_desktop_controls.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -182,6 +184,15 @@ class _MaterialDesktopControlsState extends State<MaterialDesktopControls>
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 &&
Expand Down Expand Up @@ -458,6 +469,28 @@ class _MaterialDesktopControlsState extends State<MaterialDesktopControls>
});
}

Future<void> _onAudioTrackButtonTap() async {
_hideTimer?.cancel();

final track = await showModalBottomSheet<AudioTrack>(
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();
Expand Down
50 changes: 50 additions & 0 deletions lib/src/material/widgets/audio_track_dialog.dart
Original file line number Diff line number Diff line change
@@ -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<AudioTrack> tracks,
required Object? selectedId,
}) : _tracks = tracks,
_selectedId = selectedId;

final List<AudioTrack> _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,
),
],
),
),
],
);
}
}
33 changes: 33 additions & 0 deletions lib/src/models/audio_track.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/// 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)';
}
1 change: 1 addition & 0 deletions lib/src/models/index.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export 'audio_track.dart';
export 'option_item.dart';
export 'options_translation.dart';
export 'subtitle_model.dart';
108 changes: 108 additions & 0 deletions test/audio_track_controller_test.dart
Original file line number Diff line number Diff line change
@@ -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>[
AudioTrack(id: '0', label: 'English', language: 'en'),
AudioTrack(id: '1', label: 'French', language: 'fr'),
];

ChewieController _controllerWith({
List<AudioTrack> audioTracks = const <AudioTrack>[],
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');
});
}
Loading