fix(video): stabilize player chrome overlays

This commit is contained in:
edde746
2026-06-02 14:20:24 +02:00
parent 985c279b1f
commit 24b5080c38
18 changed files with 1233 additions and 521 deletions
@@ -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);
});
});
}
+64
View File
@@ -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);