- video_player: ExoPlayer (Android) / AVPlayer (iOS/macOS) backend with PixelBufferSink, method-channel adapter, Dart-side XVideoPlayer + testing fake. - insets: XInsets singleton + XAnimatedInsets widget lerp the system viewPadding over 220ms so OS bar visibility toggles (immersiveSticky <-> edgeToEdge) slide bottom-/top-anchored UI into place instead of snapping by the nav-bar / status-bar height.
200 lines
6.4 KiB
Dart
200 lines
6.4 KiB
Dart
import 'dart:io' as io;
|
|
import 'dart:ui' show Size;
|
|
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:ux/testing.dart';
|
|
import 'package:ux/ux.dart';
|
|
|
|
void main() {
|
|
TestWidgetsFlutterBinding.ensureInitialized();
|
|
|
|
late FakeXVideoPlayerBackend fake;
|
|
|
|
setUp(() {
|
|
fake = FakeXVideoPlayerBackend(
|
|
size: const Size(720, 1280),
|
|
duration: const Duration(seconds: 30),
|
|
);
|
|
XVideoPlayerBackend.instance = fake;
|
|
});
|
|
|
|
tearDown(() {
|
|
XVideoPlayerBackend.instance = MethodChannelXVideoPlayerBackend();
|
|
});
|
|
|
|
test('initialize creates the native instance, subscribes to events, '
|
|
'and populates size + duration', () async {
|
|
final ctrl = XVideoPlayerController.file(io.File('/tmp/fake.mp4'));
|
|
addTearDown(ctrl.dispose);
|
|
|
|
expect(ctrl.value.isInitialized, isFalse);
|
|
expect(ctrl.value.size, Size.zero);
|
|
|
|
await ctrl.initialize();
|
|
|
|
expect(fake.createCalls.single, 'file:///tmp/fake.mp4');
|
|
expect(fake.initializeCalls.single, 1);
|
|
expect(ctrl.handle, 1);
|
|
expect(ctrl.textureId, 101);
|
|
expect(ctrl.value.isInitialized, isTrue);
|
|
expect(ctrl.value.size, const Size(720, 1280));
|
|
expect(ctrl.value.duration, const Duration(seconds: 30));
|
|
expect(ctrl.value.aspectRatio, 720.0 / 1280.0);
|
|
});
|
|
|
|
test('play / pause flip isPlaying and forward to the backend', () async {
|
|
final ctrl = XVideoPlayerController.file(io.File('/tmp/fake.mp4'));
|
|
addTearDown(ctrl.dispose);
|
|
await ctrl.initialize();
|
|
|
|
await ctrl.play();
|
|
expect(fake.playCalls.single, 1);
|
|
expect(ctrl.value.isPlaying, isTrue);
|
|
|
|
await ctrl.pause();
|
|
expect(fake.pauseCalls.single, 1);
|
|
expect(ctrl.value.isPlaying, isFalse);
|
|
});
|
|
|
|
test('seekTo clamps to [0, duration] and forwards the clamped value',
|
|
() async {
|
|
final ctrl = XVideoPlayerController.file(io.File('/tmp/fake.mp4'));
|
|
addTearDown(ctrl.dispose);
|
|
await ctrl.initialize();
|
|
|
|
await ctrl.seekTo(const Duration(seconds: -5));
|
|
expect(fake.seekToCalls.last.position, Duration.zero);
|
|
expect(ctrl.value.position, Duration.zero);
|
|
|
|
await ctrl.seekTo(const Duration(minutes: 99));
|
|
expect(fake.seekToCalls.last.position, const Duration(seconds: 30));
|
|
expect(ctrl.value.position, const Duration(seconds: 30));
|
|
|
|
await ctrl.seekTo(const Duration(seconds: 10));
|
|
expect(fake.seekToCalls.last.position, const Duration(seconds: 10));
|
|
});
|
|
|
|
test('setLooping / setVolume / setPlaybackSpeed clamp and forward',
|
|
() async {
|
|
final ctrl = XVideoPlayerController.file(io.File('/tmp/fake.mp4'));
|
|
addTearDown(ctrl.dispose);
|
|
await ctrl.initialize();
|
|
|
|
await ctrl.setLooping(true);
|
|
expect(fake.setLoopingCalls.single, (handle: 1, loop: true));
|
|
expect(ctrl.value.isLooping, isTrue);
|
|
|
|
await ctrl.setVolume(1.5);
|
|
expect(fake.setVolumeCalls.single.volume, 1.0);
|
|
expect(ctrl.value.volume, 1.0);
|
|
|
|
await ctrl.setVolume(-1.0);
|
|
expect(fake.setVolumeCalls.last.volume, 0.0);
|
|
expect(ctrl.value.volume, 0.0);
|
|
|
|
await ctrl.setPlaybackSpeed(0.0);
|
|
expect(fake.setPlaybackSpeedCalls.last.rate, 0.25);
|
|
expect(ctrl.value.playbackSpeed, 0.25);
|
|
|
|
await ctrl.setPlaybackSpeed(10.0);
|
|
expect(fake.setPlaybackSpeedCalls.last.rate, 4.0);
|
|
expect(ctrl.value.playbackSpeed, 4.0);
|
|
});
|
|
|
|
test('stateChanged events flow into value (position, buffered, isPlaying)',
|
|
() async {
|
|
final ctrl = XVideoPlayerController.file(io.File('/tmp/fake.mp4'));
|
|
addTearDown(ctrl.dispose);
|
|
await ctrl.initialize();
|
|
|
|
fake.emitState(
|
|
1,
|
|
isPlaying: true,
|
|
position: const Duration(seconds: 5),
|
|
buffered: [
|
|
XDurationRange(Duration.zero, const Duration(seconds: 20)),
|
|
],
|
|
);
|
|
await Future<void>.delayed(Duration.zero);
|
|
|
|
expect(ctrl.value.isPlaying, isTrue);
|
|
expect(ctrl.value.position, const Duration(seconds: 5));
|
|
expect(ctrl.value.buffered, hasLength(1));
|
|
expect(ctrl.value.buffered.single.end, const Duration(seconds: 20));
|
|
});
|
|
|
|
test('sizeChanged events update value.size', () async {
|
|
final ctrl = XVideoPlayerController.file(io.File('/tmp/fake.mp4'));
|
|
addTearDown(ctrl.dispose);
|
|
await ctrl.initialize();
|
|
|
|
fake.emitSizeChanged(1, const Size(1280, 720));
|
|
await Future<void>.delayed(Duration.zero);
|
|
|
|
expect(ctrl.value.size, const Size(1280, 720));
|
|
expect(ctrl.value.aspectRatio, 1280.0 / 720.0);
|
|
});
|
|
|
|
test('error events set hasError and stop further state mutations', () async {
|
|
final ctrl = XVideoPlayerController.file(io.File('/tmp/fake.mp4'));
|
|
addTearDown(ctrl.dispose);
|
|
await ctrl.initialize();
|
|
|
|
fake.emitError(1, 'decode_failed', 'codec init failure');
|
|
await Future<void>.delayed(Duration.zero);
|
|
|
|
expect(ctrl.value.hasError, isTrue);
|
|
expect(ctrl.value.errorDescription, 'codec init failure');
|
|
});
|
|
|
|
test('completed event with looping=false flips isPlaying off; '
|
|
'with looping=true it stays on (native loop is silent)', () async {
|
|
final ctrl = XVideoPlayerController.file(io.File('/tmp/fake.mp4'));
|
|
addTearDown(ctrl.dispose);
|
|
await ctrl.initialize();
|
|
|
|
await ctrl.play();
|
|
expect(ctrl.value.isPlaying, isTrue);
|
|
|
|
fake.emitCompleted(1);
|
|
await Future<void>.delayed(Duration.zero);
|
|
expect(ctrl.value.isPlaying, isFalse);
|
|
|
|
await ctrl.play();
|
|
await ctrl.setLooping(true);
|
|
fake.emitCompleted(1);
|
|
await Future<void>.delayed(Duration.zero);
|
|
// Looping mode: completion is a no-op on the value; native handles
|
|
// the rewind. value.isPlaying remains true.
|
|
expect(ctrl.value.isPlaying, isTrue);
|
|
});
|
|
|
|
test('dispose is idempotent and prevents further operations', () async {
|
|
final ctrl = XVideoPlayerController.file(io.File('/tmp/fake.mp4'));
|
|
await ctrl.initialize();
|
|
await ctrl.dispose();
|
|
await ctrl.dispose(); // second dispose is a no-op
|
|
expect(fake.disposeCalls, [1]);
|
|
expect(
|
|
() => ctrl.play(),
|
|
throwsA(isA<XVideoPlayerException>()),
|
|
);
|
|
});
|
|
|
|
test('initialize failure tears down the native instance so a retry '
|
|
'is not blocked by a leaked claim', () async {
|
|
fake.initializeError = const XVideoPlayerException('decode_failed');
|
|
final ctrl = XVideoPlayerController.file(io.File('/tmp/fake.mp4'));
|
|
addTearDown(ctrl.dispose);
|
|
|
|
await expectLater(
|
|
ctrl.initialize(),
|
|
throwsA(isA<XVideoPlayerException>()),
|
|
);
|
|
|
|
// The controller has a handle from `create` even though `initialize`
|
|
// threw; dispose must have been called to release it.
|
|
expect(fake.disposeCalls.single, 1);
|
|
});
|
|
}
|