fix(video): stabilize player chrome overlays
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
import 'dart:ui' show PointerDeviceKind;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/media/media_backend.dart';
|
||||
import 'package:plezy/media/media_item.dart';
|
||||
import 'package:plezy/media/media_kind.dart';
|
||||
import 'package:plezy/providers/playback_state_provider.dart';
|
||||
import 'package:plezy/screens/video_player/widgets/player_prompt_overlays.dart';
|
||||
import 'package:plezy/services/pip_service.dart';
|
||||
import 'package:plezy/widgets/video_controls/player_chrome_controller.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('play next prompt tracks chrome visibility for vertical position', (tester) async {
|
||||
PipService().isPipActive.value = false;
|
||||
final chromeController = PlayerChromeController();
|
||||
final cancelFocusNode = FocusNode(debugLabel: 'TestCancel');
|
||||
final confirmFocusNode = FocusNode(debugLabel: 'TestConfirm');
|
||||
addTearDown(chromeController.dispose);
|
||||
addTearDown(cancelFocusNode.dispose);
|
||||
addTearDown(confirmFocusNode.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
_wrapPrompt(
|
||||
VideoPlayerPlayNextOverlay(
|
||||
visible: true,
|
||||
nextEpisode: _episode(),
|
||||
autoPlayCountdown: -1,
|
||||
cancelFocusNode: cancelFocusNode,
|
||||
confirmFocusNode: confirmFocusNode,
|
||||
chromeController: chromeController,
|
||||
onCancel: () {},
|
||||
onPlayNext: () {},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(_promptPosition(tester).bottom, 100);
|
||||
|
||||
chromeController.hide();
|
||||
await tester.pump();
|
||||
expect(_promptPosition(tester).bottom, 24);
|
||||
});
|
||||
|
||||
testWidgets('hovering play next prompt holds chrome visible and stable', (tester) async {
|
||||
PipService().isPipActive.value = false;
|
||||
final chromeController = PlayerChromeController();
|
||||
final cancelFocusNode = FocusNode(debugLabel: 'TestCancel');
|
||||
final confirmFocusNode = FocusNode(debugLabel: 'TestConfirm');
|
||||
addTearDown(chromeController.dispose);
|
||||
addTearDown(cancelFocusNode.dispose);
|
||||
addTearDown(confirmFocusNode.dispose);
|
||||
|
||||
chromeController.hide();
|
||||
|
||||
await tester.pumpWidget(
|
||||
_wrapPrompt(
|
||||
VideoPlayerPlayNextOverlay(
|
||||
visible: true,
|
||||
nextEpisode: _episode(),
|
||||
autoPlayCountdown: -1,
|
||||
cancelFocusNode: cancelFocusNode,
|
||||
confirmFocusNode: confirmFocusNode,
|
||||
chromeController: chromeController,
|
||||
onCancel: () {},
|
||||
onPlayNext: () {},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(_promptPosition(tester).bottom, 24);
|
||||
|
||||
final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse);
|
||||
addTearDown(mouse.removePointer);
|
||||
await mouse.addPointer(location: tester.getCenter(find.text('Cancel')));
|
||||
await tester.pump();
|
||||
|
||||
expect(chromeController.controlsVisible, isTrue);
|
||||
expect(chromeController.isHeld(PlayerChromeHold.promptInteraction), isTrue);
|
||||
expect(_promptPosition(tester).bottom, 100);
|
||||
expect(chromeController.hide(), isFalse);
|
||||
expect(_promptPosition(tester).bottom, 100);
|
||||
});
|
||||
|
||||
testWidgets('focused play next prompt holds chrome visible', (tester) async {
|
||||
PipService().isPipActive.value = false;
|
||||
final chromeController = PlayerChromeController();
|
||||
final cancelFocusNode = FocusNode(debugLabel: 'TestCancel');
|
||||
final confirmFocusNode = FocusNode(debugLabel: 'TestConfirm');
|
||||
addTearDown(chromeController.dispose);
|
||||
addTearDown(cancelFocusNode.dispose);
|
||||
addTearDown(confirmFocusNode.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
_wrapPrompt(
|
||||
VideoPlayerPlayNextOverlay(
|
||||
visible: true,
|
||||
nextEpisode: _episode(),
|
||||
autoPlayCountdown: -1,
|
||||
cancelFocusNode: cancelFocusNode,
|
||||
confirmFocusNode: confirmFocusNode,
|
||||
chromeController: chromeController,
|
||||
onCancel: () {},
|
||||
onPlayNext: () {},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
confirmFocusNode.requestFocus();
|
||||
await tester.pump();
|
||||
|
||||
expect(chromeController.isHeld(PlayerChromeHold.promptInteraction), isTrue);
|
||||
expect(chromeController.hide(), isFalse);
|
||||
});
|
||||
|
||||
testWidgets('removing a held prompt releases hold without notifying during dispose', (tester) async {
|
||||
PipService().isPipActive.value = false;
|
||||
final chromeController = PlayerChromeController();
|
||||
final cancelFocusNode = FocusNode(debugLabel: 'TestCancel');
|
||||
final confirmFocusNode = FocusNode(debugLabel: 'TestConfirm');
|
||||
addTearDown(chromeController.dispose);
|
||||
addTearDown(cancelFocusNode.dispose);
|
||||
addTearDown(confirmFocusNode.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
_wrapPrompt(
|
||||
VideoPlayerPlayNextOverlay(
|
||||
visible: true,
|
||||
nextEpisode: _episode(),
|
||||
autoPlayCountdown: -1,
|
||||
cancelFocusNode: cancelFocusNode,
|
||||
confirmFocusNode: confirmFocusNode,
|
||||
chromeController: chromeController,
|
||||
onCancel: () {},
|
||||
onPlayNext: () {},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
chromeController.hold(PlayerChromeHold.promptInteraction);
|
||||
var notifications = 0;
|
||||
chromeController.addListener(() => notifications++);
|
||||
|
||||
await tester.pumpWidget(_wrapPrompt(const SizedBox.shrink()));
|
||||
|
||||
expect(chromeController.isHeld(PlayerChromeHold.promptInteraction), isFalse);
|
||||
expect(notifications, 0);
|
||||
});
|
||||
}
|
||||
|
||||
Widget _wrapPrompt(Widget child) {
|
||||
return ChangeNotifierProvider(
|
||||
create: (_) => PlaybackStateProvider(),
|
||||
child: MaterialApp(
|
||||
home: Scaffold(body: Stack(children: [child])),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
AnimatedPositioned _promptPosition(WidgetTester tester) {
|
||||
return tester.widget<AnimatedPositioned>(find.byType(AnimatedPositioned));
|
||||
}
|
||||
|
||||
MediaItem _episode() {
|
||||
return MediaItem(
|
||||
id: 'episode-2',
|
||||
backend: MediaBackend.plex,
|
||||
kind: MediaKind.episode,
|
||||
title: 'Episode 2',
|
||||
parentIndex: 1,
|
||||
index: 2,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import 'dart:ui' show PointerDeviceKind;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/widgets/video_controls/player_chrome_controller.dart';
|
||||
|
||||
void main() {
|
||||
group('PlayerChromeController', () {
|
||||
testWidgets('auto-hides visible controls while playing', (tester) async {
|
||||
final controller = PlayerChromeController();
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
controller.configure(hideDelay: const Duration(milliseconds: 100));
|
||||
controller.setPlaying(true);
|
||||
|
||||
expect(controller.controlsVisible, isTrue);
|
||||
await tester.pump(const Duration(milliseconds: 99));
|
||||
expect(controller.controlsVisible, isTrue);
|
||||
await tester.pump(const Duration(milliseconds: 1));
|
||||
expect(controller.controlsVisible, isFalse);
|
||||
});
|
||||
|
||||
testWidgets('visible holds suppress auto-hide until released', (tester) async {
|
||||
final controller = PlayerChromeController();
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
controller.configure(hideDelay: const Duration(milliseconds: 100));
|
||||
controller.setPlaying(true);
|
||||
controller.hold(PlayerChromeHold.promptInteraction);
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
expect(controller.controlsVisible, isTrue);
|
||||
|
||||
controller.release(PlayerChromeHold.promptInteraction);
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
expect(controller.controlsVisible, isFalse);
|
||||
});
|
||||
|
||||
testWidgets('releasing a hold while paused restarts paused auto-hide', (tester) async {
|
||||
final controller = PlayerChromeController();
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
controller.configure(hideDelay: const Duration(milliseconds: 100));
|
||||
controller.setPlaying(true);
|
||||
controller.setPlaying(false);
|
||||
controller.hold(PlayerChromeHold.promptInteraction);
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
expect(controller.controlsVisible, isTrue);
|
||||
|
||||
controller.release(PlayerChromeHold.promptInteraction);
|
||||
await tester.pump(const Duration(milliseconds: 99));
|
||||
expect(controller.controlsVisible, isTrue);
|
||||
await tester.pump(const Duration(milliseconds: 1));
|
||||
expect(controller.controlsVisible, isFalse);
|
||||
});
|
||||
|
||||
testWidgets('changing hide delay restarts paused auto-hide timer', (tester) async {
|
||||
final controller = PlayerChromeController();
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
controller.configure(hideDelay: const Duration(milliseconds: 200));
|
||||
controller.setPlaying(true);
|
||||
controller.setPlaying(false);
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
controller.configure(hideDelay: const Duration(milliseconds: 300));
|
||||
await tester.pump(const Duration(milliseconds: 299));
|
||||
expect(controller.controlsVisible, isTrue);
|
||||
await tester.pump(const Duration(milliseconds: 1));
|
||||
expect(controller.controlsVisible, isFalse);
|
||||
});
|
||||
|
||||
test('show stores focus target and notifies even when already visible', () {
|
||||
final controller = PlayerChromeController();
|
||||
addTearDown(controller.dispose);
|
||||
var notifications = 0;
|
||||
controller.addListener(() => notifications++);
|
||||
|
||||
controller.show(focusTarget: PlayerChromeFocusTarget.playPause);
|
||||
|
||||
expect(notifications, 1);
|
||||
expect(controller.pendingFocusTarget, PlayerChromeFocusTarget.playPause);
|
||||
expect(controller.takeFocusTarget(), PlayerChromeFocusTarget.playPause);
|
||||
expect(controller.takeFocusTarget(), isNull);
|
||||
});
|
||||
|
||||
test('silent release removes hold without notifying listeners', () {
|
||||
final controller = PlayerChromeController();
|
||||
addTearDown(controller.dispose);
|
||||
controller.hold(PlayerChromeHold.promptInteraction);
|
||||
var notifications = 0;
|
||||
controller.addListener(() => notifications++);
|
||||
|
||||
controller.release(PlayerChromeHold.promptInteraction, notify: false, restartAutoHide: false);
|
||||
|
||||
expect(controller.isHeld(PlayerChromeHold.promptInteraction), isFalse);
|
||||
expect(notifications, 0);
|
||||
});
|
||||
|
||||
testWidgets('interaction region shows on hover and hides on exit', (tester) async {
|
||||
final controller = PlayerChromeController();
|
||||
addTearDown(controller.dispose);
|
||||
controller.hide();
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: SizedBox(
|
||||
width: 200,
|
||||
height: 200,
|
||||
child: PlayerChromeInteractionRegion(
|
||||
controller: controller,
|
||||
hideOnExit: true,
|
||||
child: const ColoredBox(color: Colors.black),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse);
|
||||
addTearDown(mouse.removePointer);
|
||||
await mouse.addPointer(location: const Offset(250, 250));
|
||||
await tester.pump();
|
||||
await mouse.moveTo(const Offset(20, 20));
|
||||
await tester.pump();
|
||||
expect(controller.controlsVisible, isTrue);
|
||||
|
||||
await mouse.moveTo(const Offset(250, 250));
|
||||
await tester.pump();
|
||||
expect(controller.controlsVisible, isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -118,6 +118,70 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('shouldShowSkipMarkerButton', () {
|
||||
test('does not show before the first frame is rendered', () {
|
||||
expect(
|
||||
shouldShowSkipMarkerButton(
|
||||
hasFirstFrame: false,
|
||||
hasMarker: true,
|
||||
hasPlayNextPrompt: false,
|
||||
skipButtonDismissed: false,
|
||||
controlsVisible: true,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('shows after first frame when marker is active and not dismissed', () {
|
||||
expect(
|
||||
shouldShowSkipMarkerButton(
|
||||
hasFirstFrame: true,
|
||||
hasMarker: true,
|
||||
hasPlayNextPrompt: false,
|
||||
skipButtonDismissed: false,
|
||||
controlsVisible: false,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('does not show when dismissed until controls are visible again', () {
|
||||
expect(
|
||||
shouldShowSkipMarkerButton(
|
||||
hasFirstFrame: true,
|
||||
hasMarker: true,
|
||||
hasPlayNextPrompt: false,
|
||||
skipButtonDismissed: true,
|
||||
controlsVisible: false,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
expect(
|
||||
shouldShowSkipMarkerButton(
|
||||
hasFirstFrame: true,
|
||||
hasMarker: true,
|
||||
hasPlayNextPrompt: false,
|
||||
skipButtonDismissed: true,
|
||||
controlsVisible: true,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('does not show while play next prompt is active', () {
|
||||
expect(
|
||||
shouldShowSkipMarkerButton(
|
||||
hasFirstFrame: true,
|
||||
hasMarker: true,
|
||||
hasPlayNextPrompt: true,
|
||||
skipButtonDismissed: false,
|
||||
controlsVisible: true,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('mobileSkipZoneForTap', () {
|
||||
const size = Size(1000, 600);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user