Files
sync_replay_project/apps/desktop/test/uosc_controls_test.dart
T
foxyzandClaude Opus 5.5 165cd2a7f9 Experimental mpv-style (uosc) controls behind a Settings toggle
Thick timeline with hatched cache range, chapter diamonds and
chapter/time hover boxes; collapses to a thin progress line when
hidden. Flat icon row with track count badges, shader preset badge,
flat dark menus. Own show/hide state, live switch in window and
fullscreen. New buffered signal from demuxer-cache-time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 03:10:17 +03:00

470 lines
22 KiB
Dart

import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart' show JellyfinStreamType;
import 'package:watch_together/src/controls/uosc/buttons.dart';
import 'package:watch_together/src/controls/uosc/controls.dart';
import 'package:watch_together/src/controls/uosc/menu.dart';
import 'package:watch_together/src/controls/uosc/style.dart';
import 'package:watch_together/src/controls/uosc/timeline.dart';
import 'package:watch_together/src/jellyfin/track_prefs.dart';
import 'package:watch_together/src/playback/media_marks.dart';
import 'package:watch_together/src/playback/playback_controller.dart' show RenderMode;
import 'package:watch_together/src/screens/home/settings_page.dart';
import 'package:watch_together/src/screens/session/player_controls.dart' show TrackPick;
import 'package:watch_together/src/shaders/shader_presets.dart';
import 'package:watch_together/src/state/settings.dart';
import 'package:watch_together/src/update/update_controller.dart';
// The experimental mpv-style (uosc) controls, piece by piece (never the
// session screen or a media_kit `Video`).
Duration m(int min, [int sec = 0]) => Duration(minutes: min, seconds: sec);
final chapters = chaptersFromStarts([
('Prologue', Duration.zero),
('Opening', m(0, 30)),
('Part A', m(2)),
('Ending', m(20)),
('Part B', m(21, 30)),
], m(24));
Widget host(Widget child, {double width = 960}) => MaterialApp(
home: Scaffold(
backgroundColor: Colors.black,
body: Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: const EdgeInsets.only(bottom: 40),
child: SizedBox(width: width, child: child),
),
),
),
);
String boxText(WidgetTester tester, String key) => tester
.widget<Text>(find.descendant(of: find.byKey(ValueKey(key)), matching: find.byType(Text)))
.data!;
UoscTimelinePainter painter(WidgetTester tester) => tester
.widget<CustomPaint>(find.descendant(
of: find.byKey(const ValueKey('uosc-timeline')), matching: find.byType(CustomPaint)))
.painter! as UoscTimelinePainter;
Future<TestGesture> mouseAt(WidgetTester tester, Offset at) async {
final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse);
await mouse.addPointer(location: at);
addTearDown(mouse.removePointer);
return mouse;
}
const tracks = [
MpvTrack(id: 1, type: JellyfinStreamType.audio, language: 'jpn', codec: 'eac3', channels: 2, selected: true),
MpvTrack(id: 2, type: JellyfinStreamType.audio, language: 'eng', codec: 'aac', channels: 6, title: 'Dub'),
MpvTrack(id: 1, type: JellyfinStreamType.subtitle, language: 'eng', codec: 'ass', title: 'Full'),
MpvTrack(id: 2, type: JellyfinStreamType.subtitle, language: 'eng', codec: 'ass', title: 'Signs'),
];
void main() {
test('uosc clock: mm:ss, hh:mm:ss from an hour', () {
expect(formatUoscTime(Duration.zero), '00:00');
expect(formatUoscTime(m(1, 51)), '01:51');
expect(formatUoscTime(m(1, 51), withHours: true), '00:01:51');
expect(formatUoscTime(const Duration(hours: 1, minutes: 2, seconds: 3)), '01:02:03');
expect(formatUoscTime(const Duration(seconds: -4)), '00:00');
});
group('cache hatching math', () {
test('cached range runs from the position to the cache end', () {
const size = Size(1000, 30);
final r = cacheRect(size, position: m(6), buffered: m(9), duration: m(24))!;
expect(r.left, 250);
expect(r.right, 375);
expect(r.top, 0);
expect(r.bottom, 30);
expect(cacheRect(size, position: m(6), buffered: m(6), duration: m(24)), isNull);
expect(cacheRect(size, position: m(6), buffered: m(5), duration: m(24)), isNull, reason: 'behind');
expect(cacheRect(size, position: m(6), buffered: m(9), duration: Duration.zero), isNull);
// Clamped to the bar.
expect(cacheRect(size, position: m(20), buffered: m(40), duration: m(24))!.right, 1000);
});
test('45° strokes, evenly spaced, covering the whole range', () {
const r = Rect.fromLTRB(100, 0, 160, 30);
final lines = hatchLines(r, spacing: 6);
for (final (a, b) in lines) {
expect(a.dy, r.bottom);
expect(b.dy, r.top);
expect(b.dx - a.dx, r.height, reason: '45°');
// Every stroke crosses the range.
expect(b.dx, greaterThan(r.left));
expect(a.dx, lessThan(r.right));
}
for (var i = 1; i < lines.length; i++) {
expect(lines[i].$1.dx - lines[i - 1].$1.dx, 6);
}
// No gap at either edge: the first stroke starts left of the range's
// bottom-left corner diagonal, the last one within a step of the end.
expect(lines.first.$1.dx, lessThanOrEqualTo(r.left - r.height + 6));
expect(lines.last.$1.dx, greaterThan(r.right - 6));
expect(hatchLines(Rect.zero), isEmpty);
});
});
group('timeline', () {
testWidgets('elapsed part filled, time boxes inside the bar', (tester) async {
await tester.pumpWidget(host(UoscTimeline(
position: m(6), duration: m(24), buffered: m(8), chapters: chapters, onSeek: (_) {})));
final bar = tester.getRect(find.byKey(const ValueKey('uosc-timeline')));
expect(bar.width, 800, reason: 'the whole width (the test window)');
expect(bar.height, UoscTimeline.height);
final p = painter(tester);
expect(p.played, 0.25);
expect(p.hatched, isTrue);
expect(p.cache(bar.size), cacheRect(bar.size, position: m(6), buffered: m(8), duration: m(24)));
expect(boxText(tester, 'uosc-time'), '06:00');
expect(boxText(tester, 'uosc-duration'), '24:00');
final time = tester.getRect(find.byKey(const ValueKey('uosc-time')));
expect(time.left, greaterThanOrEqualTo(bar.left));
expect(time.top, greaterThanOrEqualTo(bar.top));
expect(time.bottom, lessThanOrEqualTo(bar.bottom));
expect(time.center.dx, lessThan(bar.left + 80), reason: 'at the left');
});
testWidgets('a diamond on the top edge at each chapter start but the first', (tester) async {
await tester.pumpWidget(host(UoscTimeline(
position: m(1), duration: m(24), chapters: chapters, onSeek: (_) {})));
expect(find.byKey(const ValueKey('uosc-chapter-0')), findsNothing, reason: 'starts at 0');
final bar = tester.getRect(find.byKey(const ValueKey('uosc-timeline')));
for (final c in chapters.skip(1)) {
final mark = tester.getCenter(find.byKey(ValueKey('uosc-chapter-${c.index}')));
expect(mark.dx, moreOrLessEquals(bar.left + bar.width * c.start.inSeconds / m(24).inSeconds, epsilon: 0.5),
reason: c.title);
expect(mark.dy, moreOrLessEquals(bar.top, epsilon: 0.5), reason: 'on the top edge');
}
});
testWidgets('hover: chapter title over the hover time, above the bar', (tester) async {
await tester.pumpWidget(host(UoscTimeline(
position: m(1), duration: m(24), chapters: chapters, onSeek: (_) {})));
final bar = tester.getRect(find.byKey(const ValueKey('uosc-timeline')));
final mouse = await mouseAt(tester, Offset.zero);
// 01:51 of 24:00: inside "Opening".
await mouse.moveTo(Offset(bar.left + bar.width * (111 / (24 * 60)), bar.center.dy));
await tester.pump();
expect(boxText(tester, 'uosc-hover-chapter'), 'Opening');
expect(boxText(tester, 'uosc-hover-time'), '01:51');
final title = tester.getRect(find.byKey(const ValueKey('uosc-hover-chapter')));
final time = tester.getRect(find.byKey(const ValueKey('uosc-hover-time')));
expect(title.bottom, lessThanOrEqualTo(time.top), reason: 'two boxes, title above');
expect(time.bottom, lessThan(bar.top - 4), reason: 'clear of the diamonds');
expect(painter(tester).hoverX, isNotNull);
await mouse.moveTo(Offset(bar.left + bar.width * (10 / 24), bar.center.dy));
await tester.pump();
expect(boxText(tester, 'uosc-hover-chapter'), 'Part A');
await mouse.moveTo(Offset(bar.left, bar.top - 200));
await tester.pump();
expect(find.byKey(const ValueKey('uosc-hover-time')), findsNothing);
});
testWidgets('hidden controls: a thin line with chapter ticks, no boxes, no input', (tester) async {
final seeks = <Duration>[];
await tester.pumpWidget(host(UoscTimeline(
position: m(12), duration: m(24), buffered: m(14), chapters: chapters, expanded: false, onSeek: seeks.add)));
final bar = tester.getRect(find.byKey(const ValueKey('uosc-timeline')));
expect(bar.height, UoscTimeline.collapsedHeight);
expect(painter(tester).played, 0.5);
expect(painter(tester).hatched, isFalse);
expect(find.byKey(const ValueKey('uosc-chapter-1')), findsNothing);
for (final c in chapters.skip(1)) {
final tick = tester.getRect(find.byKey(ValueKey('uosc-chapter-tick-${c.index}')));
expect(tick.center.dx, moreOrLessEquals(bar.left + bar.width * c.start.inSeconds / m(24).inSeconds, epsilon: 0.5));
expect(tick.width, lessThanOrEqualTo(2));
}
expect(find.byKey(const ValueKey('uosc-time')), findsNothing);
final mouse = await mouseAt(tester, Offset.zero);
await mouse.moveTo(bar.center);
await tester.pump();
expect(find.byKey(const ValueKey('uosc-hover-time')), findsNothing);
await tester.tapAt(bar.center);
expect(seeks, isEmpty);
});
testWidgets('drag seeks on every move and on release', (tester) async {
final seeks = <Duration>[];
await tester.pumpWidget(host(UoscTimeline(
position: m(1), duration: m(24), chapters: chapters, onSeek: seeks.add)));
final bar = tester.getRect(find.byKey(const ValueKey('uosc-timeline')));
final g = await tester.startGesture(Offset(bar.left + bar.width / 4, bar.center.dy), kind: PointerDeviceKind.mouse);
expect(seeks, isEmpty, reason: 'press alone does not seek');
await g.moveTo(Offset(bar.left + bar.width / 2, bar.center.dy));
await tester.pump();
expect(boxText(tester, 'uosc-time'), '12:00', reason: 'follows the drag');
await g.moveTo(Offset(bar.left + bar.width * 3 / 4, bar.center.dy));
await g.up();
expect(seeks, [m(12), m(18), m(18)]);
});
});
group('controls frame', () {
Widget frame({List<Widget> left = const [], Duration hideAfter = const Duration(seconds: 3)}) => MaterialApp(
home: Scaffold(
backgroundColor: Colors.black,
body: UoscControls(
hideAfter: hideAfter,
left: left,
right: const [UoscButton(key: ValueKey('right'), icon: Icons.fullscreen, tooltip: 'Fullscreen', onPressed: null)],
timeline: (context, expanded) => UoscTimeline(
position: m(6), duration: m(24), chapters: chapters, expanded: expanded, onSeek: (_) {}),
),
),
);
double buttonsOpacity(WidgetTester tester) => tester
.widget<AnimatedOpacity>(find.descendant(
of: find.byKey(const ValueKey('uosc-buttons')), matching: find.byType(AnimatedOpacity)).first)
.opacity;
testWidgets('buttons above a full-width timeline; hide after the delay, mouse brings them back',
(tester) async {
await tester.pumpWidget(frame());
final screen = tester.getSize(find.byType(UoscControls));
final bar = tester.getRect(find.byKey(const ValueKey('uosc-timeline')));
expect(bar.width, screen.width);
expect(bar.bottom, screen.height);
expect(tester.getRect(find.byKey(const ValueKey('right'))).bottom, lessThanOrEqualTo(bar.top));
expect(buttonsOpacity(tester), 1);
await tester.pump(const Duration(seconds: 4));
await tester.pumpAndSettle();
expect(buttonsOpacity(tester), 0);
expect(tester.getRect(find.byKey(const ValueKey('uosc-timeline'))).height, UoscTimeline.collapsedHeight,
reason: 'still visible as a thin line');
final mouse = await mouseAt(tester, const Offset(400, 200));
await mouse.moveTo(const Offset(420, 220));
await tester.pump();
expect(buttonsOpacity(tester), 1);
expect(tester.getRect(find.byKey(const ValueKey('uosc-timeline'))).height, UoscTimeline.height);
});
testWidgets('a long drag keeps the controls up', (tester) async {
await tester.pumpWidget(frame());
final bar = tester.getRect(find.byKey(const ValueKey('uosc-timeline')));
final g = await tester.startGesture(Offset(bar.left + 100, bar.center.dy), kind: PointerDeviceKind.mouse);
for (var i = 0; i < 10; i++) {
await tester.pump(const Duration(seconds: 1));
}
await g.moveTo(Offset(bar.left + 300, bar.center.dy));
await tester.pump(const Duration(seconds: 5));
expect(buttonsOpacity(tester), 1, reason: 'held while dragging');
expect(tester.getRect(find.byKey(const ValueKey('uosc-timeline'))).height, UoscTimeline.height);
await g.up();
await g.removePointer();
await tester.pump(const Duration(seconds: 4));
await tester.pumpAndSettle();
expect(buttonsOpacity(tester), 0, reason: 'hides again after release');
});
testWidgets('an open menu keeps the controls up', (tester) async {
await tester.pumpWidget(frame(left: [
UoscTracksButton(type: JellyfinStreamType.audio, tracks: tracks, onPick: (_) {}),
]));
await tester.tap(find.byKey(const ValueKey('uosc-audio')));
await tester.pump();
expect(find.byType(UoscMenuPanel<TrackPick>), findsOneWidget);
await tester.pump(const Duration(seconds: 10));
expect(buttonsOpacity(tester), 1);
await tester.tapAt(const Offset(5, 5)); // outside: dismiss
await tester.pump();
expect(find.byType(UoscMenuPanel<TrackPick>), findsNothing);
await tester.pump(const Duration(seconds: 4));
await tester.pumpAndSettle();
expect(buttonsOpacity(tester), 0);
});
});
group('buttons', () {
testWidgets('track buttons carry the count; dark tooltip on hover', (tester) async {
await tester.pumpWidget(host(Row(children: [
UoscTracksButton(type: JellyfinStreamType.subtitle, tracks: tracks, onPick: (_) {}),
UoscTracksButton(type: JellyfinStreamType.audio, tracks: tracks.take(1).toList(), onPick: (_) {}),
const UoscButton(key: ValueKey('plain'), icon: Icons.menu, tooltip: 'Menu', onPressed: null),
])));
String badge(String key) => tester
.widget<Text>(find.descendant(
of: find.descendant(of: find.byKey(ValueKey(key)), matching: find.byKey(const ValueKey('uosc-badge'))),
matching: find.byType(Text)))
.data!;
expect(badge('uosc-subtitles'), '2');
expect(badge('uosc-audio'), '1');
expect(find.descendant(of: find.byKey(const ValueKey('plain')), matching: find.byKey(const ValueKey('uosc-badge'))),
findsNothing);
final mouse = await mouseAt(tester, Offset.zero);
await mouse.moveTo(tester.getCenter(find.byKey(const ValueKey('uosc-subtitles'))));
await tester.pump(const Duration(milliseconds: 400));
await tester.pumpAndSettle();
expect(find.text('Subtitles'), findsOneWidget);
final tip = tester.widget<Tooltip>(find.descendant(of: find.byKey(const ValueKey('uosc-subtitles')), matching: find.byType(Tooltip)).first);
expect((tip.decoration as BoxDecoration).color, UoscStyle.panel);
});
testWidgets('shader badge shows the preset, dimmed "4K" when off', (tester) async {
final s = AppSettings.instance;
s.shaderPreset.value = ShaderPreset.off;
await tester.pumpWidget(host(Row(children: [
UoscShaderButton(renderMode: signal(RenderMode.gpu), onPick: (_, _) {}),
])));
expect(find.text('4K'), findsOneWidget);
expect(tester.widget<UoscBadgeButton>(find.byType(UoscBadgeButton)).active, isFalse);
s.shaderPreset.value = ShaderPreset.ca;
s.shaderQuality.value = ShaderQuality.fast;
await tester.pump();
expect(find.text('C+A Fast'), findsOneWidget);
expect(tester.widget<UoscBadgeButton>(find.byType(UoscBadgeButton)).active, isTrue);
s.shaderPreset.value = ShaderPreset.off;
s.shaderQuality.value = ShaderQuality.hq;
});
testWidgets('volume: click mutes and restores, wheel steps 5', (tester) async {
final got = <double>[];
var volume = 80.0;
await tester.pumpWidget(host(StatefulBuilder(
builder: (context, setState) => Row(children: [
UoscVolumeButton(volume: volume, onChanged: (v) => setState(() => got.add(volume = v))),
]),
)));
await tester.tap(find.byKey(const ValueKey('uosc-volume')));
await tester.pump();
await tester.tap(find.byKey(const ValueKey('uosc-volume')));
await tester.pump();
await tester.sendEventToBinding(PointerScrollEvent(
kind: PointerDeviceKind.mouse,
position: tester.getCenter(find.byKey(const ValueKey('uosc-volume'))),
scrollDelta: const Offset(0, -20)));
await tester.pump();
expect(got, [0, 80, 85]);
});
});
group('menus', () {
Color rowColor(WidgetTester tester, String title) => (tester
.widget<Container>(find.descendant(
of: find.byKey(ValueKey('uosc-menu-item-$title')), matching: find.byType(Container)).first)
.decoration! as BoxDecoration)
.color!;
testWidgets('audio: flat dark list, current highlighted, pick selects', (tester) async {
TrackPick? got;
await tester.pumpWidget(host(Row(children: [
UoscTracksButton(type: JellyfinStreamType.audio, tracks: tracks, onPick: (p) => got = p),
])));
await tester.tap(find.byKey(const ValueKey('uosc-audio')));
await tester.pumpAndSettle();
final menu = find.byKey(const ValueKey('uosc-menu'));
expect(tester.widget<Material>(menu).color, UoscStyle.panel);
expect(tester.getRect(menu).bottom, lessThan(tester.getRect(find.byKey(const ValueKey('uosc-audio'))).top),
reason: 'opens above the button');
expect(find.text('Audio'), findsOneWidget, reason: 'title row');
expect(rowColor(tester, 'Japanese · E-AC3 2.0'), UoscStyle.fill);
expect(rowColor(tester, 'English · AAC 5.1 · Dub'), isNot(UoscStyle.fill));
await tester.tap(find.byKey(const ValueKey('uosc-menu-item-English · AAC 5.1 · Dub')));
await tester.pumpAndSettle();
expect(got, (aid: 2, sid: null, subtitlesOff: false));
expect(menu, findsNothing);
});
testWidgets('subtitles start with Off, active when none is selected', (tester) async {
TrackPick? got;
await tester.pumpWidget(host(Row(children: [
UoscTracksButton(type: JellyfinStreamType.subtitle, tracks: tracks, onPick: (p) => got = p),
])));
await tester.tap(find.byKey(const ValueKey('uosc-subtitles')));
await tester.pumpAndSettle();
expect(rowColor(tester, 'Off'), UoscStyle.fill);
await tester.tap(find.byKey(const ValueKey('uosc-menu-item-Off')));
await tester.pumpAndSettle();
expect(got, (aid: null, sid: null, subtitlesOff: true));
});
testWidgets('chapters: titles with start times, the playing one highlighted', (tester) async {
MediaChapter? got;
await tester.pumpWidget(host(Row(children: [
UoscChaptersButton(chapters: chapters, current: 2, duration: m(24), onPick: (c) => got = c),
])));
await tester.tap(find.byKey(const ValueKey('uosc-chapters')));
await tester.pumpAndSettle();
for (final c in chapters) {
expect(find.descendant(of: find.byKey(ValueKey('uosc-menu-item-${c.title}')), matching: find.text(formatUoscTime(c.start))),
findsOneWidget);
}
expect(rowColor(tester, 'Part A'), UoscStyle.fill);
expect(rowColor(tester, 'Opening'), isNot(UoscStyle.fill));
await tester.tap(find.byKey(const ValueKey('uosc-menu-item-Ending')));
await tester.pumpAndSettle();
expect(got, chapters[3]);
});
testWidgets('no chapters: no button', (tester) async {
await tester.pumpWidget(host(Row(children: [
UoscChaptersButton(chapters: const [], current: null, duration: m(24), onPick: (_) {}),
])));
expect(find.byKey(const ValueKey('uosc-chapters')), findsNothing);
});
testWidgets('main menu runs the picked action', (tester) async {
final ran = <String>[];
await tester.pumpWidget(host(Row(children: [
UoscMainMenuButton(actions: [
(title: 'Change video', hint: null, run: () => ran.add('swap')),
(title: 'Reload video', hint: 'Ctrl+R', run: () => ran.add('reload')),
]),
])));
await tester.tap(find.byKey(const ValueKey('uosc-menu-button')));
await tester.pumpAndSettle();
expect(find.text('Ctrl+R'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('uosc-menu-item-Reload video')));
await tester.pumpAndSettle();
expect(ran, ['reload']);
});
});
testWidgets('the setting picks the controls builder, live', (tester) async {
final s = AppSettings.instance;
s.mpvControls.value = false;
await tester.pumpWidget(MaterialApp(
home: SessionControlsSwitch(
material: (_) => const Text('material'),
mpv: (_) => const Text('mpv'),
),
));
expect(find.text('material'), findsOneWidget);
s.mpvControls.value = true;
await tester.pump();
expect(find.text('mpv'), findsOneWidget);
expect(find.text('material'), findsNothing);
s.mpvControls.value = false;
await tester.pump();
expect(find.text('material'), findsOneWidget);
});
testWidgets('Settings: "Experimental: mpv-style controls", off by default', (tester) async {
final s = AppSettings.instance;
s.mpvControls.value = false;
addTearDown(() => s.mpvControls.value = false);
tester.view.physicalSize = const Size(1280, 2400);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.pumpWidget(MaterialApp(
theme: ThemeData.dark(),
home: Scaffold(body: SettingsPage(updates: UpdateController(currentVersion: '0.0.0-dev'))),
));
final toggle = find.byKey(const ValueKey('mpv-controls'));
expect(find.text('Experimental: mpv-style controls'), findsOneWidget);
expect(tester.widget<SwitchListTile>(toggle).value, isFalse);
await tester.tap(toggle);
await tester.pump();
expect(s.mpvControls.value, isTrue);
expect(tester.widget<SwitchListTile>(toggle).value, isTrue);
});
}