diff --git a/lib/widgets/video_controls/models/track_controls_state.dart b/lib/widgets/video_controls/models/track_controls_state.dart index 303bccd1..9f7c02d0 100644 --- a/lib/widgets/video_controls/models/track_controls_state.dart +++ b/lib/widgets/video_controls/models/track_controls_state.dart @@ -6,6 +6,7 @@ import '../../../media/media_source_info.dart'; import '../../../models/transcode_quality_preset.dart'; import '../../../mpv/mpv.dart'; import '../../../services/shader_service.dart'; +import '../helpers/track_filter_helper.dart'; /// Immutable configuration for track/chapter control widgets. class TrackControlsState { @@ -124,4 +125,20 @@ class TrackControlsState { this.onSubtitleDownloaded, this.subtitleSearchSupported = true, }); + + /// Source subtitles can only be selected when playback can be re-opened with + /// a Plex source subtitle stream id. + bool get canUseSourceSubtitles => + isTranscoding && sourceSubtitleTracks.isNotEmpty && onSwitchSubtitleStreamId != null; + + /// External subtitle search needs both a searchable media item and a server + /// that can proxy the OpenSubtitles request. + bool get canSearchSubtitles => ratingKey.isNotEmpty && serverId.isNotEmpty && subtitleSearchSupported; + + /// Whether the track sheet should expose subtitle controls at all. This is + /// the single source of truth shared by the toolbar icon and the sheet layout. + bool hasSubtitleControls(Tracks? tracks) { + final playerSubtitles = tracks?.subtitle ?? const []; + return canUseSourceSubtitles || TrackFilterHelper.hasTracks(playerSubtitles) || canSearchSubtitles; + } } diff --git a/lib/widgets/video_controls/sheets/track_sheet.dart b/lib/widgets/video_controls/sheets/track_sheet.dart index 7d7642a2..f482b904 100644 --- a/lib/widgets/video_controls/sheets/track_sheet.dart +++ b/lib/widgets/video_controls/sheets/track_sheet.dart @@ -12,55 +12,16 @@ import '../../../widgets/overlay_sheet.dart'; import 'base_video_control_sheet.dart'; import 'sheet_column_header.dart'; import 'subtitle_search_sheet.dart'; +import '../models/track_controls_state.dart'; import '../helpers/track_filter_helper.dart'; import '../helpers/track_selection_helper.dart'; /// Combined bottom sheet for selecting audio and subtitle tracks side-by-side. class TrackSheet extends StatelessWidget { final Player player; - final String ratingKey; - final String serverId; - final String? mediaTitle; - final Future Function()? onSubtitleDownloaded; - final Function(AudioTrack)? onAudioTrackChanged; - final Function(SubtitleTrack)? onSubtitleTrackChanged; - final Function(SubtitleTrack)? onSecondarySubtitleTrackChanged; + final TrackControlsState trackControlsState; - /// When true, or when a Jellyfin source has external audio, the audio column - /// renders [sourceAudioTracks] and taps are routed to [onSwitchAudioStreamId] - /// instead of using the player's in-stream audio selection. - final bool isTranscoding; - final List sourceAudioTracks; - final int? selectedAudioStreamId; - final ValueChanged? onSwitchAudioStreamId; - final List sourceSubtitleTracks; - final int? selectedSubtitleStreamId; - final ValueChanged? onSwitchSubtitleStreamId; - - /// Whether OpenSubtitles search is supported by the active server. Plex - /// proxies the OpenSubtitles plugin; Jellyfin doesn't expose an - /// equivalent today. - final bool subtitleSearchSupported; - - const TrackSheet({ - super.key, - required this.player, - this.ratingKey = '', - this.serverId = '', - this.mediaTitle, - this.onSubtitleDownloaded, - this.onAudioTrackChanged, - this.onSubtitleTrackChanged, - this.onSecondarySubtitleTrackChanged, - this.isTranscoding = false, - this.sourceAudioTracks = const [], - this.selectedAudioStreamId, - this.onSwitchAudioStreamId, - this.sourceSubtitleTracks = const [], - this.selectedSubtitleStreamId, - this.onSwitchSubtitleStreamId, - this.subtitleSearchSupported = true, - }); + const TrackSheet({super.key, required this.player, required this.trackControlsState}); @override Widget build(BuildContext context) { @@ -75,12 +36,15 @@ class TrackSheet extends StatelessWidget { (t) => t?.subtitle ?? [], ); - final hasExternalSourceAudio = sourceAudioTracks.any((track) => track.isExternal); + final state = trackControlsState; + final hasExternalSourceAudio = state.sourceAudioTracks.any((track) => track.isExternal); final useSourceAudio = - (isTranscoding || hasExternalSourceAudio) && sourceAudioTracks.length > 1 && onSwitchAudioStreamId != null; - final useSourceSubtitles = isTranscoding && sourceSubtitleTracks.isNotEmpty && onSwitchSubtitleStreamId != null; + (state.isTranscoding || hasExternalSourceAudio) && + state.sourceAudioTracks.length > 1 && + state.onSwitchAudioStreamId != null; + final useSourceSubtitles = state.canUseSourceSubtitles; final showAudio = useSourceAudio || playerAudioTracks.length > 1; - final showSubtitles = useSourceSubtitles || subtitleTracks.isNotEmpty; + final showSubtitles = state.hasSubtitleControls(tracks); final String title; final IconData icon; @@ -109,9 +73,9 @@ class TrackSheet extends StatelessWidget { Widget audioColumnFor(TrackSelection sel, bool showHeader) { if (useSourceAudio) { return _SourceAudioColumn( - tracks: sourceAudioTracks, - selectedStreamId: selectedAudioStreamId, - onSelected: onSwitchAudioStreamId!, + tracks: state.sourceAudioTracks, + selectedStreamId: state.selectedAudioStreamId, + onSelected: state.onSwitchAudioStreamId!, showHeader: showHeader, ); } @@ -119,7 +83,7 @@ class TrackSheet extends StatelessWidget { tracks: playerAudioTracks, selection: sel, player: player, - onTrackChanged: onAudioTrackChanged, + onTrackChanged: state.onAudioTrackChanged, showHeader: showHeader, ); } @@ -127,30 +91,18 @@ class TrackSheet extends StatelessWidget { Widget subtitleColumnFor(TrackSelection sel, bool showHeader) { if (useSourceSubtitles) { return _SourceSubtitleColumn( - tracks: sourceSubtitleTracks, - selectedStreamId: selectedSubtitleStreamId, - onSelected: onSwitchSubtitleStreamId!, - ratingKey: ratingKey, - serverId: serverId, - mediaTitle: mediaTitle, - onSubtitleDownloaded: onSubtitleDownloaded, + tracks: state.sourceSubtitleTracks, + trackControlsState: state, showHeader: showHeader, - subtitleSearchSupported: subtitleSearchSupported, ); } return _SubtitleColumn( tracks: subtitleTracks, selection: sel, player: player, - ratingKey: ratingKey, - serverId: serverId, - mediaTitle: mediaTitle, - onSubtitleDownloaded: onSubtitleDownloaded, - onTrackChanged: onSubtitleTrackChanged, - onSecondaryTrackChanged: onSecondarySubtitleTrackChanged, supportsSecondary: supportsSecondary, showHeader: showHeader, - subtitleSearchSupported: subtitleSearchSupported, + trackControlsState: state, ); } @@ -249,26 +201,10 @@ class _SourceAudioColumnState extends State<_SourceAudioColumn> { class _SourceSubtitleColumn extends StatefulWidget { final List tracks; - final int? selectedStreamId; - final ValueChanged onSelected; - final String ratingKey; - final String serverId; - final String? mediaTitle; - final Future Function()? onSubtitleDownloaded; + final TrackControlsState trackControlsState; final bool showHeader; - final bool subtitleSearchSupported; - const _SourceSubtitleColumn({ - required this.tracks, - required this.selectedStreamId, - required this.onSelected, - this.ratingKey = '', - this.serverId = '', - this.mediaTitle, - this.onSubtitleDownloaded, - required this.showHeader, - this.subtitleSearchSupported = true, - }); + const _SourceSubtitleColumn({required this.tracks, required this.trackControlsState, required this.showHeader}); @override State<_SourceSubtitleColumn> createState() => _SourceSubtitleColumnState(); @@ -304,7 +240,7 @@ class _SourceSubtitleColumnState extends State<_SourceSubtitleColumn> { isSelected: selectedId == 0, onTap: () { OverlaySheetController.of(context).close(); - widget.onSelected(0); + widget.trackControlsState.onSwitchSubtitleStreamId!(0); }, ); } @@ -316,35 +252,19 @@ class _SourceSubtitleColumnState extends State<_SourceSubtitleColumn> { isSelected: track.id == selectedId, onTap: () { OverlaySheetController.of(context).close(); - widget.onSelected(track.id); + widget.trackControlsState.onSwitchSubtitleStreamId!(track.id); }, ); }, ), ), - if (widget.ratingKey.isNotEmpty && widget.subtitleSearchSupported) ...[ - Divider(height: 1, color: Theme.of(context).dividerColor), - FocusableListTile( - leading: const AppIcon(Symbols.search_rounded), - title: Text(t.videoControls.searchSubtitles), - onTap: () { - OverlaySheetController.of(context).push( - builder: (_) => SubtitleSearchSheet( - ratingKey: widget.ratingKey, - serverId: widget.serverId, - mediaTitle: widget.mediaTitle, - onSubtitleDownloaded: widget.onSubtitleDownloaded, - ), - ); - }, - ), - ], + ..._buildSubtitleSearchFooter(context, widget.trackControlsState), ], ); } int _effectiveSelectedStreamId() { - final explicit = widget.selectedStreamId; + final explicit = widget.trackControlsState.selectedSubtitleStreamId; if (explicit != null && (explicit == 0 || widget.tracks.any((track) => track.id == explicit))) return explicit; for (final track in widget.tracks) { if (track.selected) return track.id; @@ -426,29 +346,17 @@ class _SubtitleColumn extends StatefulWidget { final List tracks; final TrackSelection selection; final Player player; - final String ratingKey; - final String serverId; - final String? mediaTitle; - final Future Function()? onSubtitleDownloaded; - final Function(SubtitleTrack)? onTrackChanged; - final Function(SubtitleTrack)? onSecondaryTrackChanged; final bool supportsSecondary; final bool showHeader; - final bool subtitleSearchSupported; + final TrackControlsState trackControlsState; const _SubtitleColumn({ required this.tracks, required this.selection, required this.player, - this.ratingKey = '', - this.serverId = '', - this.mediaTitle, - this.onSubtitleDownloaded, - this.onTrackChanged, - this.onSecondaryTrackChanged, this.supportsSecondary = false, required this.showHeader, - this.subtitleSearchSupported = true, + required this.trackControlsState, }); @override @@ -494,22 +402,22 @@ class _SubtitleColumnState extends State<_SubtitleColumn> { // Turning off primary also clears secondary if (hasSecondary) { widget.player.selectSecondarySubtitleTrack(SubtitleTrack.off); - widget.onSecondaryTrackChanged?.call(SubtitleTrack.off); + widget.trackControlsState.onSecondarySubtitleTrackChanged?.call(SubtitleTrack.off); } widget.player.selectSubtitleTrack(SubtitleTrack.off); - widget.onTrackChanged?.call(SubtitleTrack.off); + widget.trackControlsState.onSubtitleTrackChanged?.call(SubtitleTrack.off); OverlaySheetController.of(context).close(); }, onLongPress: widget.supportsSecondary && hasSecondary ? () { widget.player.selectSecondarySubtitleTrack(SubtitleTrack.off); - widget.onSecondaryTrackChanged?.call(SubtitleTrack.off); + widget.trackControlsState.onSecondarySubtitleTrackChanged?.call(SubtitleTrack.off); } : null, onSecondaryTap: widget.supportsSecondary && hasSecondary ? () { widget.player.selectSecondarySubtitleTrack(SubtitleTrack.off); - widget.onSecondaryTrackChanged?.call(SubtitleTrack.off); + widget.trackControlsState.onSecondarySubtitleTrackChanged?.call(SubtitleTrack.off); } : null, ); @@ -543,10 +451,10 @@ class _SubtitleColumnState extends State<_SubtitleColumn> { // If tapping a track that is currently the secondary, clear secondary first if (isSecondary) { widget.player.selectSecondarySubtitleTrack(SubtitleTrack.off); - widget.onSecondaryTrackChanged?.call(SubtitleTrack.off); + widget.trackControlsState.onSecondarySubtitleTrackChanged?.call(SubtitleTrack.off); } widget.player.selectSubtitleTrack(track); - widget.onTrackChanged?.call(track); + widget.trackControlsState.onSubtitleTrackChanged?.call(track); OverlaySheetController.of(context).close(); }, onLongPress: widget.supportsSecondary @@ -554,11 +462,11 @@ class _SubtitleColumnState extends State<_SubtitleColumn> { if (isSecondary) { // Already secondary — clear it widget.player.selectSecondarySubtitleTrack(SubtitleTrack.off); - widget.onSecondaryTrackChanged?.call(SubtitleTrack.off); + widget.trackControlsState.onSecondarySubtitleTrackChanged?.call(SubtitleTrack.off); } else if (!isPrimary) { // Set as secondary (don't close sheet so user sees badge update) widget.player.selectSecondarySubtitleTrack(track); - widget.onSecondaryTrackChanged?.call(track); + widget.trackControlsState.onSecondarySubtitleTrackChanged?.call(track); } } : null, @@ -566,10 +474,10 @@ class _SubtitleColumnState extends State<_SubtitleColumn> { ? () { if (isSecondary) { widget.player.selectSecondarySubtitleTrack(SubtitleTrack.off); - widget.onSecondaryTrackChanged?.call(SubtitleTrack.off); + widget.trackControlsState.onSecondarySubtitleTrackChanged?.call(SubtitleTrack.off); } else if (!isPrimary) { widget.player.selectSecondarySubtitleTrack(track); - widget.onSecondaryTrackChanged?.call(track); + widget.trackControlsState.onSecondarySubtitleTrackChanged?.call(track); } } : null, @@ -577,24 +485,30 @@ class _SubtitleColumnState extends State<_SubtitleColumn> { }, ), ), - if (widget.ratingKey.isNotEmpty && widget.subtitleSearchSupported) ...[ - Divider(height: 1, color: Theme.of(context).dividerColor), - FocusableListTile( - leading: const AppIcon(Symbols.search_rounded), - title: Text(t.videoControls.searchSubtitles), - onTap: () { - OverlaySheetController.of(context).push( - builder: (_) => SubtitleSearchSheet( - ratingKey: widget.ratingKey, - serverId: widget.serverId, - mediaTitle: widget.mediaTitle, - onSubtitleDownloaded: widget.onSubtitleDownloaded, - ), - ); - }, - ), - ], + ..._buildSubtitleSearchFooter(context, widget.trackControlsState), ], ); } } + +List _buildSubtitleSearchFooter(BuildContext context, TrackControlsState state) { + if (!state.canSearchSubtitles) return const []; + + return [ + Divider(height: 1, color: Theme.of(context).dividerColor), + FocusableListTile( + leading: const AppIcon(Symbols.search_rounded), + title: Text(t.videoControls.searchSubtitles), + onTap: () { + OverlaySheetController.of(context).push( + builder: (_) => SubtitleSearchSheet( + ratingKey: state.ratingKey, + serverId: state.serverId, + mediaTitle: state.mediaTitle, + onSubtitleDownloaded: state.onSubtitleDownloaded, + ), + ); + }, + ), + ]; +} diff --git a/lib/widgets/video_controls/widgets/track_chapter_controls.dart b/lib/widgets/video_controls/widgets/track_chapter_controls.dart index c2da317b..e23b74e3 100644 --- a/lib/widgets/video_controls/widgets/track_chapter_controls.dart +++ b/lib/widgets/video_controls/widgets/track_chapter_controls.dart @@ -18,7 +18,6 @@ import '../sheets/queue_sheet.dart'; import '../sheets/track_sheet.dart'; import '../sheets/video_settings_sheet.dart'; import '../../../services/shader_service.dart'; -import '../helpers/track_filter_helper.dart'; import '../video_control_button.dart'; /// Row of track and chapter control buttons for the video player @@ -86,9 +85,6 @@ class TrackChapterControls extends StatelessWidget { VoidCallback? get onToggleFullscreen => trackControlsState.onToggleFullscreen; VoidCallback? get onToggleAlwaysOnTop => trackControlsState.onToggleAlwaysOnTop; Function(int)? get onSwitchVersion => trackControlsState.onSwitchVersion; - Function(AudioTrack)? get onAudioTrackChanged => trackControlsState.onAudioTrackChanged; - Function(SubtitleTrack)? get onSubtitleTrackChanged => trackControlsState.onSubtitleTrackChanged; - Function(SubtitleTrack)? get onSecondarySubtitleTrackChanged => trackControlsState.onSecondarySubtitleTrackChanged; VoidCallback? get onLoadSeekTimes => trackControlsState.onLoadSeekTimes; VoidCallback? get onCancelAutoHide => trackControlsState.onCancelAutoHide; VoidCallback? get onStartAutoHide => trackControlsState.onStartAutoHide; @@ -103,9 +99,6 @@ class TrackChapterControls extends StatelessWidget { bool get subtitlesVisible => trackControlsState.subtitlesVisible; bool get showQueueButton => trackControlsState.showQueueButton; Function(MediaItem)? get onQueueItemSelected => trackControlsState.onQueueItemSelected; - String get ratingKey => trackControlsState.ratingKey; - String? get mediaTitle => trackControlsState.mediaTitle; - Future Function()? get onSubtitleDownloaded => trackControlsState.onSubtitleDownloaded; /// Handle key event for button navigation KeyEventResult _handleButtonKeyEvent(FocusNode _, KeyEvent event, int index, int totalButtons) { @@ -257,13 +250,11 @@ class TrackChapterControls extends StatelessWidget { // Combined audio & subtitles button { final currentIndex = buttonIndex; - final hasSourceSubs = - trackControlsState.sourceSubtitleTracks.isNotEmpty && trackControlsState.onSwitchSubtitleStreamId != null; - final hasSubs = _hasSubtitles(tracks) || hasSourceSubs; + final hasSubtitleControls = trackControlsState.hasSubtitleControls(tracks); final selectedSub = player.state.track.subtitle; final hasActiveSubtitle = selectedSub != null && selectedSub.id != 'no'; - final isHidden = hasSubs && hasActiveSubtitle && !subtitlesVisible; - final icon = hasSubs + final isHidden = hasSubtitleControls && hasActiveSubtitle && !subtitlesVisible; + final icon = hasSubtitleControls ? (isHidden ? Symbols.subtitles_off_rounded : Symbols.subtitles_rounded) : Symbols.audiotrack_rounded; buttons.add( @@ -278,24 +269,7 @@ class TrackChapterControls extends StatelessWidget { onCancelAutoHide?.call(); OverlaySheetController.of(context) .show( - builder: (_) => TrackSheet( - player: player, - ratingKey: ratingKey, - serverId: serverId, - mediaTitle: mediaTitle, - onSubtitleDownloaded: onSubtitleDownloaded, - onAudioTrackChanged: onAudioTrackChanged, - onSubtitleTrackChanged: onSubtitleTrackChanged, - onSecondarySubtitleTrackChanged: onSecondarySubtitleTrackChanged, - isTranscoding: trackControlsState.isTranscoding, - sourceAudioTracks: trackControlsState.sourceAudioTracks, - selectedAudioStreamId: trackControlsState.selectedAudioStreamId, - onSwitchAudioStreamId: trackControlsState.onSwitchAudioStreamId, - sourceSubtitleTracks: trackControlsState.sourceSubtitleTracks, - selectedSubtitleStreamId: trackControlsState.selectedSubtitleStreamId, - onSwitchSubtitleStreamId: trackControlsState.onSwitchSubtitleStreamId, - subtitleSearchSupported: trackControlsState.subtitleSearchSupported, - ), + builder: (_) => TrackSheet(player: player, trackControlsState: trackControlsState), ) .whenComplete(() => onStartAutoHide?.call()); }, @@ -480,11 +454,6 @@ class TrackChapterControls extends StatelessWidget { return count; } - bool _hasSubtitles(Tracks? tracks) { - if (tracks == null) return false; - return TrackFilterHelper.hasTracks(tracks.subtitle); - } - IconData _getBoxFitIcon(int mode) { switch (mode) { case 0: diff --git a/test/widgets/track_sheet_test.dart b/test/widgets/track_sheet_test.dart new file mode 100644 index 00000000..3e350ccc --- /dev/null +++ b/test/widgets/track_sheet_test.dart @@ -0,0 +1,159 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/i18n/strings.g.dart'; +import 'package:plezy/media/media_source_info.dart'; +import 'package:plezy/mpv/mpv.dart'; +import 'package:plezy/widgets/video_controls/models/track_controls_state.dart'; +import 'package:plezy/widgets/video_controls/sheets/track_sheet.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + LocaleSettings.setLocaleSync(AppLocale.en); + }); + + group('TrackSheet subtitle controls', () { + testWidgets('shows subtitle search when Plex search is available without subtitle tracks', (tester) async { + final player = _FakeTrackSheetPlayer( + tracks: const Tracks( + audio: [ + AudioTrack(id: 'a1'), + AudioTrack(id: 'a2'), + ], + ), + track: const TrackSelection( + audio: AudioTrack(id: 'a1'), + subtitle: SubtitleTrack.off, + ), + ); + + await _pumpTrackSheet( + tester, + player: player, + trackControlsState: const TrackControlsState( + ratingKey: '123', + serverId: 'plex-server', + subtitleSearchSupported: true, + ), + ); + + expect(find.text('Search Subtitles'), findsOneWidget); + }); + + testWidgets('hides subtitle search when external subtitle search is unsupported', (tester) async { + final player = _FakeTrackSheetPlayer( + tracks: const Tracks( + audio: [ + AudioTrack(id: 'a1'), + AudioTrack(id: 'a2'), + ], + ), + track: const TrackSelection( + audio: AudioTrack(id: 'a1'), + subtitle: SubtitleTrack.off, + ), + ); + + await _pumpTrackSheet( + tester, + player: player, + trackControlsState: const TrackControlsState( + ratingKey: '123', + serverId: 'jellyfin-server', + subtitleSearchSupported: false, + ), + ); + + expect(find.text('Search Subtitles'), findsNothing); + }); + }); + + group('TrackControlsState.hasSubtitleControls', () { + test('counts source subtitles only when source switching is available', () { + final sourceSubtitle = MediaSubtitleTrack(id: 1, selected: false, forced: false); + + expect( + TrackControlsState( + isTranscoding: true, + sourceSubtitleTracks: [sourceSubtitle], + ).hasSubtitleControls(const Tracks()), + isFalse, + ); + expect( + TrackControlsState( + isTranscoding: true, + sourceSubtitleTracks: [sourceSubtitle], + onSwitchSubtitleStreamId: (_) {}, + ).hasSubtitleControls(const Tracks()), + isTrue, + ); + }); + + test('ignores player subtitle placeholders', () { + const state = TrackControlsState(subtitleSearchSupported: false); + + expect(state.hasSubtitleControls(const Tracks(subtitle: [SubtitleTrack.auto, SubtitleTrack.off])), isFalse); + expect(state.hasSubtitleControls(const Tracks(subtitle: [SubtitleTrack(id: 's1')])), isTrue); + }); + }); +} + +Future _pumpTrackSheet( + WidgetTester tester, { + required Player player, + required TrackControlsState trackControlsState, +}) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + width: 700, + height: 400, + child: TrackSheet(player: player, trackControlsState: trackControlsState), + ), + ), + ), + ); + await tester.pump(); +} + +class _FakeTrackSheetPlayer implements Player { + _FakeTrackSheetPlayer({required Tracks tracks, required TrackSelection track}) + : _state = PlayerState(tracks: tracks, track: track), + _streams = PlayerStreams( + playing: const Stream.empty(), + completed: const Stream.empty(), + buffering: const Stream.empty(), + position: const Stream.empty(), + duration: const Stream.empty(), + seekable: const Stream.empty(), + buffer: const Stream.empty(), + volume: const Stream.empty(), + rate: const Stream.empty(), + tracks: const Stream.empty(), + track: const Stream.empty(), + log: const Stream.empty(), + error: const Stream.empty(), + audioDevice: const Stream.empty(), + audioDevices: const Stream>.empty(), + bufferRanges: const Stream>.empty(), + playbackRestart: const Stream.empty(), + backendSwitched: const Stream.empty(), + ); + + final PlayerState _state; + final PlayerStreams _streams; + + @override + PlayerState get state => _state; + + @override + PlayerStreams get streams => _streams; + + @override + bool get supportsSecondarySubtitles => false; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +}