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:
foxyzandClaude Opus 5.5 committed 2026-10-13 01:28:25 +03:00
1 parent 9b1c8fa368
commit c0ab4a828b
12 files changed
+936 -9

No files matched your search

+43
View File
@@ -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);
});
}
+95
View File
@@ -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);
});
}