Files
plezy/test/widgets/video_controls_select_key_test.dart
T
294419109b feat(player): dismiss the skip prompt on Back instead of exiting (#2049)
While a Skip Intro / Skip Credits prompt is up and the chrome is down,
Back walked the screen's staged chain and exited playback. On a remote
nothing else means "no thanks": left/right seek, OK takes the skip,
up/down raises the OSD. Declining an intro cost you the episode.

Adds a stage for it alongside the sheet and content-strip stages that
already run locally in the player controls. Gated on the same condition
as Select's skip path, off on phones (#1938), off without canControl,
and off while a screen-level prompt owns the key.

The claim is latched for the whole press: handleBackKeyAction acts on
the key-up, and the button's own 7s auto-dismiss can fire in between.

Co-authored-by: cajunflavoredbob <cajunflavoredbob@users.noreply.github.com>
2026-08-21 08:48:44 +02:00

638 lines
26 KiB
Dart

import 'dart:async';
import 'package:drift/native.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:intl/date_symbol_data_local.dart';
import 'package:provider/provider.dart';
import 'package:plezy/database/app_database.dart';
import 'package:plezy/focus/input_mode_tracker.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/providers/playback_state_provider.dart';
import 'package:plezy/services/settings_service.dart';
import 'package:plezy/services/video_volume_controller.dart';
import 'package:plezy/utils/platform_detector.dart';
import 'package:plezy/watch_together/providers/watch_together_provider.dart';
import 'package:plezy/widgets/video_controls/desktop_video_controls.dart';
import 'package:plezy/widgets/video_controls/player_chrome_controller.dart';
import 'package:plezy/widgets/video_controls/video_controls.dart';
import 'package:plezy/widgets/video_controls/widgets/player_toast_indicator.dart';
import 'package:plezy/widgets/video_controls/widgets/skip_marker_button.dart';
import 'package:plezy/focus/dpad_navigator.dart';
import '../test_helpers/media_items.dart';
import '../test_helpers/prefs.dart';
import '../test_helpers/theme.dart';
/// Pressing Enter on the player surface used to raise the chrome *and* drop
/// focus onto Play/Pause, while the global input-mode tracker separately
/// switched the whole app into keyboard mode — even with "Video Player
/// Navigation" off. Enter activates whatever already has focus; it is not a
/// request to start navigating, so it must leave focus where the viewer left it
/// while still doing its job. Tab and a remote's OK remain the deliberate ways
/// into the OSD.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late _TogglePlayer player;
late PlayerChromeController chrome;
late PlayerToastController toast;
late VideoVolumeController volume;
late PlaybackStateProvider playbackState;
late WatchTogetherProvider watchTogether;
late AppDatabase database;
late ValueNotifier<bool> hasFirstFrame;
late SettingsService settings;
late FocusNode screenFocusNode;
var toggles = 0;
/// What the player screen would have done with each Back that escaped the
/// controls. The harness cannot run the real staged chain, so it records the
/// disposition the screen would have resolved: an empty list means the
/// controls answered the press themselves.
final screenBackDispositions = <PlayerBackDisposition>[];
/// Back key-downs that reached the screen node. A stage that claims the press
/// consumes the down as well as the up, so this separates "the controls
/// declined the press" from "a later guard handed it back".
var screenBackKeyDowns = 0;
Future<void> setNavigationEnabled(bool value) => settings.write(SettingsService.videoPlayerNavigationEnabled, value);
setUp(() async {
LocaleSettings.setLocaleSync(AppLocale.en);
await initializeDateFormatting('en');
resetSharedPreferencesForTest();
SettingsService.resetForTesting();
settings = await SettingsService.getInstance();
// Desktop with a pointer: the configuration the report came from.
TvDetectionService.debugSetAppleTVOverride(false);
PlatformDetector.debugSetIsDesktopOSOverride(true);
database = AppDatabase.forTesting(NativeDatabase.memory());
player = _TogglePlayer();
chrome = PlayerChromeController(initiallyVisible: false);
toast = PlayerToastController();
volume = VideoVolumeController(player: player, settings: settings, initialVolume: 100);
playbackState = PlaybackStateProvider();
watchTogether = WatchTogetherProvider();
hasFirstFrame = ValueNotifier<bool>(true);
screenFocusNode = FocusNode(debugLabel: 'VideoPlayerScreen');
toggles = 0;
screenBackDispositions.clear();
screenBackKeyDowns = 0;
});
tearDown(() async {
TvDetectionService.debugSetAppleTVOverride(null);
TvDetectionService.setForceTVSync(false);
PlatformDetector.debugSetIsDesktopOSOverride(null);
hasFirstFrame.dispose();
screenFocusNode.dispose();
volume.dispose();
playbackState.dispose();
watchTogether.dispose();
chrome.dispose();
toast.dispose();
await player.close();
await database.close();
});
/// Mirrors the production tree: the screen-level node autofocuses during the
/// loading phase and already holds the remote by the time the controls mount,
/// so the controls' own `autofocus` cannot win it back. Without that, the
/// surface would take focus by default and the startup claim would look
/// correct even when it is not.
Widget shell(Widget child, {TargetPlatform platform = TargetPlatform.windows}) => InputModeTracker(
child: MultiProvider(
providers: [
Provider<AppDatabase>.value(value: database),
ChangeNotifierProvider<PlaybackStateProvider>.value(value: playbackState),
ChangeNotifierProvider<WatchTogetherProvider>.value(value: watchTogether),
],
child: MaterialApp(
theme: ThemeData(platform: platform, extensions: const [testMonoTokens]),
home: Scaffold(
body: SizedBox(
width: 1280,
height: 720,
child: Focus(
focusNode: screenFocusNode,
autofocus: true,
onKeyEvent: (node, event) {
if (event is KeyDownEvent && event.logicalKey.isBackKey) screenBackKeyDowns++;
if (event is KeyUpEvent && event.logicalKey.isBackKey) {
screenBackDispositions.add(
resolvePlayerBackDisposition(
navigationKey: classifyPlayerNavigationKey(event, isAppleTV: PlatformDetector.isAppleTV()),
contentStripVisible: chrome.contentStripVisible,
controlsVisible: chrome.controlsVisible,
physicalEscapeExitsFullscreen: false,
),
);
}
return KeyEventResult.ignored;
},
child: child,
),
),
),
),
),
);
Future<void> pumpPlayer(
WidgetTester tester, {
List<MediaMarker>? markers,
TargetPlatform platform = TargetPlatform.windows,
bool canControl = true,
bool playbackPromptOpen = false,
}) async {
await tester.pumpWidget(shell(const SizedBox.expand(), platform: platform));
await tester.pump();
expect(screenFocusNode.hasPrimaryFocus, isTrue, reason: 'precondition: the screen node owns the remote');
await tester.pumpWidget(
shell(
platform: platform,
PlexVideoControls(
player: player,
volumeController: volume,
metadata: testMediaItem(id: 'select-key'),
initialMarkers: markers,
toastController: toast,
chromeController: chrome,
hasFirstFrame: hasFirstFrame,
canNavigateMediaItems: false,
canControl: canControl,
playbackPromptOpen: playbackPromptOpen,
onPlayPauseRequested: (_) async => toggles++,
),
),
);
await tester.pumpAndSettle();
}
Future<void> press(WidgetTester tester, LogicalKeyboardKey key) async {
await tester.sendKeyDownEvent(key);
await tester.pump();
await tester.sendKeyUpEvent(key);
await tester.pumpAndSettle();
}
String? focusLabel() => FocusManager.instance.primaryFocus?.debugLabel;
InputMode currentMode(WidgetTester tester) =>
InputModeTracker.of(tester.element(find.byType(PlexVideoControls)), listen: false);
/// Mounts the player, runs [body], then unmounts and disarms the auto-hide
/// timer so the harness's pending-timer check stays honest.
void playerTest(
String description,
Future<void> Function(WidgetTester tester) body, {
List<MediaMarker>? markers,
TargetPlatform platform = TargetPlatform.windows,
bool canControl = true,
bool playbackPromptOpen = false,
}) {
testWidgets(description, (tester) async {
await pumpPlayer(
tester,
markers: markers,
platform: platform,
canControl: canControl,
playbackPromptOpen: playbackPromptOpen,
);
await body(tester);
chrome.cancelAutoHide();
await tester.pumpWidget(const SizedBox.shrink());
});
}
group('player navigation disabled', () {
setUp(() => setNavigationEnabled(false));
playerTest('the player surface, not the screen node, owns the remote once mounted', (tester) async {
expect(focusLabel(), 'PlayerSurface');
});
playerTest('Enter raises the chrome and toggles playback without taking focus', (tester) async {
await press(tester, LogicalKeyboardKey.enter);
expect(chrome.controlsVisible, isTrue, reason: 'Select is the show-me-the-controls affordance');
expect(toggles, 1);
expect(focusLabel(), 'PlayerSurface', reason: 'a keyboard Enter must not start a focus session');
expect(currentMode(tester), InputMode.pointer, reason: 'and must not arm focus chrome app-wide');
});
playerTest('Enter keeps toggling once the chrome is up', (tester) async {
await press(tester, LogicalKeyboardKey.enter);
await press(tester, LogicalKeyboardKey.enter);
expect(toggles, 2, reason: 'Select must not become a one-shot key when the chrome is visible');
expect(focusLabel(), 'PlayerSurface');
});
playerTest('a remote OK does hand the chrome focus', (tester) async {
await press(tester, LogicalKeyboardKey.select);
expect(chrome.controlsVisible, isTrue);
expect(focusLabel(), 'PlayPause', reason: 'a remote has no pointer, so OK is a navigation request');
expect(currentMode(tester), InputMode.keyboard);
});
playerTest('Tab is the keyboard way into the OSD, and keeps traversing inside it', (tester) async {
await press(tester, LogicalKeyboardKey.tab);
expect(chrome.controlsVisible, isTrue);
expect(focusLabel(), 'PlayPause', reason: 'Tab with the chrome down raises it and hands it focus');
// The surface handler must not swallow Tab once focus is inside the OSD,
// or the viewer is stranded on the control Tab first landed on.
await press(tester, LogicalKeyboardKey.tab);
expect(
focusLabel(),
isNot(anyOf('PlayPause', 'PlayerSurface')),
reason: 'app-level NextFocusAction must reach the next OSD control',
);
});
playerTest('an arrow seeks without switching the app into keyboard mode', (tester) async {
await press(tester, LogicalKeyboardKey.arrowRight);
expect(currentMode(tester), InputMode.pointer, reason: 'arrows are playback shortcuts here');
expect(focusLabel(), 'PlayerSurface');
});
});
group('player navigation enabled', () {
setUp(() => setNavigationEnabled(true));
playerTest('Enter shows the chrome and toggles, leaving focus put', (tester) async {
await press(tester, LogicalKeyboardKey.enter);
expect(chrome.controlsVisible, isTrue);
expect(toggles, 1);
// Opting into player navigation buys arrow keys, not a focus session from
// a plain Enter: focus and input mode move together or not at all.
expect(focusLabel(), 'PlayerSurface');
expect(currentMode(tester), InputMode.pointer);
});
playerTest('ArrowUp raises the chrome onto Play/Pause', (tester) async {
await press(tester, LogicalKeyboardKey.arrowUp);
expect(chrome.controlsVisible, isTrue);
expect(focusLabel(), 'PlayPause');
expect(currentMode(tester), InputMode.keyboard);
});
playerTest('ArrowUp hands focus over when the chrome is already up', (tester) async {
chrome.show();
await tester.pumpAndSettle();
expect(focusLabel(), 'PlayerSurface', reason: 'precondition: nothing in the chrome owns focus');
await press(tester, LogicalKeyboardKey.arrowUp);
expect(focusLabel(), 'PlayPause', reason: 'the arrow must not be consumed into a dead end');
});
playerTest('a horizontal arrow hands focus over when the chrome is already up', (tester) async {
chrome.show();
await tester.pumpAndSettle();
expect(focusLabel(), 'PlayerSurface', reason: 'precondition: nothing in the chrome owns focus');
await press(tester, LogicalKeyboardKey.arrowRight);
// The surface only owns horizontals while the chrome is down, so this
// arrow promotes the app into keyboard mode; focus has to become visible
// with it or the two diverge.
expect(currentMode(tester), InputMode.keyboard);
expect(focusLabel(), 'PlayPause', reason: 'the arrow must not be consumed into a dead end');
});
playerTest('a horizontal arrow seeks under the chrome without arming focus', (tester) async {
await press(tester, LogicalKeyboardKey.arrowRight);
expect(currentMode(tester), InputMode.pointer, reason: 'a hidden-chrome seek is not navigation');
expect(chrome.controlsVisible, isFalse);
});
});
playerTest('the OSD focus entry point is raw mechanism, not policy', (tester) async {
await setNavigationEnabled(false);
chrome.show();
await tester.pumpAndSettle();
// Internal hand-offs — the skip-marker button's ArrowDown, an item swap —
// must keep working even with navigation off and no keyboard session.
tester.state<DesktopVideoControlsState>(find.byType(DesktopVideoControls)).requestPlayPauseFocus();
await tester.pumpAndSettle();
expect(focusLabel(), 'PlayPause');
});
// The player surface must own the remote from the moment it mounts, whatever
// the chrome is doing. When it does not, the screen node answers the first
// key with its chrome-raising self-heal instead of a playback shortcut.
group('startup hands the remote to the player surface', () {
setUp(() {
chrome.dispose();
chrome = PlayerChromeController(initiallyVisible: true);
});
playerTest('when the route opens with the chrome already up (desktop)', (tester) async {
expect(focusLabel(), 'PlayerSurface');
});
playerTest('and Enter there toggles playback instead of raising a self-heal', (tester) async {
await press(tester, LogicalKeyboardKey.enter);
expect(toggles, 1);
expect(focusLabel(), 'PlayerSurface');
expect(currentMode(tester), InputMode.pointer);
});
});
group('startup on a television', () {
setUp(() async {
TvDetectionService.debugSetAppleTVOverride(true);
PlatformDetector.debugSetIsDesktopOSOverride(false);
await setNavigationEnabled(true);
});
// A TV route opens with the chrome down, navigation on and keyboard mode
// already active — the exact combination that used to skip the claim.
playerTest('the surface owns the remote even though the chrome starts down', (tester) async {
expect(chrome.controlsVisible, isFalse);
expect(focusLabel(), 'PlayerSurface');
});
});
// Declining a skip prompt is the mirror of taking it. Back used to fall
// through to the screen's staged chain and exit the player outright, so the
// only key on a remote that means "no thanks" also ended the episode.
// Desktop, not TV, because Apple TV runs Back on key-down: the normal
// down-consume/up-act path is where the claim has to hold.
group('declining a skip prompt', () {
final introMarker = MediaMarker(id: 1, type: 'intro', startTimeOffset: 10000, endTimeOffset: 45000);
Future<void> showPrompt(WidgetTester tester) async {
player.emitPosition(const Duration(seconds: 15));
await tester.pumpAndSettle();
expect(find.byType(SkipMarkerButton), findsOneWidget, reason: 'precondition: the prompt is up');
}
playerTest('Back hides the prompt without skipping or exiting', markers: [introMarker], (tester) async {
await showPrompt(tester);
await press(tester, LogicalKeyboardKey.gameButtonB);
expect(find.byType(SkipMarkerButton), findsNothing, reason: 'the prompt is declined');
expect(player.state.position, const Duration(seconds: 15), reason: 'declining must not skip');
expect(focusLabel(), 'PlayerSurface', reason: 'the vanished button must hand the remote back');
expect(screenBackDispositions, isEmpty, reason: 'the press must not reach the exit chain');
});
playerTest('the next Back belongs to the screen again', markers: [introMarker], (tester) async {
await showPrompt(tester);
await press(tester, LogicalKeyboardKey.gameButtonB);
await press(tester, LogicalKeyboardKey.gameButtonB);
expect(screenBackDispositions, [PlayerBackDisposition.exitPlayer], reason: 'one press declines, the next leaves');
});
// The button's own 7s auto-dismiss is armed for every prompt while
// auto-skip is off, which is the default. handleBackKeyAction acts on the
// key-up, so a timer landing mid-press used to flip the gate and hand the
// press to the screen -- exiting the player on the very press meant to
// keep it open.
playerTest('a mid-press auto-dismiss cannot turn the press into an exit', markers: [introMarker], (tester) async {
await showPrompt(tester);
await tester.sendKeyDownEvent(LogicalKeyboardKey.gameButtonB);
await tester.pump();
await tester.pump(const Duration(seconds: 8));
expect(find.byType(SkipMarkerButton), findsNothing, reason: 'precondition: the timer fired mid-press');
await tester.sendKeyUpEvent(LogicalKeyboardKey.gameButtonB);
await tester.pumpAndSettle();
expect(screenBackDispositions, isEmpty, reason: 'the claim is latched for the whole press');
});
playerTest('physical Escape declines it too, having no fullscreen role here', markers: [introMarker], (
tester,
) async {
await showPrompt(tester);
await press(tester, LogicalKeyboardKey.escape);
expect(find.byType(SkipMarkerButton), findsNothing);
expect(screenBackDispositions, isEmpty, reason: 'Escape must not exit the player either');
});
playerTest('a screen-level prompt keeps its own Back', markers: [introMarker], playbackPromptOpen: true, (
tester,
) async {
await showPrompt(tester);
await press(tester, LogicalKeyboardKey.gameButtonB);
expect(find.byType(SkipMarkerButton), findsOneWidget, reason: 'the prompt is not ours to answer');
expect(screenBackKeyDowns, 1, reason: 'the press is never claimed, not merely handed back');
expect(screenBackDispositions, isNotEmpty, reason: 'the prompt stage lives on the screen');
});
playerTest('a viewer who cannot skip keeps their exit press', markers: [introMarker], canControl: false, (
tester,
) async {
await showPrompt(tester);
await press(tester, LogicalKeyboardKey.gameButtonB);
expect(find.byType(SkipMarkerButton), findsOneWidget, reason: 'the button is inert, not dismissable');
expect(screenBackDispositions, [PlayerBackDisposition.exitPlayer]);
});
playerTest('the chrome owns a press that starts under it', markers: [introMarker], (tester) async {
await showPrompt(tester);
chrome.show();
await tester.pumpAndSettle();
await press(tester, LogicalKeyboardKey.gameButtonB);
expect(screenBackKeyDowns, 1, reason: 'the press is never claimed, not merely handed back');
expect(screenBackDispositions, [PlayerBackDisposition.hideControls]);
});
// The gesture the feature exists for: "no thanks" while the countdown runs.
playerTest('declining stops a running auto-skip countdown', markers: [introMarker], (tester) async {
await settings.write(SettingsService.autoSkipIntro, true);
await showPrompt(tester);
await press(tester, LogicalKeyboardKey.gameButtonB);
await tester.pump(const Duration(seconds: 10));
expect(player.state.position, const Duration(seconds: 15), reason: 'a declined countdown must not fire');
expect(screenBackDispositions, isEmpty);
});
// The latch covers the button vanishing mid-press, not the chrome rising.
// With the OSD up Back belongs to the chrome, so a press that starts under
// a bare prompt and ends under the OSD has to be handed back.
playerTest('the claim is released when the chrome comes up mid-press', markers: [introMarker], (tester) async {
await showPrompt(tester);
await tester.sendKeyDownEvent(LogicalKeyboardKey.gameButtonB);
await tester.pump();
chrome.show();
await tester.pumpAndSettle();
await tester.sendKeyUpEvent(LogicalKeyboardKey.gameButtonB);
await tester.pumpAndSettle();
expect(screenBackDispositions, [
PlayerBackDisposition.hideControls,
], reason: 'with the chrome up the press is the chrome to stage');
});
playerTest('a phone Back stays unconditional', markers: [introMarker], platform: TargetPlatform.android, (
tester,
) async {
await showPrompt(tester);
await press(tester, LogicalKeyboardKey.gameButtonB);
expect(find.byType(SkipMarkerButton), findsOneWidget, reason: 'the prompt does not own a phone Back');
expect(screenBackDispositions, [PlayerBackDisposition.exitPlayer], reason: '#1938: mobile Back exits, always');
});
});
// Android TV is the platform the report came from, and it is materially
// different: the marker autofocuses the button, so the key is dispatched
// from the button's node upward rather than from the surface. The forced-TV
// override is the only way to get a non-Apple TV, since the Apple override
// sets both isTV and isAppleTV.
group('declining a skip prompt on Android TV', () {
setUp(() async {
TvDetectionService.debugSetAppleTVOverride(null);
await TvDetectionService.getInstance(forceTv: true);
TvDetectionService.setForceTVSync(true);
PlatformDetector.debugSetIsDesktopOSOverride(false);
await setNavigationEnabled(true);
});
playerTest(
'Back declines the focused prompt and hands the remote back',
markers: [MediaMarker(id: 1, type: 'intro', startTimeOffset: 10000, endTimeOffset: 45000)],
(tester) async {
InputModeTracker.reportNonPointerInput();
player.emitPosition(const Duration(seconds: 15));
await tester.pumpAndSettle();
expect(focusLabel(), 'SkipMarkerButton', reason: 'precondition: TV autofocuses the sole affordance');
await press(tester, LogicalKeyboardKey.gameButtonB);
expect(find.byType(SkipMarkerButton), findsNothing);
expect(player.state.position, const Duration(seconds: 15), reason: 'declining must not skip');
expect(focusLabel(), 'PlayerSurface', reason: 'the vanished button must hand the remote back');
expect(screenBackKeyDowns, 0, reason: 'the prompt answered the press');
expect(screenBackDispositions, isEmpty);
},
);
});
group('skip intro on a television', () {
setUp(() async {
TvDetectionService.debugSetAppleTVOverride(true);
PlatformDetector.debugSetIsDesktopOSOverride(false);
await setNavigationEnabled(true);
});
// Selecting Skip Intro removes the focused button from the tree. The
// remote must land back on the player surface — otherwise focus falls out
// of the controls subtree, the screen node reclaims it, and its self-heal
// turns the next Select into a chrome-raise, so pausing takes two presses
// (#1890).
playerTest(
'Select toggles playback on the first press after skipping the intro',
markers: [MediaMarker(id: 1, type: 'intro', startTimeOffset: 10000, endTimeOffset: 45000)],
(tester) async {
// A remote is non-pointer input, so the app is already in keyboard
// mode when the marker appears — the combination that autofocuses the
// skip button on TV.
InputModeTracker.reportNonPointerInput();
player.emitPosition(const Duration(seconds: 15));
await tester.pumpAndSettle();
expect(focusLabel(), 'SkipMarkerButton', reason: 'precondition: TV autofocuses the sole affordance');
await press(tester, LogicalKeyboardKey.select);
expect(player.state.position, const Duration(seconds: 45), reason: 'Select activated the skip');
expect(focusLabel(), 'PlayerSurface', reason: 'the vanished button must hand the remote back');
await press(tester, LogicalKeyboardKey.select);
expect(toggles, 1, reason: 'one press must toggle playback, not merely raise the chrome');
},
);
});
}
/// Minimal [Player] reporting steady playback; transport is routed to the
/// widget's `onPlayPauseRequested` callback so the test can count toggles.
class _TogglePlayer implements Player {
Duration _position = const Duration(minutes: 5);
final StreamController<Duration> _positionController = StreamController<Duration>.broadcast();
/// Moves the playhead and announces it on the position stream, the way a
/// real player drives marker detection.
void emitPosition(Duration position) {
_position = position;
_positionController.add(position);
}
Future<void> close() => _positionController.close();
@override
String get playerType => 'mpv';
@override
PlayerState get state =>
PlayerState(playing: true, position: _position, duration: const Duration(minutes: 45), seekable: true);
/// A real player announces the new playhead right after a seek; marker
/// detection (and its focus handling) rides on that event.
@override
Future<void> seek(Duration position) async => emitPosition(position);
@override
PlayerStreams get streams => PlayerStreams(
playing: const Stream<bool>.empty(),
completed: const Stream<bool>.empty(),
buffering: const Stream<bool>.empty(),
position: _positionController.stream,
duration: const Stream<Duration>.empty(),
seekable: const Stream<bool>.empty(),
buffer: const Stream<Duration>.empty(),
volume: const Stream<double>.empty(),
rate: const Stream<double>.empty(),
tracks: const Stream<Tracks>.empty(),
track: const Stream<TrackSelection>.empty(),
log: const Stream<PlayerLog>.empty(),
error: const Stream<PlayerError>.empty(),
audioDevice: const Stream<AudioDevice>.empty(),
audioDevices: const Stream<List<AudioDevice>>.empty(),
bufferRanges: const Stream<List<BufferRange>>.empty(),
playbackRestart: const Stream<void>.empty(),
backendSwitched: const Stream<void>.empty(),
);
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}