Player controls: one model, two skins (Standard, Minimal); conventional layout; controls settings
PlayerControlsModel holds the controls' state and actions once; the Standard skin (our own, no more media_kit controls or private-state reach-ins) and Minimal (the former uosc design, made the app's own and credited) only draw it. One layout spec for both: play first, then previous/next, volume, time; subtitles, audio, settings, chat, fullscreen last; everything else in the settings menu. Settings -> Player -> Controls: time display, size, hide delay, optional buttons. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
959eed38a4
commit
fd8064611a
40 files changed
+3820
-3975
No files matched your search
@@ -0,0 +1,218 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
import 'package:watch_together/src/controls/controls_probe.dart';
|
||||
import 'package:watch_together/src/controls/model/controls_layout.dart';
|
||||
import 'package:watch_together/src/controls/model/controls_model.dart';
|
||||
import 'package:watch_together/src/controls/model/controls_prefs.dart';
|
||||
import 'package:watch_together/src/controls/session_controls.dart';
|
||||
import 'package:watch_together/src/playback/media_marks.dart';
|
||||
import 'package:watch_together/src/state/session_state.dart';
|
||||
import 'package:watch_together/src/state/settings.dart';
|
||||
|
||||
// The controls model and its layout spec: which controls the bar has, in
|
||||
// which order, what goes to ⚙; and both skins drawing the same order.
|
||||
|
||||
const none = <OptionalControl>{};
|
||||
|
||||
void main() {
|
||||
group('bar layout', () {
|
||||
test('play first, fullscreen last; no episode, no chapters: the base set', () {
|
||||
final bar = barControls(const ControlsSnapshot(), none);
|
||||
expect(bar.left, [BarControl.playPause, BarControl.volume, BarControl.time]);
|
||||
expect(bar.right, [BarControl.subtitles, BarControl.audio, BarControl.settings, BarControl.chat, BarControl.fullscreen]);
|
||||
});
|
||||
|
||||
test('episodes and chapters slot in after play, in the spec order', () {
|
||||
final bar = barControls(
|
||||
const ControlsSnapshot(previousLabel: 'S1E6', nextLabel: 'S1E8', hasChapters: true, hasDuration: true), none);
|
||||
expect(bar.left, [
|
||||
BarControl.playPause,
|
||||
BarControl.previous,
|
||||
BarControl.next,
|
||||
BarControl.chapterPrevious,
|
||||
BarControl.chapterNext,
|
||||
BarControl.volume,
|
||||
BarControl.time,
|
||||
]);
|
||||
expect(barControls(const ControlsSnapshot(nextLabel: 'Up'), none).left,
|
||||
[BarControl.playPause, BarControl.next, BarControl.volume, BarControl.time]);
|
||||
});
|
||||
|
||||
test('hidden optional buttons leave the bar and show in ⚙', () {
|
||||
const s = ControlsSnapshot(hasChapters: true, hasDuration: true);
|
||||
final hidden = OptionalControl.values.toSet();
|
||||
final bar = barControls(s, hidden);
|
||||
expect(bar.left, [BarControl.playPause, BarControl.volume, BarControl.time]);
|
||||
expect(bar.right, [BarControl.settings, BarControl.fullscreen]);
|
||||
expect(menuActions(s, hidden), containsAllInOrder([ControlAction.subtitles, ControlAction.audio, ControlAction.chat]));
|
||||
final shown = menuActions(s, none);
|
||||
expect(shown, isNot(contains(ControlAction.subtitles)));
|
||||
expect(shown, isNot(contains(ControlAction.chat)));
|
||||
});
|
||||
|
||||
test('⚙ menu: what applies, in a fixed order', () {
|
||||
expect(menuActions(const ControlsSnapshot(), none), [
|
||||
ControlAction.shaders,
|
||||
ControlAction.changeVideo,
|
||||
ControlAction.reload,
|
||||
ControlAction.markAllReady,
|
||||
ControlAction.markProblem,
|
||||
ControlAction.playerSettings,
|
||||
ControlAction.leave,
|
||||
]);
|
||||
final all = menuActions(const ControlsSnapshot(hasChapters: true, hasDuration: true, hasLink: true), none);
|
||||
expect(all.first, ControlAction.chapters);
|
||||
expect(all, containsAllInOrder([ControlAction.skipForward, ControlAction.copyLink]));
|
||||
expect(menuActions(const ControlsSnapshot(solo: true), none), isNot(contains(ControlAction.markAllReady)),
|
||||
reason: 'nobody to mark ready');
|
||||
});
|
||||
|
||||
test('play / pause follows the session: playing, ready and waiting, not ready', () {
|
||||
expect(controlIcon(BarControl.playPause, const ControlsSnapshot(play: PlayState.playing)), Icons.pause_rounded);
|
||||
expect(controlIcon(BarControl.playPause, const ControlsSnapshot(play: PlayState.paused)), Icons.play_arrow_rounded);
|
||||
expect(controlIcon(BarControl.playPause, const ControlsSnapshot(play: PlayState.waiting)), Icons.check_rounded);
|
||||
expect(controlTooltip(BarControl.playPause, const ControlsSnapshot(play: PlayState.playing)),
|
||||
'Pause for everyone (Space)');
|
||||
expect(controlTooltip(BarControl.playPause, const ControlsSnapshot(solo: true)), 'Play (Space)');
|
||||
});
|
||||
|
||||
test('episode buttons: the label and the keys', () {
|
||||
const s = ControlsSnapshot(previousLabel: 'S1E6 Before', nextLabel: 'S1E7 Battle with the Heroes');
|
||||
expect(controlTooltip(BarControl.next, s), 'Next: S1E7 Battle with the Heroes (> or Shift+N)');
|
||||
expect(controlTooltip(BarControl.previous, s), 'Previous: S1E6 Before (< or Shift+P)');
|
||||
expect(controlTooltip(BarControl.next, const ControlsSnapshot(nextLabel: 'Clip', nextIsUpNext: true)),
|
||||
'Up next: Clip (> or Shift+N)');
|
||||
expect(controlEnabled(BarControl.chapterNext, const ControlsSnapshot(hasChapters: true)), isFalse,
|
||||
reason: 'no duration yet');
|
||||
});
|
||||
|
||||
test('clock: elapsed / total, or the time left', () {
|
||||
const p = Duration(minutes: 6), d = Duration(minutes: 24);
|
||||
expect(clockText(p, d, TimeDisplay.elapsed), '6:00 / 24:00');
|
||||
expect(clockText(p, d, TimeDisplay.remaining), '-18:00');
|
||||
expect(clockText(p, Duration.zero, TimeDisplay.remaining), '6:00 / 0:00', reason: 'length unknown');
|
||||
expect(TimeDisplay.elapsed.toggled, TimeDisplay.remaining);
|
||||
});
|
||||
});
|
||||
|
||||
group('both skins draw the model', () {
|
||||
final s = AppSettings.instance;
|
||||
final coord = SessionState.instance.coordinator;
|
||||
|
||||
tearDown(() {
|
||||
s.controlsSkin.value = ControlsSkin.standard;
|
||||
s.hiddenControls.value = const {};
|
||||
s.progressLine.value = true;
|
||||
coord.playback.chapters.value = const [];
|
||||
coord.playback.duration.value = Duration.zero;
|
||||
});
|
||||
|
||||
Future<PlayerControlsModel> pump(WidgetTester tester, ControlsSkin skin) async {
|
||||
tester.view.physicalSize = const Size(1280, 720);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
s.controlsSkin.value = skin;
|
||||
final model = PlayerControlsModel(
|
||||
coord: coord,
|
||||
fullscreen: signal(false),
|
||||
chatOpen: signal(false),
|
||||
unread: signal(0),
|
||||
onToggleFullscreen: () {},
|
||||
onToggleChat: () {},
|
||||
onChangeVideo: () {},
|
||||
onCollapse: () {},
|
||||
onLeave: () {},
|
||||
);
|
||||
final shown = ValueNotifier(true);
|
||||
final hold = ControlsHold();
|
||||
addTearDown(() {
|
||||
model.dispose();
|
||||
shown.dispose();
|
||||
hold.dispose();
|
||||
});
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
theme: ThemeData.dark(),
|
||||
home: Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
body: SessionControls(model: model, shown: shown, hold: hold, onDoubleClick: () {}),
|
||||
),
|
||||
));
|
||||
await tester.pump();
|
||||
return model;
|
||||
}
|
||||
|
||||
/// The bar's controls left to right, by their keys.
|
||||
List<BarControl> drawn(WidgetTester tester) {
|
||||
final found = <(double, BarControl)>[];
|
||||
for (final c in BarControl.values) {
|
||||
final f = find.byKey(ValueKey('control-${c.name}'));
|
||||
if (f.evaluate().isEmpty) continue;
|
||||
final r = tester.getRect(f);
|
||||
if (r.width > 0) found.add((r.left, c));
|
||||
}
|
||||
found.sort((a, b) => a.$1.compareTo(b.$1));
|
||||
return [for (final (_, c) in found) c];
|
||||
}
|
||||
|
||||
for (final skin in ControlsSkin.values) {
|
||||
testWidgets('${skin.label}: the layout\'s order, play leftmost, fullscreen rightmost', (tester) async {
|
||||
coord.playback.duration.value = const Duration(minutes: 24);
|
||||
coord.playback.chapters.value = chaptersFromStarts([
|
||||
('A', Duration.zero),
|
||||
('B', const Duration(minutes: 2)),
|
||||
], const Duration(minutes: 24));
|
||||
final model = await pump(tester, skin);
|
||||
final bar = model.bar.value;
|
||||
final want = [
|
||||
...bar.left,
|
||||
...bar.right,
|
||||
].where((c) => skin == ControlsSkin.standard || c != BarControl.time).toList();
|
||||
expect(drawn(tester), want);
|
||||
expect(want.first, BarControl.playPause);
|
||||
expect(want.last, BarControl.fullscreen);
|
||||
expect(want, contains(BarControl.chapterNext));
|
||||
|
||||
s.setControlShown(OptionalControl.chat, false);
|
||||
await tester.pump();
|
||||
expect(drawn(tester), isNot(contains(BarControl.chat)));
|
||||
});
|
||||
}
|
||||
|
||||
testWidgets('⚙ lists the rest', (tester) async {
|
||||
await pump(tester, ControlsSkin.minimal);
|
||||
await tester.tap(find.byKey(const ValueKey('control-settings')));
|
||||
await tester.pumpAndSettle();
|
||||
for (final a in [ControlAction.reload, ControlAction.markProblem, ControlAction.playerSettings]) {
|
||||
expect(find.byKey(ValueKey('controls-menu-${a.name}')), findsOneWidget, reason: a.name);
|
||||
}
|
||||
expect(find.byKey(const ValueKey('controls-menu-chapters')), findsNothing, reason: 'no chapters');
|
||||
await tester.tapAt(const Offset(5, 5));
|
||||
await tester.pumpAndSettle();
|
||||
});
|
||||
|
||||
testWidgets('Minimal hidden: the timeline stays as a line unless switched off', (tester) async {
|
||||
s.controlsHideSecs.value = 2;
|
||||
addTearDown(() => s.controlsHideSecs.value = defaultControlsHideSecs);
|
||||
await pump(tester, ControlsSkin.minimal);
|
||||
double opacity() => tester.widget<AnimatedOpacity>(find.byKey(const ValueKey('minimal-timeline-wrap'))).opacity;
|
||||
await tester.pump(const Duration(seconds: 3));
|
||||
await tester.pumpAndSettle();
|
||||
expect(opacity(), 1, reason: 'hidden, line on');
|
||||
s.progressLine.value = false;
|
||||
await tester.pump();
|
||||
expect(opacity(), 0, reason: 'hidden, line off');
|
||||
});
|
||||
|
||||
testWidgets('Standard hidden: a thin progress line unless switched off', (tester) async {
|
||||
await pump(tester, ControlsSkin.standard);
|
||||
expect(find.byKey(const ValueKey('progress-line-standard')), findsNothing, reason: 'controls up');
|
||||
await tester.pump(const Duration(seconds: 4));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const ValueKey('progress-line-standard')), findsOneWidget);
|
||||
s.progressLine.value = false;
|
||||
await tester.pump();
|
||||
expect(find.byKey(const ValueKey('progress-line-standard')), findsNothing);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -501,18 +501,6 @@ void main() {
|
||||
const JellyfinMedia(
|
||||
serverId: fixtureServerId, baseUrl: 'b', itemId: nextEpisodeId, title: 'Battle with the Heroes'));
|
||||
|
||||
testWidgets('next / previous buttons: tooltip, hidden without an episode', (tester) async {
|
||||
var pressed = 0;
|
||||
await tester.pumpWidget(app(Row(children: [
|
||||
EpisodeSkipButton(episode: null, next: false, onPressed: () {}),
|
||||
EpisodeSkipButton(episode: next, next: true, onPressed: () => pressed++),
|
||||
])));
|
||||
expect(find.byKey(const ValueKey('episode-previous')), findsNothing);
|
||||
expect(find.byTooltip('Next: S1E7 Battle with the Heroes (> or Shift+N)'), findsOneWidget);
|
||||
await tester.tap(find.byKey(const ValueKey('episode-next')));
|
||||
expect(pressed, 1);
|
||||
});
|
||||
|
||||
testWidgets('end card: Next prompt, countdown with Cancel', (tester) async {
|
||||
var play = 0, cancel = 0;
|
||||
await tester.pumpWidget(app(EndOfEpisodeCard(
|
||||
|
||||
@@ -0,0 +1,430 @@
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:watch_together/src/controls/frame.dart';
|
||||
import 'package:watch_together/src/controls/minimal/style.dart';
|
||||
import 'package:watch_together/src/controls/minimal/timeline.dart';
|
||||
import 'package:watch_together/src/controls/model/controls_prefs.dart';
|
||||
import 'package:watch_together/src/controls/volume.dart';
|
||||
import 'package:watch_together/src/playback/media_marks.dart';
|
||||
import 'package:watch_together/src/shell/settings_tab.dart';
|
||||
import 'package:watch_together/src/state/settings.dart';
|
||||
import 'package:watch_together/src/update/update_controller.dart';
|
||||
import 'package:watch_together/src/widgets/app_menu.dart';
|
||||
|
||||
// The Minimal skin's pieces and the shared controls frame (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 = 800}) => MaterialApp(
|
||||
theme: ThemeData.dark(),
|
||||
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 text(WidgetTester tester, String key) => tester.widget<Text>(find.byKey(ValueKey(key))).data!;
|
||||
|
||||
MinimalTimelinePainter painter(WidgetTester tester) => tester
|
||||
.widget<CustomPaint>(find.descendant(
|
||||
of: find.byKey(const ValueKey('minimal-timeline')), matching: find.byType(CustomPaint)))
|
||||
.painter! as MinimalTimelinePainter;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('timeline math', () {
|
||||
test('missing part: from the cache end (or the position) to the end', () {
|
||||
expect(uncachedFrom(position: m(6), buffered: m(9), duration: m(24)), 9 / 24);
|
||||
expect(uncachedFrom(position: m(6), buffered: m(5), duration: m(24)), 6 / 24, reason: 'stale cache end');
|
||||
expect(uncachedFrom(position: m(6), buffered: Duration.zero, duration: m(24)), isNull, reason: 'unknown');
|
||||
expect(uncachedFrom(position: m(6), buffered: m(24), duration: m(24)), isNull, reason: 'cached to the end');
|
||||
expect(uncachedFrom(position: m(6), buffered: m(9), duration: Duration.zero), isNull);
|
||||
});
|
||||
|
||||
test('cache label: seconds ahead, under 60 s, not when cached to the end', () {
|
||||
expect(cacheLabel(position: m(6), buffered: m(6, 30), duration: m(24)), '30s');
|
||||
expect(cacheLabel(position: m(6), buffered: m(6), duration: m(24)), '0s');
|
||||
expect(cacheLabel(position: m(6), buffered: m(5), duration: m(24)), '0s');
|
||||
expect(cacheLabel(position: m(6), buffered: m(7), duration: m(24)), isNull, reason: 'a minute or more');
|
||||
expect(cacheLabel(position: m(23, 40), buffered: m(24), duration: m(24)), isNull, reason: 'eof cached');
|
||||
expect(cacheLabel(position: m(6), buffered: Duration.zero, duration: m(24)), isNull);
|
||||
});
|
||||
|
||||
test('band size: the full band shown, the line hidden, scaled', () {
|
||||
expect(minimalBandSize(1, 1), MinimalTimelineSpec.size);
|
||||
expect(minimalBandSize(0, 1), MinimalTimelineSpec.lineSize);
|
||||
expect(minimalBandSize(1, 1.25), MinimalTimelineSpec.size * 1.25);
|
||||
});
|
||||
});
|
||||
|
||||
group('timeline', () {
|
||||
testWidgets('played part, elapsed left, remaining right, cache label', (tester) async {
|
||||
await tester.pumpWidget(host(MinimalTimeline(
|
||||
position: m(6), duration: m(24), buffered: m(6, 30), chapters: chapters, onSeek: (_) {})));
|
||||
final bar = tester.getRect(find.byKey(const ValueKey('minimal-timeline')));
|
||||
expect(bar.width, 800);
|
||||
expect(bar.height, MinimalTimelineSpec.size);
|
||||
final p = painter(tester);
|
||||
expect(p.progress, 0.25);
|
||||
expect(p.lineX(800), 200);
|
||||
expect(p.visibility, 1);
|
||||
expect(p.uncached, 6.5 / 24);
|
||||
expect(p.elapsed, '6:00');
|
||||
expect(p.right, '-18:00');
|
||||
expect(p.cache, '30s');
|
||||
expect(p.textOpacity, 1);
|
||||
});
|
||||
|
||||
testWidgets('the total instead of the time left', (tester) async {
|
||||
await tester.pumpWidget(host(MinimalTimeline(
|
||||
position: m(6), duration: m(24), remaining: false, onSeek: (_) {})));
|
||||
expect(painter(tester).right, '24:00');
|
||||
});
|
||||
|
||||
testWidgets('a press on the right-hand time switches it, no seek', (tester) async {
|
||||
final seeks = <Duration>[];
|
||||
var toggled = 0;
|
||||
await tester.pumpWidget(host(MinimalTimeline(
|
||||
position: m(6), duration: m(24), onSeek: seeks.add, onToggleTime: () => toggled++)));
|
||||
final bar = tester.getRect(find.byKey(const ValueKey('minimal-timeline')));
|
||||
await tester.tapAt(Offset(bar.right - 20, bar.center.dy));
|
||||
expect(toggled, 1);
|
||||
expect(seeks, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('a mark at each chapter start', (tester) async {
|
||||
await tester.pumpWidget(host(MinimalTimeline(position: m(1), duration: m(24), chapters: chapters, onSeek: (_) {})));
|
||||
expect(painter(tester).chapters, [for (final c in chapters) c.start.inSeconds / m(24).inSeconds]);
|
||||
});
|
||||
|
||||
testWidgets('hover: chapter title over the time, above the band', (tester) async {
|
||||
await tester.pumpWidget(host(MinimalTimeline(position: m(1), duration: m(24), chapters: chapters, onSeek: (_) {})));
|
||||
final bar = tester.getRect(find.byKey(const ValueKey('minimal-timeline')));
|
||||
final mouse = await mouseAt(tester, Offset.zero);
|
||||
// 1:51 of 24:00: inside "Opening".
|
||||
await mouse.moveTo(Offset(bar.left + bar.width * 111 / (24 * 60) + 0.2, bar.center.dy));
|
||||
await tester.pump();
|
||||
expect(text(tester, 'minimal-hover-chapter'), 'Opening');
|
||||
expect(text(tester, 'minimal-hover-time'), '1:51');
|
||||
final title = tester.getRect(find.byKey(const ValueKey('minimal-hover-chapter')));
|
||||
final time = tester.getRect(find.byKey(const ValueKey('minimal-hover-time')));
|
||||
expect(title.bottom, lessThanOrEqualTo(time.top), reason: 'title above');
|
||||
expect(time.bottom, lessThan(bar.top), reason: 'above the band');
|
||||
expect(painter(tester).hoverX, isNotNull);
|
||||
await mouse.moveTo(Offset(bar.left + bar.width * (10 / 24), bar.center.dy));
|
||||
await tester.pump();
|
||||
expect(text(tester, 'minimal-hover-chapter'), 'Part A');
|
||||
await mouse.moveTo(Offset(bar.left, bar.top - 200));
|
||||
await tester.pump();
|
||||
expect(find.byKey(const ValueKey('minimal-hover-time')), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('a press on a hovered chapter mark jumps to its chapter', (tester) async {
|
||||
final seeks = <Duration>[];
|
||||
await tester.pumpWidget(host(MinimalTimeline(position: m(1), duration: m(24), chapters: chapters, onSeek: seeks.add)));
|
||||
final bar = tester.getRect(find.byKey(const ValueKey('minimal-timeline')));
|
||||
final at = Offset(bar.left + bar.width * 2 / 24 + 3, bar.top + 2);
|
||||
final mouse = await mouseAt(tester, at);
|
||||
await mouse.moveTo(at);
|
||||
await tester.pump();
|
||||
expect(painter(tester).hoveredChapter, 2);
|
||||
expect(text(tester, 'minimal-hover-time'), '2:00');
|
||||
await mouse.down(at);
|
||||
await mouse.up();
|
||||
await tester.pump();
|
||||
expect(seeks, [m(2)]);
|
||||
});
|
||||
|
||||
testWidgets('hidden controls: the progress line, no text, no input', (tester) async {
|
||||
final seeks = <Duration>[];
|
||||
await tester.pumpWidget(host(MinimalTimeline(
|
||||
position: m(12), duration: m(24), buffered: m(14), chapters: chapters, expanded: false, onSeek: seeks.add)));
|
||||
final bar = tester.getRect(find.byKey(const ValueKey('minimal-timeline')));
|
||||
expect(bar.height, MinimalTimeline.collapsedHeight);
|
||||
final p = painter(tester);
|
||||
expect(p.progress, 0.5);
|
||||
expect(p.visibility, 0);
|
||||
expect(p.textOpacity, 0);
|
||||
final mouse = await mouseAt(tester, Offset.zero);
|
||||
await mouse.moveTo(bar.center);
|
||||
await tester.pump();
|
||||
expect(find.byKey(const ValueKey('minimal-hover-time')), findsNothing);
|
||||
await tester.tapAt(bar.center);
|
||||
expect(seeks, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('press seeks, every drag move seeks, release holds the target', (tester) async {
|
||||
final seeks = <Duration>[];
|
||||
await tester.pumpWidget(host(MinimalTimeline(position: m(1), duration: m(24), chapters: chapters, onSeek: seeks.add)));
|
||||
final bar = tester.getRect(find.byKey(const ValueKey('minimal-timeline')));
|
||||
final g0 = await tester.startGesture(Offset(bar.left + bar.width / 4, bar.center.dy), kind: PointerDeviceKind.mouse);
|
||||
expect(seeks, [m(6)], reason: 'the press seeks');
|
||||
await g0.moveTo(Offset(bar.left + bar.width / 2, bar.center.dy));
|
||||
await tester.pump();
|
||||
expect(painter(tester).elapsed, formatClock(seeks.last), reason: 'follows the drag');
|
||||
await g0.moveTo(Offset(bar.left + bar.width * 3 / 4, bar.center.dy));
|
||||
await g0.up();
|
||||
await tester.pump();
|
||||
expect(seeks, hasLength(3), reason: 'release at the last move: no repeat');
|
||||
expect(painter(tester).progress, 0.75, reason: 'shows the target until the position catches up');
|
||||
await tester.pump(const Duration(seconds: 3));
|
||||
expect(painter(tester).progress, 1 / 24, reason: 'never confirmed: back to the position');
|
||||
});
|
||||
});
|
||||
|
||||
group('controls frame', () {
|
||||
Widget frame({Duration hideAfter = const Duration(seconds: 3), Widget? child}) => MaterialApp(
|
||||
theme: ThemeData.dark(),
|
||||
home: Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
body: ControlsFrame(
|
||||
hideAfter: hideAfter,
|
||||
builder: (context, visible) => Stack(children: [
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||
Opacity(opacity: visible ? 1 : 0, child: child ?? const SizedBox(height: 40)),
|
||||
MinimalTimeline(
|
||||
position: m(6), duration: m(24), chapters: chapters, expanded: visible, onSeek: (_) {}),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
bool up(WidgetTester tester) => tester.state<ControlsFrameState>(find.byType(ControlsFrame)).visible;
|
||||
|
||||
testWidgets('hide after the delay, mouse brings them back', (tester) async {
|
||||
await tester.pumpWidget(frame());
|
||||
expect(up(tester), isTrue);
|
||||
await tester.pump(const Duration(seconds: 4));
|
||||
await tester.pumpAndSettle();
|
||||
expect(up(tester), isFalse);
|
||||
expect(tester.getRect(find.byKey(const ValueKey('minimal-timeline'))).height, MinimalTimeline.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(up(tester), isTrue);
|
||||
await tester.pumpAndSettle(const Duration(milliseconds: 50));
|
||||
expect(tester.getRect(find.byKey(const ValueKey('minimal-timeline'))).height, MinimalTimelineSpec.size,
|
||||
reason: 'grown back (animated)');
|
||||
});
|
||||
|
||||
testWidgets('a long drag on the timeline keeps the controls up', (tester) async {
|
||||
await tester.pumpWidget(frame());
|
||||
final bar = tester.getRect(find.byKey(const ValueKey('minimal-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(up(tester), isTrue, reason: 'held while dragging');
|
||||
await g.up();
|
||||
await g.removePointer();
|
||||
await tester.pump(const Duration(seconds: 4));
|
||||
await tester.pumpAndSettle();
|
||||
expect(up(tester), isFalse, reason: 'hides again after release');
|
||||
});
|
||||
|
||||
testWidgets('an open app menu keeps the controls up', (tester) async {
|
||||
await tester.pumpWidget(frame(
|
||||
child: Builder(
|
||||
builder: (context) => TextButton(
|
||||
key: const ValueKey('open'),
|
||||
onPressed: () => showAppMenu<int>(context, items: const [AppMenuItem(value: 1, label: 'One')]),
|
||||
child: const Text('open'),
|
||||
),
|
||||
),
|
||||
));
|
||||
await tester.tap(find.byKey(const ValueKey('open')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('One'), findsOneWidget);
|
||||
await tester.pump(const Duration(seconds: 10));
|
||||
expect(up(tester), isTrue);
|
||||
await tester.tapAt(const Offset(5, 5)); // outside: dismiss
|
||||
await tester.pumpAndSettle();
|
||||
await tester.pump(const Duration(seconds: 4));
|
||||
await tester.pumpAndSettle();
|
||||
expect(up(tester), isFalse);
|
||||
});
|
||||
|
||||
testWidgets('double-click on the picture, not on the bars', (tester) async {
|
||||
var doubles = 0;
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
home: ControlsFrame(
|
||||
onDoubleClick: () => doubles++,
|
||||
barBottom: 100,
|
||||
builder: (context, visible) => const SizedBox.expand(),
|
||||
),
|
||||
));
|
||||
Future<void> twice(Offset at) async {
|
||||
await tester.tapAt(at, kind: PointerDeviceKind.mouse);
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
await tester.tapAt(at, kind: PointerDeviceKind.mouse);
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
}
|
||||
|
||||
await twice(const Offset(400, 200));
|
||||
expect(doubles, 1);
|
||||
await twice(const Offset(400, 580));
|
||||
expect(doubles, 1, reason: 'on the bottom bar');
|
||||
});
|
||||
});
|
||||
|
||||
group('buttons', () {
|
||||
testWidgets('count badge in the accent; tooltip on the app glass', (tester) async {
|
||||
await tester.pumpWidget(host(const Row(children: [
|
||||
MinimalButton(key: ValueKey('subs'), icon: Icons.subtitles_outlined, tooltip: 'Subtitles', badge: 2, onPressed: null),
|
||||
MinimalButton(key: ValueKey('plain'), icon: Icons.menu, tooltip: 'Menu', onPressed: null),
|
||||
])));
|
||||
expect(
|
||||
find.descendant(of: find.byKey(const ValueKey('subs')), matching: find.byKey(const ValueKey('minimal-badge'))),
|
||||
findsOneWidget);
|
||||
expect(find.descendant(of: find.byKey(const ValueKey('subs')), matching: find.text('2')), findsOneWidget);
|
||||
expect(
|
||||
find.descendant(of: find.byKey(const ValueKey('plain')), matching: find.byKey(const ValueKey('minimal-badge'))),
|
||||
findsNothing);
|
||||
final tip = tester.widget<Tooltip>(
|
||||
find.descendant(of: find.byKey(const ValueKey('subs')), matching: find.byType(Tooltip)).first);
|
||||
expect((tip.decoration as BoxDecoration).color, MinimalStyle.tip);
|
||||
});
|
||||
|
||||
testWidgets('volume: click mutes and restores, wheel steps 5', (tester) async {
|
||||
final got = <double>[];
|
||||
var volume = 80.0;
|
||||
var before = 80.0;
|
||||
await tester.pumpWidget(host(StatefulBuilder(
|
||||
builder: (context, setState) => Row(children: [
|
||||
VolumeControl(
|
||||
volume: volume,
|
||||
onChanged: (v) => setState(() => got.add(volume = v)),
|
||||
onToggleMute: () => setState(() {
|
||||
if (volume > 0) {
|
||||
before = volume;
|
||||
got.add(volume = 0);
|
||||
} else {
|
||||
got.add(volume = before);
|
||||
}
|
||||
}),
|
||||
button: (context, icon, color, tip, onPressed) =>
|
||||
MinimalButton(key: const ValueKey('speaker'), icon: icon, tooltip: tip, onPressed: onPressed),
|
||||
),
|
||||
]),
|
||||
)));
|
||||
await tester.tap(find.byKey(const ValueKey('speaker')));
|
||||
await tester.pump();
|
||||
await tester.tap(find.byKey(const ValueKey('speaker')));
|
||||
await tester.pump();
|
||||
final at = tester.getCenter(find.byKey(const ValueKey('speaker')));
|
||||
await tester.sendEventToBinding(
|
||||
PointerScrollEvent(kind: PointerDeviceKind.mouse, position: at, scrollDelta: const Offset(0, -60)));
|
||||
await tester.pump();
|
||||
expect(got, [0, 80, 85], reason: 'a notch is one step');
|
||||
for (var i = 0; i < 3; i++) {
|
||||
await tester.sendEventToBinding(
|
||||
PointerScrollEvent(kind: PointerDeviceKind.mouse, position: at, scrollDelta: const Offset(0, 20)));
|
||||
}
|
||||
await tester.pump();
|
||||
expect(got, [0, 80, 85, 80], reason: 'smooth deltas add up to a step');
|
||||
});
|
||||
});
|
||||
|
||||
group('volume wheel', () {
|
||||
testWidgets('anywhere: vertical steps; lists scroll instead; sideways is nothing', (tester) async {
|
||||
final steps = <int>[];
|
||||
final list = ScrollController();
|
||||
addTearDown(list.dispose);
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
home: VolumeWheel(
|
||||
onStep: steps.add,
|
||||
child: Stack(children: [
|
||||
const Positioned.fill(child: ColoredBox(color: Colors.black)),
|
||||
Positioned(
|
||||
right: 0,
|
||||
top: 0,
|
||||
width: 200,
|
||||
height: 200,
|
||||
child: ListView(controller: list, children: [for (var i = 0; i < 50; i++) Text('row $i')]),
|
||||
),
|
||||
]),
|
||||
),
|
||||
));
|
||||
Future<void> wheel(Offset at, Offset delta) =>
|
||||
tester.sendEventToBinding(PointerScrollEvent(kind: PointerDeviceKind.mouse, position: at, scrollDelta: delta));
|
||||
await wheel(const Offset(100, 400), const Offset(0, -60));
|
||||
await wheel(const Offset(100, 400), const Offset(0, 60));
|
||||
expect(steps, [1, -1], reason: 'up louder, down quieter');
|
||||
await wheel(const Offset(100, 400), const Offset(60, 0));
|
||||
await wheel(const Offset(100, 400), const Offset(60, -10));
|
||||
expect(steps, [1, -1], reason: 'sideways');
|
||||
await wheel(const Offset(700, 100), const Offset(0, 60));
|
||||
await tester.pump();
|
||||
expect(steps, [1, -1], reason: 'the list took it');
|
||||
expect(list.offset, greaterThan(0));
|
||||
});
|
||||
});
|
||||
|
||||
test('the old uosc choice becomes Minimal', () {
|
||||
expect(ControlsSkin.fromStored(null), ControlsSkin.standard);
|
||||
expect(ControlsSkin.fromStored(null, legacyMpvControls: true), ControlsSkin.minimal);
|
||||
expect(ControlsSkin.fromStored(null, legacyMpvControls: false), ControlsSkin.standard);
|
||||
expect(ControlsSkin.fromStored('standard', legacyMpvControls: true), ControlsSkin.standard,
|
||||
reason: 'a saved skin wins');
|
||||
expect(ControlsSkin.fromStored('minimal'), ControlsSkin.minimal);
|
||||
});
|
||||
|
||||
testWidgets('Settings: "Player design", Standard by default, Minimal picked live', (tester) async {
|
||||
final s = AppSettings.instance;
|
||||
s.controlsSkin.value = ControlsSkin.standard;
|
||||
addTearDown(() => s.controlsSkin.value = ControlsSkin.standard);
|
||||
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: SettingsTab(
|
||||
section: SettingsSection.player,
|
||||
onSection: (_) {},
|
||||
updates: UpdateController(currentVersion: '0.0.0-dev'),
|
||||
),
|
||||
),
|
||||
));
|
||||
final design = find.byKey(const ValueKey('player-design'));
|
||||
expect(find.text('Player design'), findsOneWidget);
|
||||
expect(tester.widget<AppSelect<ControlsSkin>>(design).value, ControlsSkin.standard);
|
||||
await tester.tap(design);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Minimal').last);
|
||||
await tester.pumpAndSettle();
|
||||
expect(s.controlsSkin.value, ControlsSkin.minimal);
|
||||
expect(tester.widget<AppSelect<ControlsSkin>>(design).value, ControlsSkin.minimal);
|
||||
});
|
||||
}
|
||||
@@ -2,11 +2,15 @@ import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sync_relay_core/core.dart' show JellyfinStreamType;
|
||||
import 'package:watch_together/src/controls/control_menus.dart';
|
||||
import 'package:watch_together/src/controls/standard/seek_bar.dart';
|
||||
import 'package:watch_together/src/jellyfin/track_prefs.dart';
|
||||
import 'package:watch_together/src/playback/media_marks.dart';
|
||||
import 'package:watch_together/src/screens/session/player_controls.dart';
|
||||
|
||||
// The control-bar pieces alone (never the session screen or a `Video`).
|
||||
// The control-bar pieces alone (never the session screen or a `Video`):
|
||||
// the Standard seek bar, the skip pill and the shared track / chapter
|
||||
// lists.
|
||||
|
||||
Duration m(int min, [int sec = 0]) => Duration(minutes: min, seconds: sec);
|
||||
|
||||
@@ -18,9 +22,6 @@ final chapters = chaptersFromStarts([
|
||||
('Part B', m(21, 30)),
|
||||
], m(24));
|
||||
|
||||
Finder item<T>(String text) =>
|
||||
find.ancestor(of: find.text(text), matching: find.byType(CheckedPopupMenuItem<T>));
|
||||
|
||||
Widget host(Widget child, {double width = 960}) => MaterialApp(
|
||||
home: Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
@@ -143,56 +144,30 @@ void main() {
|
||||
MpvTrack(id: 1, type: JellyfinStreamType.subtitle, language: 'eng', codec: 'ass', title: 'Full'),
|
||||
];
|
||||
|
||||
testWidgets('audio menu: count badge, labels, current checked, pick', (tester) async {
|
||||
TrackPick? got;
|
||||
await tester.pumpWidget(host(Row(children: [
|
||||
TrackMenuButton(type: JellyfinStreamType.audio, tracks: tracks, onPick: (p) => got = p),
|
||||
TrackMenuButton(type: JellyfinStreamType.subtitle, tracks: tracks, onPick: (p) => got = p),
|
||||
])));
|
||||
expect(find.descendant(of: find.byKey(const ValueKey('audio-button')), matching: find.text('2')),
|
||||
findsOneWidget);
|
||||
expect(find.descendant(of: find.byKey(const ValueKey('subtitles-button')), matching: find.text('1')),
|
||||
findsOneWidget);
|
||||
await tester.tap(find.byKey(const ValueKey('audio-button')));
|
||||
await tester.pumpAndSettle();
|
||||
final items = tester.widgetList<CheckedPopupMenuItem<TrackPick>>(find.byType(CheckedPopupMenuItem<TrackPick>));
|
||||
test('audio list: counts, labels, mpv\'s selection checked', () {
|
||||
expect(trackCount(tracks, JellyfinStreamType.audio), 2);
|
||||
expect(trackCount(tracks, JellyfinStreamType.subtitle), 1);
|
||||
final items = trackMenuItems(JellyfinStreamType.audio, tracks);
|
||||
expect(items.map((i) => i.label), ['Japanese · E-AC3 2.0', 'English · AAC 5.1 · Dub']);
|
||||
expect(items.map((i) => i.checked), [true, false]);
|
||||
expect(find.text('Japanese · E-AC3 2.0'), findsOneWidget);
|
||||
await tester.tap(item<TrackPick>('English · AAC 5.1 · Dub'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(got, (aid: 2, sid: null, subtitlesOff: false));
|
||||
expect(items[1].value, (aid: 2, sid: null, subtitlesOff: false));
|
||||
expect(trackMenuItems(JellyfinStreamType.audio, const []).single.enabled, isFalse, reason: 'None');
|
||||
});
|
||||
|
||||
testWidgets('subtitle menu has Off, checked when none is selected', (tester) async {
|
||||
TrackPick? got;
|
||||
await tester.pumpWidget(host(TrackMenuButton(
|
||||
type: JellyfinStreamType.subtitle, tracks: tracks, onPick: (p) => got = p)));
|
||||
await tester.tap(find.byKey(const ValueKey('subtitles-button')));
|
||||
await tester.pumpAndSettle();
|
||||
final items = tester.widgetList<CheckedPopupMenuItem<TrackPick>>(find.byType(CheckedPopupMenuItem<TrackPick>));
|
||||
test('subtitle list starts with Off, checked when none is selected', () {
|
||||
final items = trackMenuItems(JellyfinStreamType.subtitle, tracks);
|
||||
expect(items.map((i) => i.label), ['Off', 'English · ASS · Full']);
|
||||
expect(items.map((i) => i.checked), [true, false]);
|
||||
await tester.tap(item<TrackPick>('Off'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(got, (aid: null, sid: null, subtitlesOff: true));
|
||||
expect(items.first.value, (aid: null, sid: null, subtitlesOff: true));
|
||||
expect(items.last.value, (aid: null, sid: 1, subtitlesOff: false));
|
||||
});
|
||||
|
||||
testWidgets('chapter menu: current checked, a pick seeks to its start', (tester) async {
|
||||
MediaChapter? got;
|
||||
await tester.pumpWidget(host(ChaptersControlButton(chapters: chapters, current: 2, onPick: (c) => got = c)));
|
||||
await tester.tap(find.byKey(const ValueKey('chapters-button')));
|
||||
await tester.pumpAndSettle();
|
||||
final items = tester.widgetList<CheckedPopupMenuItem<MediaChapter>>(
|
||||
find.byType(CheckedPopupMenuItem<MediaChapter>));
|
||||
test('chapter list: start times, the current one checked', () {
|
||||
final items = chapterMenuItems(chapters, 2);
|
||||
expect(items.map((i) => i.checked), [false, false, true, false, false]);
|
||||
expect(find.text('20:00'), findsOneWidget);
|
||||
await tester.tap(item<MediaChapter>('ED'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(got!.start, m(20));
|
||||
});
|
||||
|
||||
testWidgets('no chapters, no chapter button', (tester) async {
|
||||
await tester.pumpWidget(host(ChaptersControlButton(chapters: const [], current: null, onPick: (_) {})));
|
||||
expect(find.byKey(const ValueKey('chapters-button')), findsNothing);
|
||||
expect(items[3].label, 'ED');
|
||||
expect(items[3].hint, '20:00');
|
||||
expect(items[3].value.start, m(20));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,13 +1,12 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
import 'package:watch_together/src/controls/controls_probe.dart';
|
||||
import 'package:watch_together/src/controls/frame.dart';
|
||||
import 'package:watch_together/src/controls/model/controls_prefs.dart';
|
||||
import 'package:watch_together/src/controls/player_settings.dart';
|
||||
import 'package:watch_together/src/controls/uosc/controls.dart';
|
||||
import 'package:watch_together/src/jellyfin/track_prefs.dart';
|
||||
import 'package:watch_together/src/schedule/animeflow.dart';
|
||||
import 'package:watch_together/src/shaders/shader_presets.dart';
|
||||
@@ -18,7 +17,7 @@ import 'package:watch_together/src/state/session_state.dart';
|
||||
import 'package:watch_together/src/state/settings.dart';
|
||||
import 'package:watch_together/src/update/update_controller.dart';
|
||||
|
||||
// The player's ⚙ card (both designs, never a media_kit `Video`), the
|
||||
// The player settings card (both skins, never a media_kit `Video`), the
|
||||
// Settings pages, the host flag in the party UI and invite addresses.
|
||||
|
||||
Widget app(Widget child) => MaterialApp(theme: ThemeData.dark(), home: Scaffold(body: child));
|
||||
@@ -41,7 +40,11 @@ void main() {
|
||||
final coord = SessionState.instance.coordinator;
|
||||
|
||||
tearDown(() {
|
||||
s.mpvControls.value = false;
|
||||
s.controlsSkin.value = ControlsSkin.standard;
|
||||
s.timeDisplay.value = TimeDisplay.elapsed;
|
||||
s.controlsSize.value = ControlsSize.normal;
|
||||
s.controlsHideSecs.value = defaultControlsHideSecs;
|
||||
s.hiddenControls.value = const {};
|
||||
s.progressLine.value = true;
|
||||
s.shaderPreset.value = ShaderPreset.off;
|
||||
s.shaderQuality.value = ShaderQuality.hq;
|
||||
@@ -53,15 +56,23 @@ void main() {
|
||||
SessionState.instance.recents.value = const [];
|
||||
});
|
||||
|
||||
group('⚙ player settings', () {
|
||||
testWidgets('standard controls: the gear opens every player setting, changes apply live', (tester) async {
|
||||
Widget opener() => Builder(
|
||||
builder: (context) => TextButton(
|
||||
key: const ValueKey('open-settings'),
|
||||
onPressed: () => showPlayerSettings(context),
|
||||
child: const Text('settings'),
|
||||
),
|
||||
);
|
||||
|
||||
group('player settings card', () {
|
||||
testWidgets('lists every player setting, changes apply live', (tester) async {
|
||||
bigWindow(tester);
|
||||
coord.playback.tracks.value = const [
|
||||
MpvTrack(id: 1, type: JellyfinStreamType.audio, language: 'jpn', codec: 'eac3', channels: 2, selected: true),
|
||||
MpvTrack(id: 1, type: JellyfinStreamType.subtitle, language: 'eng', codec: 'ass', title: 'Full'),
|
||||
];
|
||||
await tester.pumpWidget(app(const Align(alignment: Alignment.bottomRight, child: PlayerSettingsButton())));
|
||||
await tester.tap(find.byKey(const ValueKey('player-settings')));
|
||||
await tester.pumpWidget(app(Align(alignment: Alignment.bottomRight, child: opener())));
|
||||
await tester.tap(find.byKey(const ValueKey('open-settings')));
|
||||
await tester.pumpAndSettle();
|
||||
final card = find.byKey(const ValueKey('player-settings-card'));
|
||||
expect(card, findsOneWidget);
|
||||
@@ -81,8 +92,8 @@ void main() {
|
||||
expect(find.descendant(of: find.byKey(const ValueKey('audio-track')), matching: find.textContaining('Japanese')),
|
||||
findsOneWidget);
|
||||
|
||||
await pick(tester, 'player-design', 'uosc');
|
||||
expect(s.mpvControls.value, isTrue);
|
||||
await pick(tester, 'player-design', 'Minimal');
|
||||
expect(s.controlsSkin.value, ControlsSkin.minimal);
|
||||
await tester.tap(find.byKey(const ValueKey('progress-line')));
|
||||
await tester.pump();
|
||||
expect(s.progressLine.value, isFalse);
|
||||
@@ -98,19 +109,17 @@ void main() {
|
||||
expect(card, findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('uosc controls: the gear opens the card and keeps the controls up', (tester) async {
|
||||
testWidgets('keeps the controls up while open', (tester) async {
|
||||
bigWindow(tester);
|
||||
s.mpvControls.value = true;
|
||||
await tester.pumpWidget(app(UoscControls(
|
||||
await tester.pumpWidget(app(ControlsFrame(
|
||||
hideAfter: const Duration(seconds: 1),
|
||||
timeline: (context, expanded) => const SizedBox(height: 30),
|
||||
right: const [UoscPlayerSettingsButton()],
|
||||
builder: (context, visible) => Align(alignment: Alignment.bottomRight, child: opener()),
|
||||
)));
|
||||
final controls = tester.state<UoscControlsState>(find.byType(UoscControls));
|
||||
await tester.tap(find.byKey(const ValueKey('uosc-player-settings')));
|
||||
final controls = tester.state<ControlsFrameState>(find.byType(ControlsFrame));
|
||||
await tester.tap(find.byKey(const ValueKey('open-settings')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const ValueKey('player-settings-card')), findsOneWidget);
|
||||
expect(find.text('uosc'), findsOneWidget, reason: 'the current design');
|
||||
expect(find.text('Standard'), findsOneWidget, reason: 'the current skin');
|
||||
await tester.pump(const Duration(seconds: 3));
|
||||
expect(controls.visible, isTrue, reason: 'held while the card is open');
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
|
||||
@@ -118,49 +127,6 @@ void main() {
|
||||
await tester.pump(const Duration(seconds: 2));
|
||||
expect(controls.visible, isFalse);
|
||||
});
|
||||
|
||||
testWidgets('uosc: hidden controls keep the line unless the setting is off', (tester) async {
|
||||
await tester.pumpWidget(app(UoscControls(
|
||||
hideAfter: const Duration(milliseconds: 100),
|
||||
timeline: (context, expanded) => const SizedBox(height: 2),
|
||||
)));
|
||||
double opacity() =>
|
||||
tester.widget<AnimatedOpacity>(find.byKey(const ValueKey('uosc-timeline-wrap'))).opacity;
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
expect(opacity(), 1, reason: 'hidden, line on');
|
||||
s.setProgressLine(false);
|
||||
await tester.pump();
|
||||
expect(opacity(), 0, reason: 'hidden, line off');
|
||||
final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse);
|
||||
await mouse.addPointer(location: const Offset(400, 300));
|
||||
addTearDown(mouse.removePointer);
|
||||
await mouse.moveTo(const Offset(410, 300));
|
||||
await tester.pump();
|
||||
expect(opacity(), 1, reason: 'controls up: the full timeline');
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('standard controls: the probe follows media_kit\'s show / hide rule', (tester) async {
|
||||
final shown = ValueNotifier(false);
|
||||
addTearDown(shown.dispose);
|
||||
await tester.pumpWidget(app(ControlsHoverProbe(
|
||||
shown: shown,
|
||||
hideAfter: const Duration(seconds: 3),
|
||||
child: const SizedBox.expand(),
|
||||
)));
|
||||
await tester.pump();
|
||||
expect(shown.value, isTrue, reason: 'up on mount');
|
||||
await tester.pump(const Duration(seconds: 3));
|
||||
expect(shown.value, isFalse, reason: 'down 3 s later');
|
||||
final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse);
|
||||
await mouse.addPointer(location: const Offset(400, 300));
|
||||
addTearDown(mouse.removePointer);
|
||||
await mouse.moveTo(const Offset(420, 300));
|
||||
await tester.pump();
|
||||
expect(shown.value, isTrue, reason: 'mouse movement');
|
||||
await tester.pump(const Duration(seconds: 3));
|
||||
expect(shown.value, isFalse);
|
||||
});
|
||||
|
||||
group('Settings pages', () {
|
||||
@@ -195,16 +161,37 @@ void main() {
|
||||
expect(find.byIcon(Icons.hub_outlined), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('Player: the same rows as the ⚙, plus a pointer to it', (tester) async {
|
||||
await pumpSettings(tester, SettingsSection.player);
|
||||
expect(find.textContaining("also in the player's ⚙"), findsOneWidget);
|
||||
for (final key in ['player-design', 'progress-line', 'shader-preset', 'hwdec']) {
|
||||
testWidgets('Player: the card\'s rows, a pointer to it, and the controls\' choices', (tester) async {
|
||||
await pumpSettings(tester, SettingsSection.player, size: const Size(1280, 2400));
|
||||
expect(find.textContaining('also in the player: ⚙ → Player settings'), findsOneWidget);
|
||||
for (final key in [
|
||||
'player-design',
|
||||
'progress-line',
|
||||
'shader-preset',
|
||||
'hwdec',
|
||||
'time-display',
|
||||
'controls-size',
|
||||
'controls-hide',
|
||||
for (final c in OptionalControl.values) 'bar-${c.name}',
|
||||
]) {
|
||||
expect(find.byKey(ValueKey(key)), findsOneWidget, reason: key);
|
||||
}
|
||||
expect(find.byKey(const ValueKey('audio-track')), findsNothing, reason: 'tracks only in the player');
|
||||
expect(find.text('Standard'), findsOneWidget);
|
||||
await pick(tester, 'player-design', 'uosc');
|
||||
expect(s.mpvControls.value, isTrue);
|
||||
await pick(tester, 'player-design', 'Minimal');
|
||||
expect(s.controlsSkin.value, ControlsSkin.minimal);
|
||||
await pick(tester, 'time-display', 'Remaining');
|
||||
expect(s.timeDisplay.value, TimeDisplay.remaining);
|
||||
await pick(tester, 'controls-size', 'Large');
|
||||
expect(s.controlsSize.value, ControlsSize.large);
|
||||
await pick(tester, 'controls-hide', '5 s');
|
||||
expect(s.controlsHideSecs.value, 5);
|
||||
await tester.tap(find.byKey(const ValueKey('bar-chat')));
|
||||
await tester.pump();
|
||||
expect(s.hiddenControls.value, {OptionalControl.chat});
|
||||
await tester.tap(find.byKey(const ValueKey('bar-chat')));
|
||||
await tester.pump();
|
||||
expect(s.hiddenControls.value, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('Party & network: host defaults and recent servers with remove', (tester) async {
|
||||
|
||||
@@ -3,9 +3,9 @@ import 'dart:typed_data';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
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/timeline.dart';
|
||||
import 'package:watch_together/src/controls/frame.dart';
|
||||
import 'package:watch_together/src/controls/minimal/style.dart';
|
||||
import 'package:watch_together/src/controls/minimal/timeline.dart';
|
||||
import 'package:watch_together/src/session/health.dart';
|
||||
import 'package:watch_together/src/session/now_playing.dart';
|
||||
import 'package:watch_together/src/shell/now_playing_title.dart';
|
||||
@@ -248,21 +248,37 @@ void main() {
|
||||
expect(find.textContaining('Waiting for Someone With A Long Name 0'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('uosc top bar and start card: ${size.width.toInt()}', (tester) async {
|
||||
testWidgets('minimal top bar and start card: ${size.width.toInt()}', (tester) async {
|
||||
debugArtBytes = (hash) => png;
|
||||
await sized(
|
||||
tester,
|
||||
size,
|
||||
Stack(fit: StackFit.expand, children: [
|
||||
StartCard(now: long),
|
||||
UoscControls(
|
||||
topLeft: NowPlayingBlock(now: long),
|
||||
top: Row(mainAxisSize: MainAxisSize.min, children: [
|
||||
for (final i in [Icons.chat_bubble_outline, Icons.fullscreen, Icons.close])
|
||||
UoscButton(icon: i, tooltip: '', onPressed: () {}),
|
||||
ControlsFrame(
|
||||
builder: (context, visible) => Stack(children: [
|
||||
Positioned(
|
||||
left: 16,
|
||||
right: 10,
|
||||
top: 0,
|
||||
height: 72,
|
||||
child: Row(children: [
|
||||
Expanded(child: NowPlayingBlock(now: long)),
|
||||
for (final i in [Icons.fullscreen, Icons.close])
|
||||
MinimalButton(icon: i, tooltip: '', onPressed: () {}),
|
||||
]),
|
||||
),
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: MinimalTimeline(
|
||||
position: Duration.zero,
|
||||
duration: const Duration(minutes: 24),
|
||||
expanded: visible,
|
||||
onSeek: (_) {}),
|
||||
),
|
||||
]),
|
||||
timeline: (context, expanded) =>
|
||||
UoscTimeline(position: Duration.zero, duration: const Duration(minutes: 24), expanded: expanded, onSeek: (_) {}),
|
||||
),
|
||||
]),
|
||||
);
|
||||
|
||||
@@ -1,547 +0,0 @@
|
||||
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/controls/volume.dart' show VolumeWheel;
|
||||
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/session/player_controls.dart' show TrackPick;
|
||||
import 'package:watch_together/src/shaders/shader_presets.dart';
|
||||
import 'package:watch_together/src/shell/settings_tab.dart';
|
||||
import 'package:watch_together/src/state/settings.dart';
|
||||
import 'package:watch_together/src/update/update_controller.dart';
|
||||
import 'package:watch_together/src/widgets/app_menu.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('timeline math (uosc)', () {
|
||||
test('uosc clock: trimmed by the duration, minus kept', () {
|
||||
expect(uoscClock(m(4, 22), const Duration(hours: 1, minutes: 52)), '00:04:22');
|
||||
expect(uoscClock(-const Duration(hours: 1, minutes: 47, seconds: 30), const Duration(hours: 1, minutes: 52)),
|
||||
'-01:47:30');
|
||||
expect(uoscClock(m(6), m(24)), '06:00');
|
||||
expect(uoscClock(-m(18), m(24)), '-18:00');
|
||||
expect(uoscClock(const Duration(seconds: 7), const Duration(seconds: 40)), '07');
|
||||
expect(uoscClock(Duration.zero, m(24)), '00:00');
|
||||
});
|
||||
|
||||
test('uncached part: from the cache end (or the position) to the end', () {
|
||||
expect(uncachedFrom(position: m(6), buffered: m(9), duration: m(24)), 9 / 24);
|
||||
expect(uncachedFrom(position: m(6), buffered: m(5), duration: m(24)), 6 / 24, reason: 'stale cache end');
|
||||
expect(uncachedFrom(position: m(6), buffered: Duration.zero, duration: m(24)), isNull, reason: 'unknown');
|
||||
expect(uncachedFrom(position: m(6), buffered: m(24), duration: m(24)), isNull, reason: 'cached to the end');
|
||||
expect(uncachedFrom(position: m(6), buffered: m(9), duration: Duration.zero), isNull);
|
||||
});
|
||||
|
||||
test('cache label: seconds ahead, under 60 s, not when cached to the end', () {
|
||||
expect(cacheLabel(position: m(6), buffered: m(6, 30), duration: m(24)), '30s');
|
||||
expect(cacheLabel(position: m(6), buffered: m(6), duration: m(24)), '0s');
|
||||
expect(cacheLabel(position: m(6), buffered: m(5), duration: m(24)), '0s');
|
||||
expect(cacheLabel(position: m(6), buffered: m(7), duration: m(24)), isNull, reason: 'a minute or more');
|
||||
expect(cacheLabel(position: m(23, 40), buffered: m(24), duration: m(24)), isNull, reason: 'eof cached');
|
||||
expect(cacheLabel(position: m(6), buffered: Duration.zero, duration: m(24)), isNull);
|
||||
});
|
||||
|
||||
test('geometry: times map around the position line', () {
|
||||
const g = UoscTimelineGeometry(width: 1000, size: 40, progress: 0.5);
|
||||
expect(g.lineLeft, 499);
|
||||
expect(g.lineRight, 501);
|
||||
expect(g.x(0), 0.5);
|
||||
expect(g.x(1), 1000 - 0.5);
|
||||
expect(g.x(0.75), greaterThan(g.lineRight), reason: 'past the line');
|
||||
expect(g.fractionAt(500), 0.5, reason: 'on the line: the position');
|
||||
expect(g.fractionAt(0), 0);
|
||||
expect(g.fractionAt(1000), 1);
|
||||
expect(g.fractionAt(g.x(0.25)), moreOrLessEquals(0.25, epsilon: 0.001));
|
||||
expect(g.fractionAt(g.x(0.75)), moreOrLessEquals(0.75, epsilon: 0.001));
|
||||
// Collapsing: the line widens to 20 px over the last eighth.
|
||||
expect(UoscTimelineGeometry.sizeAt(1), 40);
|
||||
expect(UoscTimelineGeometry.sizeAt(0), 2);
|
||||
expect(UoscTimelineGeometry.lineWidthAt(40), 2);
|
||||
expect(UoscTimelineGeometry.lineWidthAt(2), 20);
|
||||
});
|
||||
});
|
||||
|
||||
group('timeline', () {
|
||||
testWidgets('position line, elapsed left, remaining right, cache label', (tester) async {
|
||||
await tester.pumpWidget(host(UoscTimeline(
|
||||
position: m(6), duration: m(24), buffered: m(6, 30), 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.geometry.lineRight - p.geometry.lineLeft, UoscTimelineSpec.lineWidth, reason: 'a line, no fill');
|
||||
expect(p.visibility, 1);
|
||||
expect(p.uncached, 6.5 / 24);
|
||||
expect(p.elapsed, '06:00');
|
||||
expect(p.remaining, '-18:00');
|
||||
expect(p.cache, '30s');
|
||||
expect(p.textOpacity, 1);
|
||||
});
|
||||
|
||||
testWidgets('a diamond on the top edge at each chapter start', (tester) async {
|
||||
await tester.pumpWidget(host(UoscTimeline(
|
||||
position: m(1), duration: m(24), chapters: chapters, onSeek: (_) {})));
|
||||
expect(painter(tester).chapters, [for (final c in chapters) c.start.inSeconds / m(24).inSeconds]);
|
||||
});
|
||||
|
||||
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);
|
||||
final g = painter(tester).geometry;
|
||||
// 01:51 of 24:00: inside "Opening".
|
||||
await mouse.moveTo(Offset(bar.left + g.x(111 / (24 * 60)) + 0.2, 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('a press on a hovered diamond jumps to its chapter', (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 at = Offset(bar.left + painter(tester).geometry.x(2 / 24) + 3, bar.top + 2);
|
||||
final mouse = await mouseAt(tester, at);
|
||||
await mouse.moveTo(at);
|
||||
await tester.pump();
|
||||
expect(painter(tester).hoveredChapter, 2);
|
||||
expect(boxText(tester, 'uosc-hover-time'), '02:00');
|
||||
await mouse.down(at);
|
||||
await mouse.up();
|
||||
await tester.pump();
|
||||
expect(seeks, [m(2)]);
|
||||
});
|
||||
|
||||
testWidgets('hidden controls: the progress line, no text, 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);
|
||||
final p = painter(tester);
|
||||
expect(p.played, 0.5);
|
||||
expect(p.visibility, 0);
|
||||
expect(p.geometry.lineWidth, UoscTimelineSpec.progressLineWidth);
|
||||
expect(p.textOpacity, 0);
|
||||
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('press seeks, every drag move seeks, release holds the target', (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 = painter(tester).geometry;
|
||||
final g0 = await tester.startGesture(Offset(bar.left + g.x(0.25), bar.center.dy), kind: PointerDeviceKind.mouse);
|
||||
expect(seeks, hasLength(1), reason: 'the press seeks');
|
||||
expect(seeks.single.inSeconds, closeTo(6 * 60, 2));
|
||||
await g0.moveTo(Offset(bar.left + bar.width / 2, bar.center.dy));
|
||||
await tester.pump();
|
||||
expect(painter(tester).elapsed, uoscClock(seeks.last, m(24)), reason: 'follows the drag');
|
||||
await g0.moveTo(Offset(bar.left + bar.width * 3 / 4, bar.center.dy));
|
||||
await g0.up();
|
||||
await tester.pump();
|
||||
expect(seeks, hasLength(3), reason: 'release at the last move: no repeat');
|
||||
expect(painter(tester).played, moreOrLessEquals(seeks.last.inMilliseconds / m(24).inMilliseconds, epsilon: 0.01),
|
||||
reason: 'shows the target until the position catches up');
|
||||
await tester.pump(const Duration(seconds: 3));
|
||||
expect(painter(tester).played, 1 / 24, reason: 'never confirmed: back to the position');
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
await tester.pump(UoscTimeline.animation);
|
||||
expect(tester.getRect(find.byKey(const ValueKey('uosc-timeline'))).height, UoscTimeline.height,
|
||||
reason: 'grown back (animated)');
|
||||
});
|
||||
|
||||
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, -60)));
|
||||
await tester.pump();
|
||||
expect(got, [0, 80, 85], reason: 'a notch is one step');
|
||||
for (var i = 0; i < 3; i++) {
|
||||
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, 80], reason: 'smooth deltas add up to a step');
|
||||
});
|
||||
});
|
||||
|
||||
group('volume wheel', () {
|
||||
testWidgets('anywhere: vertical steps; lists scroll instead; sideways is nothing', (tester) async {
|
||||
final steps = <int>[];
|
||||
final list = ScrollController();
|
||||
addTearDown(list.dispose);
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
home: VolumeWheel(
|
||||
onStep: steps.add,
|
||||
child: Stack(children: [
|
||||
const Positioned.fill(child: ColoredBox(color: Colors.black)),
|
||||
Positioned(
|
||||
right: 0,
|
||||
top: 0,
|
||||
width: 200,
|
||||
height: 200,
|
||||
child: ListView(controller: list, children: [for (var i = 0; i < 50; i++) Text('row $i')]),
|
||||
),
|
||||
]),
|
||||
),
|
||||
));
|
||||
Future<void> wheel(Offset at, Offset delta) =>
|
||||
tester.sendEventToBinding(PointerScrollEvent(kind: PointerDeviceKind.mouse, position: at, scrollDelta: delta));
|
||||
await wheel(const Offset(100, 400), const Offset(0, -60));
|
||||
await wheel(const Offset(100, 400), const Offset(0, 60));
|
||||
expect(steps, [1, -1], reason: 'up louder, down quieter');
|
||||
await wheel(const Offset(100, 400), const Offset(60, 0));
|
||||
await wheel(const Offset(100, 400), const Offset(60, -10));
|
||||
expect(steps, [1, -1], reason: 'sideways');
|
||||
await wheel(const Offset(700, 100), const Offset(0, 60));
|
||||
await tester.pump();
|
||||
expect(steps, [1, -1], reason: 'the list took it');
|
||||
expect(list.offset, greaterThan(0));
|
||||
});
|
||||
});
|
||||
|
||||
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: "Player design", Standard 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: SettingsTab(
|
||||
section: SettingsSection.player,
|
||||
onSection: (_) {},
|
||||
updates: UpdateController(currentVersion: '0.0.0-dev'),
|
||||
),
|
||||
),
|
||||
));
|
||||
final design = find.byKey(const ValueKey('player-design'));
|
||||
expect(find.text('Player design'), findsOneWidget);
|
||||
expect(tester.widget<AppSelect<bool>>(design).value, isFalse);
|
||||
await tester.tap(design);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('uosc').last);
|
||||
await tester.pumpAndSettle();
|
||||
expect(s.mpvControls.value, isTrue);
|
||||
expect(tester.widget<AppSelect<bool>>(design).value, isTrue);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user