video: desktop controls, shared scrubber, lifecycle guard and first-frame gate; zoomable and adapter tweaks
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9b1c8fa368
commit
c0ab4a828b
12 files changed
+936
-9
No files matched your search
@@ -0,0 +1,43 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:leaf_widgets/leaf_widgets.dart';
|
||||
|
||||
void main() {
|
||||
test('immediate entry while slots are free', () async {
|
||||
final gate = FirstFrameGate(maxConcurrent: 2);
|
||||
await gate.enter();
|
||||
await gate.enter();
|
||||
expect(gate.activeCount, 2);
|
||||
expect(gate.waitingCount, 0);
|
||||
gate.exit();
|
||||
gate.exit();
|
||||
expect(gate.activeCount, 0);
|
||||
});
|
||||
|
||||
test('third entrant waits, exit hands the slot over', () async {
|
||||
final gate = FirstFrameGate(maxConcurrent: 2);
|
||||
await gate.enter();
|
||||
await gate.enter();
|
||||
|
||||
var thirdIn = false;
|
||||
final pending = gate.enter().then((_) => thirdIn = true);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(thirdIn, isFalse);
|
||||
expect(gate.waitingCount, 1);
|
||||
|
||||
gate.exit();
|
||||
await pending;
|
||||
expect(thirdIn, isTrue);
|
||||
expect(gate.activeCount, 2);
|
||||
|
||||
gate.exit();
|
||||
gate.exit();
|
||||
expect(gate.activeCount, 0);
|
||||
});
|
||||
|
||||
test('exit without enter never goes negative', () {
|
||||
final gate = FirstFrameGate(maxConcurrent: 1);
|
||||
gate.exit();
|
||||
expect(gate.activeCount, 0);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:leaf_widgets/leaf_widgets.dart';
|
||||
|
||||
class FakeAdapter extends VideoPlayerAdapter {
|
||||
bool playing = false;
|
||||
bool disposed = false;
|
||||
|
||||
final _positionCtrl = StreamController<Duration>.broadcast();
|
||||
final _bufferCtrl = StreamController<Duration>.broadcast();
|
||||
final _playingCtrl = StreamController<bool>.broadcast();
|
||||
final _readyCtrl = StreamController<void>.broadcast();
|
||||
final _errorCtrl = StreamController<Object>.broadcast();
|
||||
|
||||
@override
|
||||
Widget get surface => const ColoredBox(color: Color(0xFF332211));
|
||||
|
||||
@override
|
||||
Future<void> load({required String url, Map<String, String>? headers}) async {}
|
||||
|
||||
void emitPlaying(bool value) {
|
||||
playing = value;
|
||||
_playingCtrl.add(value);
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<Duration> get positionStream => _positionCtrl.stream;
|
||||
@override
|
||||
Stream<Duration> get bufferStream => _bufferCtrl.stream;
|
||||
@override
|
||||
Stream<bool> get playingStream => _playingCtrl.stream;
|
||||
@override
|
||||
Stream<void> get readyStream => _readyCtrl.stream;
|
||||
@override
|
||||
Stream<Object> get errorStream => _errorCtrl.stream;
|
||||
|
||||
@override
|
||||
Duration get position => Duration.zero;
|
||||
@override
|
||||
Duration get duration => const Duration(seconds: 60);
|
||||
@override
|
||||
Duration get buffer => Duration.zero;
|
||||
@override
|
||||
bool get isPlaying => playing;
|
||||
|
||||
@override
|
||||
void play() => emitPlaying(true);
|
||||
@override
|
||||
void pause() => emitPlaying(false);
|
||||
@override
|
||||
void playOrPause() => playing ? pause() : play();
|
||||
@override
|
||||
void seek(Duration target) {}
|
||||
@override
|
||||
void dispose() => disposed = true;
|
||||
}
|
||||
|
||||
class FakeVolumeAdapter extends FakeAdapter implements HasVolume {
|
||||
double _volume = 80;
|
||||
final setVolumes = <double>[];
|
||||
final _volumeCtrl = StreamController<double>.broadcast();
|
||||
|
||||
@override
|
||||
Stream<double> get volumeStream => _volumeCtrl.stream;
|
||||
@override
|
||||
double get volume => _volume;
|
||||
@override
|
||||
void setVolume(double volume) {
|
||||
setVolumes.add(volume);
|
||||
_volume = volume;
|
||||
_volumeCtrl.add(volume);
|
||||
}
|
||||
}
|
||||
|
||||
Widget harness(VideoPlayerAdapter adapter, {Duration hideDelay = const Duration(milliseconds: 500)}) {
|
||||
return MaterialApp(
|
||||
home: Scaffold(
|
||||
body: LeafDesktopVideoControls(adapter: adapter, hideDelay: hideDelay),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
AnimatedOpacity barOf(WidgetTester tester) {
|
||||
final found = find.byWidgetPredicate((w) => w is AnimatedOpacity && w.child is GestureDetector);
|
||||
return tester.widget<AnimatedOpacity>(found);
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('single click anywhere toggles playback', (tester) async {
|
||||
final adapter = FakeAdapter();
|
||||
await tester.pumpWidget(harness(adapter));
|
||||
expect(adapter.playing, isFalse);
|
||||
|
||||
await tester.tap(find.byType(LeafDesktopVideoControls));
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
expect(adapter.playing, isTrue);
|
||||
|
||||
await tester.tap(find.byType(LeafDesktopVideoControls));
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
expect(adapter.playing, isFalse);
|
||||
});
|
||||
|
||||
testWidgets('bar hides after inactivity while playing, shows on pause', (tester) async {
|
||||
final adapter = FakeAdapter()..playing = true;
|
||||
await tester.pumpWidget(harness(adapter));
|
||||
expect(barOf(tester).opacity, 1.0);
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 600));
|
||||
expect(barOf(tester).opacity, 0.0);
|
||||
|
||||
adapter.emitPlaying(false);
|
||||
await tester.pump();
|
||||
expect(barOf(tester).opacity, 1.0);
|
||||
});
|
||||
|
||||
testWidgets('bar stays up while the cursor is active', (tester) async {
|
||||
final adapter = FakeAdapter()..playing = true;
|
||||
await tester.pumpWidget(harness(adapter));
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
expect(barOf(tester).opacity, 1.0);
|
||||
|
||||
final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse);
|
||||
await mouse.moveTo(const Offset(400, 300));
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
expect(barOf(tester).opacity, 1.0);
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 600));
|
||||
expect(barOf(tester).opacity, 0.0);
|
||||
});
|
||||
|
||||
testWidgets('volume UI only with HasVolume; mute toggles', (tester) async {
|
||||
await tester.pumpWidget(harness(FakeAdapter()));
|
||||
expect(find.byType(Slider), findsNothing);
|
||||
|
||||
final volume = FakeVolumeAdapter();
|
||||
await tester.pumpWidget(harness(volume));
|
||||
expect(find.byType(Slider), findsOneWidget);
|
||||
expect(find.byIcon(Icons.volume_up), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byIcon(Icons.volume_up));
|
||||
await tester.pump();
|
||||
expect(volume.setVolumes, contains(0.0));
|
||||
expect(find.byIcon(Icons.volume_off), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byIcon(Icons.volume_off));
|
||||
await tester.pump();
|
||||
expect(volume.setVolumes.last, 80.0);
|
||||
});
|
||||
|
||||
testWidgets('hovering the scrubber shows the time bubble before clicking', (tester) async {
|
||||
final adapter = FakeAdapter();
|
||||
await tester.pumpWidget(harness(adapter));
|
||||
|
||||
final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse);
|
||||
await mouse.moveTo(tester.getCenter(find.byType(LeafScrubber)));
|
||||
await tester.pump();
|
||||
expect(find.text('00:30'), findsOneWidget);
|
||||
|
||||
await mouse.moveTo(Offset.zero);
|
||||
await tester.pump();
|
||||
expect(find.text('00:30'), findsNothing);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:leaf_widgets/leaf_widgets.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('backgrounding pauses a playing child, foreground resumes it', (tester) async {
|
||||
var pauses = 0;
|
||||
var resumes = 0;
|
||||
var playing = true;
|
||||
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
home: StatefulBuilder(
|
||||
builder: (context, setState) => VideoLifecycleGuard(
|
||||
isPlaying: playing,
|
||||
onPause: () {
|
||||
pauses++;
|
||||
setState(() => playing = false);
|
||||
},
|
||||
onResume: () {
|
||||
resumes++;
|
||||
setState(() => playing = true);
|
||||
},
|
||||
child: const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
));
|
||||
|
||||
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused);
|
||||
await tester.pump();
|
||||
expect(pauses, 1);
|
||||
|
||||
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
|
||||
await tester.pump();
|
||||
expect(resumes, 1);
|
||||
});
|
||||
|
||||
testWidgets('idle child gets no calls on background round-trip', (tester) async {
|
||||
var pauses = 0;
|
||||
var resumes = 0;
|
||||
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
home: VideoLifecycleGuard(
|
||||
isPlaying: false,
|
||||
onPause: () => pauses++,
|
||||
onResume: () => resumes++,
|
||||
child: const SizedBox.shrink(),
|
||||
),
|
||||
));
|
||||
|
||||
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused);
|
||||
await tester.pump();
|
||||
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
|
||||
await tester.pump();
|
||||
expect(pauses, 0);
|
||||
expect(resumes, 0);
|
||||
});
|
||||
|
||||
testWidgets('covering route pauses, uncovering resumes', (tester) async {
|
||||
var pauses = 0;
|
||||
var resumes = 0;
|
||||
var playing = true;
|
||||
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
home: StatefulBuilder(
|
||||
builder: (context, setState) => VideoLifecycleGuard(
|
||||
isPlaying: playing,
|
||||
onPause: () {
|
||||
pauses++;
|
||||
setState(() => playing = false);
|
||||
},
|
||||
onResume: () {
|
||||
resumes++;
|
||||
setState(() => playing = true);
|
||||
},
|
||||
child: Builder(
|
||||
builder: (context) => TextButton(
|
||||
onPressed: () => showDialog(context: context, builder: (_) => const Text('cover')),
|
||||
child: const Text('open'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
));
|
||||
|
||||
await tester.tap(find.text('open'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('cover'), findsOneWidget);
|
||||
expect(pauses, 1);
|
||||
|
||||
Navigator.of(tester.element(find.text('cover'))).pop();
|
||||
await tester.pumpAndSettle();
|
||||
expect(resumes, 1);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user