video_player + insets: native playback backend + animated viewPadding
- 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.
This commit is contained in:
155
lib/src/testing/fake_video_player.dart
Normal file
155
lib/src/testing/fake_video_player.dart
Normal file
@@ -0,0 +1,155 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui' show Size;
|
||||
|
||||
import 'package:ux/src/video/x_video_player.dart' show XDurationRange, XVideoPlayerException;
|
||||
import 'package:ux/src/video/x_video_player_backend.dart';
|
||||
|
||||
/// In-memory backend for [XVideoPlayerController] tests. Swap in via
|
||||
/// `XVideoPlayerBackend.instance = FakeXVideoPlayerBackend()` before
|
||||
/// any UI mounts; restore with
|
||||
/// `XVideoPlayerBackend.instance = MethodChannelXVideoPlayerBackend()`
|
||||
/// in `tearDown`.
|
||||
class FakeXVideoPlayerBackend implements XVideoPlayerBackend {
|
||||
FakeXVideoPlayerBackend({
|
||||
this.size = const Size(720, 1280),
|
||||
this.duration = const Duration(seconds: 10),
|
||||
this.rotationQuarterTurns = 0,
|
||||
this.textureIdSeed = 100,
|
||||
});
|
||||
|
||||
// ---- captured calls ---------------------------------------------
|
||||
|
||||
final List<String> createCalls = [];
|
||||
final List<int> initializeCalls = [];
|
||||
final List<int> disposeCalls = [];
|
||||
final List<int> playCalls = [];
|
||||
final List<int> pauseCalls = [];
|
||||
final List<({int handle, Duration position})> seekToCalls = [];
|
||||
final List<({int handle, bool loop})> setLoopingCalls = [];
|
||||
final List<({int handle, double volume})> setVolumeCalls = [];
|
||||
final List<({int handle, double rate})> setPlaybackSpeedCalls = [];
|
||||
|
||||
// ---- configurable returns ---------------------------------------
|
||||
|
||||
Size size;
|
||||
Duration duration;
|
||||
int rotationQuarterTurns;
|
||||
int textureIdSeed;
|
||||
|
||||
/// If non-null, [create] throws this exception. Drives error paths.
|
||||
XVideoPlayerException? createError;
|
||||
XVideoPlayerException? initializeError;
|
||||
|
||||
// ---- internal ---------------------------------------------------
|
||||
|
||||
int _nextHandle = 1;
|
||||
final Map<int, StreamController<XVideoPlayerEvent>> _events = {};
|
||||
|
||||
StreamController<XVideoPlayerEvent> _controllerFor(int handle) {
|
||||
return _events.putIfAbsent(
|
||||
handle,
|
||||
() => StreamController<XVideoPlayerEvent>.broadcast(),
|
||||
);
|
||||
}
|
||||
|
||||
// ---- XVideoPlayerBackend ----------------------------------------
|
||||
|
||||
@override
|
||||
Future<XVideoPlayerCreateResult> create({required String uri}) async {
|
||||
createCalls.add(uri);
|
||||
if (createError != null) throw createError!;
|
||||
final handle = _nextHandle++;
|
||||
_controllerFor(handle); // pre-warm
|
||||
return XVideoPlayerCreateResult(
|
||||
handle: handle,
|
||||
textureId: textureIdSeed + handle,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<XVideoPlayerMetadata> initialize(int handle) async {
|
||||
initializeCalls.add(handle);
|
||||
if (initializeError != null) throw initializeError!;
|
||||
return XVideoPlayerMetadata(
|
||||
size: size,
|
||||
duration: duration,
|
||||
rotationQuarterTurns: rotationQuarterTurns,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> disposeInstance(int handle) async {
|
||||
disposeCalls.add(handle);
|
||||
final controller = _events.remove(handle);
|
||||
await controller?.close();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> play(int handle) async {
|
||||
playCalls.add(handle);
|
||||
emitState(handle, isPlaying: true);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> pause(int handle) async {
|
||||
pauseCalls.add(handle);
|
||||
emitState(handle, isPlaying: false);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> seekTo(int handle, Duration position) async {
|
||||
seekToCalls.add((handle: handle, position: position));
|
||||
emitState(handle, position: position);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setLooping(int handle, bool loop) async {
|
||||
setLoopingCalls.add((handle: handle, loop: loop));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setVolume(int handle, double volume) async {
|
||||
setVolumeCalls.add((handle: handle, volume: volume));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setPlaybackSpeed(int handle, double rate) async {
|
||||
setPlaybackSpeedCalls.add((handle: handle, rate: rate));
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<XVideoPlayerEvent> events(int handle) =>
|
||||
_controllerFor(handle).stream;
|
||||
|
||||
// ---- test helpers ------------------------------------------------
|
||||
|
||||
void emitState(
|
||||
int handle, {
|
||||
bool? isPlaying,
|
||||
bool? isBuffering,
|
||||
Duration? position,
|
||||
List<XDurationRange>? buffered,
|
||||
}) {
|
||||
_controllerFor(handle).add(
|
||||
XVideoPlayerStateChanged(
|
||||
handle,
|
||||
isPlaying: isPlaying,
|
||||
isBuffering: isBuffering,
|
||||
position: position,
|
||||
buffered: buffered,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void emitSizeChanged(int handle, Size size) {
|
||||
_controllerFor(handle).add(XVideoPlayerSizeChanged(handle, size));
|
||||
}
|
||||
|
||||
void emitCompleted(int handle) {
|
||||
_controllerFor(handle).add(XVideoPlayerCompleted(handle));
|
||||
}
|
||||
|
||||
void emitError(int handle, String code, [String? description]) {
|
||||
_controllerFor(handle).add(XVideoPlayerError(handle, code, description));
|
||||
}
|
||||
}
|
||||
325
lib/src/video/x_video_player.dart
Normal file
325
lib/src/video/x_video_player.dart
Normal file
@@ -0,0 +1,325 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io' as io;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart' show PlatformException;
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'x_video_player_backend.dart';
|
||||
import 'x_video_player_view.dart' show XVideoPlayerView;
|
||||
|
||||
/// Throws when a [XVideoPlayerController] call fails on the platform
|
||||
/// side. Maps from native `PlatformException` codes.
|
||||
class XVideoPlayerException implements Exception {
|
||||
const XVideoPlayerException(this.code, [this.description]);
|
||||
|
||||
final String code;
|
||||
final String? description;
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'XVideoPlayerException($code${description == null ? '' : ': $description'})';
|
||||
}
|
||||
|
||||
/// One contiguous range of buffered playback time. Matches the shape
|
||||
/// `package:video_player` exposes today so call sites that read it
|
||||
/// (gallery scrub bar) port mechanically.
|
||||
class XDurationRange {
|
||||
const XDurationRange(this.start, this.end);
|
||||
final Duration start;
|
||||
final Duration end;
|
||||
}
|
||||
|
||||
/// Immutable snapshot of an [XVideoPlayerController]'s state.
|
||||
class XVideoPlayerValue {
|
||||
const XVideoPlayerValue({
|
||||
this.isInitialized = false,
|
||||
this.isPlaying = false,
|
||||
this.isBuffering = false,
|
||||
this.isLooping = false,
|
||||
this.rotationQuarterTurns = 0,
|
||||
this.position = Duration.zero,
|
||||
this.duration = Duration.zero,
|
||||
this.size = Size.zero,
|
||||
this.volume = 1.0,
|
||||
this.playbackSpeed = 1.0,
|
||||
this.buffered = const [],
|
||||
this.errorDescription,
|
||||
});
|
||||
|
||||
static const uninitialized = XVideoPlayerValue();
|
||||
|
||||
final bool isInitialized;
|
||||
final bool isPlaying;
|
||||
final bool isBuffering;
|
||||
final bool isLooping;
|
||||
|
||||
/// Quarter-turns of clockwise rotation [XVideoPlayerView] applies
|
||||
/// to the underlying `Texture`. Populated from the native side at
|
||||
/// initialize-time — Android always 0 (codec + GLES blit handle
|
||||
/// rotation natively), Apple non-zero when the asset's
|
||||
/// `preferredTransform` rotates the natural pixel buffer.
|
||||
final int rotationQuarterTurns;
|
||||
|
||||
/// Last known playhead position. Reported by the native side on a
|
||||
/// fixed cadence (~10 Hz) — read this through a
|
||||
/// [ValueListenableBuilder] / `addListener` for live scrubbing.
|
||||
final Duration position;
|
||||
|
||||
/// Total duration of the media. `Duration.zero` until [isInitialized].
|
||||
final Duration duration;
|
||||
|
||||
/// Natural pixel dimensions of the video frame. `Size.zero` until
|
||||
/// the native side reports metadata.
|
||||
final Size size;
|
||||
|
||||
final double volume;
|
||||
final double playbackSpeed;
|
||||
|
||||
/// One range per contiguous buffered span. Empty before init; on
|
||||
/// local-file playback this is typically a single `[0..duration]`
|
||||
/// range that appears once the first frame is decoded.
|
||||
final List<XDurationRange> buffered;
|
||||
|
||||
final String? errorDescription;
|
||||
|
||||
bool get hasError => errorDescription != null;
|
||||
|
||||
/// Aspect ratio with a graceful fall-through to 1:1 before the first
|
||||
/// frame is decoded. Compose / gallery wrap the player in
|
||||
/// `AspectRatio` and would otherwise divide by zero.
|
||||
double get aspectRatio {
|
||||
if (size.width <= 0 || size.height <= 0) return 1.0;
|
||||
return size.width / size.height;
|
||||
}
|
||||
|
||||
XVideoPlayerValue copyWith({
|
||||
bool? isInitialized,
|
||||
bool? isPlaying,
|
||||
bool? isBuffering,
|
||||
bool? isLooping,
|
||||
int? rotationQuarterTurns,
|
||||
Duration? position,
|
||||
Duration? duration,
|
||||
Size? size,
|
||||
double? volume,
|
||||
double? playbackSpeed,
|
||||
List<XDurationRange>? buffered,
|
||||
Object? errorDescription = _unset,
|
||||
}) =>
|
||||
XVideoPlayerValue(
|
||||
isInitialized: isInitialized ?? this.isInitialized,
|
||||
isPlaying: isPlaying ?? this.isPlaying,
|
||||
isBuffering: isBuffering ?? this.isBuffering,
|
||||
isLooping: isLooping ?? this.isLooping,
|
||||
rotationQuarterTurns:
|
||||
rotationQuarterTurns ?? this.rotationQuarterTurns,
|
||||
position: position ?? this.position,
|
||||
duration: duration ?? this.duration,
|
||||
size: size ?? this.size,
|
||||
volume: volume ?? this.volume,
|
||||
playbackSpeed: playbackSpeed ?? this.playbackSpeed,
|
||||
buffered: buffered ?? this.buffered,
|
||||
errorDescription: identical(errorDescription, _unset)
|
||||
? this.errorDescription
|
||||
: errorDescription as String?,
|
||||
);
|
||||
|
||||
static const _unset = Object();
|
||||
}
|
||||
|
||||
/// Owns one native video-player session. The surface mirrors the
|
||||
/// subset of `package:video_player`'s `VideoPlayerController` the app
|
||||
/// currently uses — only the `.file(...)` constructor, the standard
|
||||
/// playback controls, and the `value` getters compose / gallery /
|
||||
/// outgoing-media read.
|
||||
///
|
||||
/// Lifecycle: construct → [initialize] → use → [dispose]. After
|
||||
/// [dispose] every other method throws [XVideoPlayerException("disposed")].
|
||||
class XVideoPlayerController extends ChangeNotifier
|
||||
implements ValueListenable<XVideoPlayerValue> {
|
||||
XVideoPlayerController.file(io.File file) : _file = file;
|
||||
|
||||
final io.File _file;
|
||||
|
||||
XVideoPlayerValue _value = XVideoPlayerValue.uninitialized;
|
||||
|
||||
@override
|
||||
XVideoPlayerValue get value => _value;
|
||||
|
||||
set _setValue(XVideoPlayerValue next) {
|
||||
if (identical(_value, next)) return;
|
||||
_value = next;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
int? _handle;
|
||||
int? _textureId;
|
||||
StreamSubscription<XVideoPlayerEvent>? _eventsSub;
|
||||
bool _disposed = false;
|
||||
Completer<void>? _initCompleter;
|
||||
|
||||
/// Native handle once [initialize] has resolved. Null otherwise.
|
||||
int? get handle => _handle;
|
||||
|
||||
/// Texture id on Apple platforms. On Android playback uses a platform
|
||||
/// view (no texture id), and this stays null.
|
||||
int? get textureId => _textureId;
|
||||
|
||||
/// Configure the native session and load the file. Resolves once the
|
||||
/// codec is ready and `value.size` / `value.duration` are populated.
|
||||
/// Throws if the file is missing, unreadable, or unsupported.
|
||||
Future<void> initialize() {
|
||||
_throwIfDisposed('initialize');
|
||||
final existing = _initCompleter;
|
||||
if (existing != null) return existing.future;
|
||||
final completer = Completer<void>();
|
||||
_initCompleter = completer;
|
||||
_initInternal().then(
|
||||
(_) => completer.complete(),
|
||||
onError: completer.completeError,
|
||||
);
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
Future<void> _initInternal() async {
|
||||
try {
|
||||
final result = await XVideoPlayerBackend.instance.create(
|
||||
uri: 'file://${_file.path}',
|
||||
);
|
||||
_handle = result.handle;
|
||||
_textureId = result.textureId;
|
||||
_eventsSub = XVideoPlayerBackend.instance.events(result.handle).listen(
|
||||
_onEvent,
|
||||
onError: (Object error, StackTrace? stack) {
|
||||
_setValue = _value.copyWith(errorDescription: error.toString());
|
||||
},
|
||||
);
|
||||
final metadata =
|
||||
await XVideoPlayerBackend.instance.initialize(result.handle);
|
||||
_setValue = _value.copyWith(
|
||||
isInitialized: true,
|
||||
size: metadata.size,
|
||||
duration: metadata.duration,
|
||||
rotationQuarterTurns: metadata.rotationQuarterTurns,
|
||||
);
|
||||
} catch (_) {
|
||||
// Tear down anything the native side allocated mid-failure so a
|
||||
// retry isn't blocked by leaked resources.
|
||||
await dispose();
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
void _onEvent(XVideoPlayerEvent event) {
|
||||
switch (event) {
|
||||
case XVideoPlayerStateChanged(
|
||||
:final isPlaying,
|
||||
:final isBuffering,
|
||||
:final position,
|
||||
:final buffered
|
||||
):
|
||||
_setValue = _value.copyWith(
|
||||
isPlaying: isPlaying ?? _value.isPlaying,
|
||||
isBuffering: isBuffering ?? _value.isBuffering,
|
||||
position: position ?? _value.position,
|
||||
buffered: buffered ?? _value.buffered,
|
||||
);
|
||||
case XVideoPlayerSizeChanged(:final size):
|
||||
_setValue = _value.copyWith(size: size);
|
||||
case XVideoPlayerCompleted():
|
||||
if (!_value.isLooping) {
|
||||
_setValue = _value.copyWith(isPlaying: false);
|
||||
}
|
||||
case XVideoPlayerError(:final code, :final description):
|
||||
_setValue = _value.copyWith(errorDescription: description ?? code);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> play() async {
|
||||
final handle = _requireHandle('play');
|
||||
await XVideoPlayerBackend.instance.play(handle);
|
||||
_setValue = _value.copyWith(isPlaying: true);
|
||||
}
|
||||
|
||||
Future<void> pause() async {
|
||||
final handle = _requireHandle('pause');
|
||||
await XVideoPlayerBackend.instance.pause(handle);
|
||||
_setValue = _value.copyWith(isPlaying: false);
|
||||
}
|
||||
|
||||
Future<void> seekTo(Duration position) async {
|
||||
final handle = _requireHandle('seekTo');
|
||||
final clamped = position < Duration.zero
|
||||
? Duration.zero
|
||||
: position > _value.duration && _value.duration > Duration.zero
|
||||
? _value.duration
|
||||
: position;
|
||||
await XVideoPlayerBackend.instance.seekTo(handle, clamped);
|
||||
_setValue = _value.copyWith(position: clamped);
|
||||
}
|
||||
|
||||
Future<void> setLooping(bool loop) async {
|
||||
final handle = _requireHandle('setLooping');
|
||||
await XVideoPlayerBackend.instance.setLooping(handle, loop);
|
||||
_setValue = _value.copyWith(isLooping: loop);
|
||||
}
|
||||
|
||||
Future<void> setVolume(double volume) async {
|
||||
final handle = _requireHandle('setVolume');
|
||||
final clamped = volume.clamp(0.0, 1.0).toDouble();
|
||||
await XVideoPlayerBackend.instance.setVolume(handle, clamped);
|
||||
_setValue = _value.copyWith(volume: clamped);
|
||||
}
|
||||
|
||||
Future<void> setPlaybackSpeed(double rate) async {
|
||||
final handle = _requireHandle('setPlaybackSpeed');
|
||||
final clamped = rate.clamp(0.25, 4.0).toDouble();
|
||||
await XVideoPlayerBackend.instance.setPlaybackSpeed(handle, clamped);
|
||||
_setValue = _value.copyWith(playbackSpeed: clamped);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
final handle = _handle;
|
||||
_handle = null;
|
||||
final sub = _eventsSub;
|
||||
_eventsSub = null;
|
||||
if (handle != null) {
|
||||
try {
|
||||
await XVideoPlayerBackend.instance.disposeInstance(handle);
|
||||
} on PlatformException catch (_) {
|
||||
// Native side may have already torn down (e.g. engine
|
||||
// detaching). Don't propagate — dispose() must be safe to
|
||||
// call repeatedly.
|
||||
}
|
||||
}
|
||||
await sub?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Texture-backed (Apple) or platform-view-backed (Android) render
|
||||
/// widget. Equivalent to `VideoPlayer(controller)` — wrap in
|
||||
/// `AspectRatio` to control framing.
|
||||
Widget buildPlayer() => XVideoPlayerView(controller: this);
|
||||
|
||||
int _requireHandle(String op) {
|
||||
_throwIfDisposed(op);
|
||||
final h = _handle;
|
||||
if (h == null) {
|
||||
throw const XVideoPlayerException('not_initialized');
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
void _throwIfDisposed(String op) {
|
||||
if (_disposed) {
|
||||
throw XVideoPlayerException(
|
||||
'disposed',
|
||||
'$op called on a disposed controller',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
132
lib/src/video/x_video_player_backend.dart
Normal file
132
lib/src/video/x_video_player_backend.dart
Normal file
@@ -0,0 +1,132 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui' show Size;
|
||||
|
||||
import 'x_video_player.dart' show XDurationRange;
|
||||
import 'x_video_player_channel.dart' show MethodChannelXVideoPlayerBackend;
|
||||
|
||||
/// Backend contract that [XVideoPlayerController] dispatches into. The
|
||||
/// default implementation calls into native code via the `ux/video` /
|
||||
/// `ux/video/events` channels; tests substitute their own (see
|
||||
/// `package:ux/testing.dart`'s `FakeXVideoPlayerBackend`).
|
||||
///
|
||||
/// Every per-instance call carries a `handle` returned by [create] so
|
||||
/// the plugin can route to the right native session. Multiple
|
||||
/// controllers can hold simultaneous handles.
|
||||
abstract class XVideoPlayerBackend {
|
||||
/// Swap to inject a fake before any UI code mounts a controller.
|
||||
static XVideoPlayerBackend instance = MethodChannelXVideoPlayerBackend();
|
||||
|
||||
/// Allocate a native player instance bound to [uri] (currently always
|
||||
/// `file://...`). Returns the handle and, on Apple platforms, the
|
||||
/// Flutter texture id; Android returns `textureId: null` because the
|
||||
/// render path is a platform view that takes the handle as creation
|
||||
/// params.
|
||||
Future<XVideoPlayerCreateResult> create({required String uri});
|
||||
|
||||
/// Load the media. Resolves once the codec is configured and metadata
|
||||
/// (size, duration) is known. Throws
|
||||
/// [XVideoPlayerException("decode_failed")] if the file can't be
|
||||
/// opened or the codec init failed permanently.
|
||||
Future<XVideoPlayerMetadata> initialize(int handle);
|
||||
|
||||
/// Tear down the session. Releases the codec and any held surface /
|
||||
/// texture. Safe to call repeatedly; no-op once disposed.
|
||||
Future<void> disposeInstance(int handle);
|
||||
|
||||
Future<void> play(int handle);
|
||||
Future<void> pause(int handle);
|
||||
Future<void> seekTo(int handle, Duration position);
|
||||
Future<void> setLooping(int handle, bool loop);
|
||||
Future<void> setVolume(int handle, double volume);
|
||||
Future<void> setPlaybackSpeed(int handle, double rate);
|
||||
|
||||
/// Live event stream for [handle]. The controller subscribes during
|
||||
/// [create] / [initialize] and unsubscribes on [disposeInstance].
|
||||
Stream<XVideoPlayerEvent> events(int handle);
|
||||
}
|
||||
|
||||
/// The tuple returned by [XVideoPlayerBackend.create] — everything the
|
||||
/// controller needs to start the load + route subsequent calls.
|
||||
class XVideoPlayerCreateResult {
|
||||
const XVideoPlayerCreateResult({
|
||||
required this.handle,
|
||||
this.textureId,
|
||||
});
|
||||
|
||||
final int handle;
|
||||
|
||||
/// Apple-platform Flutter texture id, or null on Android (which uses
|
||||
/// a platform view keyed off [handle] instead).
|
||||
final int? textureId;
|
||||
}
|
||||
|
||||
/// Media metadata returned by [XVideoPlayerBackend.initialize] once the
|
||||
/// codec is ready.
|
||||
class XVideoPlayerMetadata {
|
||||
const XVideoPlayerMetadata({
|
||||
required this.size,
|
||||
required this.duration,
|
||||
this.rotationQuarterTurns = 0,
|
||||
});
|
||||
|
||||
final Size size;
|
||||
final Duration duration;
|
||||
|
||||
/// Number of 90° clockwise rotations the Flutter `Texture` widget
|
||||
/// needs so the rendered frame reads upright. Android always
|
||||
/// reports 0 (the codec + GLES blit apply the rotation upstream of
|
||||
/// Flutter). Apple reports the rotation derived from the video
|
||||
/// track's `preferredTransform` — `AVPlayerItemVideoOutput`
|
||||
/// delivers pixel buffers in the file's natural orientation, so the
|
||||
/// `RotatedBox` wrapper applies it Dart-side.
|
||||
final int rotationQuarterTurns;
|
||||
}
|
||||
|
||||
/// Events pushed by the native side over `ux/video/events`. Sealed —
|
||||
/// new variants land here as the contract grows.
|
||||
sealed class XVideoPlayerEvent {
|
||||
const XVideoPlayerEvent(this.handle);
|
||||
final int handle;
|
||||
}
|
||||
|
||||
/// Periodic + edge-triggered state update. Any field may be null,
|
||||
/// meaning "no change since the last snapshot." Position is included
|
||||
/// on every emit; the rest are emitted on transition.
|
||||
class XVideoPlayerStateChanged extends XVideoPlayerEvent {
|
||||
const XVideoPlayerStateChanged(
|
||||
super.handle, {
|
||||
this.isPlaying,
|
||||
this.isBuffering,
|
||||
this.position,
|
||||
this.buffered,
|
||||
});
|
||||
|
||||
final bool? isPlaying;
|
||||
final bool? isBuffering;
|
||||
final Duration? position;
|
||||
final List<XDurationRange>? buffered;
|
||||
}
|
||||
|
||||
/// Fired when the codec reports a video-size change. Usually exactly
|
||||
/// once shortly after the first frame is decoded; some adaptive
|
||||
/// streams emit it on resolution changes (not in scope for the
|
||||
/// file-only constructor today but the event remains valid).
|
||||
class XVideoPlayerSizeChanged extends XVideoPlayerEvent {
|
||||
const XVideoPlayerSizeChanged(super.handle, this.size);
|
||||
final Size size;
|
||||
}
|
||||
|
||||
/// Fired when playback hits the end of the media. Loop-mode is
|
||||
/// observed natively, so the controller only sees this event when
|
||||
/// playback truly stops at the tail.
|
||||
class XVideoPlayerCompleted extends XVideoPlayerEvent {
|
||||
const XVideoPlayerCompleted(super.handle);
|
||||
}
|
||||
|
||||
/// Terminal error. The controller marks the value as `hasError = true`
|
||||
/// and stops emitting state updates until [XVideoPlayerBackend.disposeInstance].
|
||||
class XVideoPlayerError extends XVideoPlayerEvent {
|
||||
const XVideoPlayerError(super.handle, this.code, this.description);
|
||||
final String code;
|
||||
final String? description;
|
||||
}
|
||||
155
lib/src/video/x_video_player_channel.dart
Normal file
155
lib/src/video/x_video_player_channel.dart
Normal file
@@ -0,0 +1,155 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui' show Size;
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'x_video_player.dart' show XDurationRange, XVideoPlayerException;
|
||||
import 'x_video_player_backend.dart';
|
||||
|
||||
/// Production [XVideoPlayerBackend]. Hand-rolled MethodChannel +
|
||||
/// EventChannel — matches the rest of `package:ux`, no pigeon.
|
||||
class MethodChannelXVideoPlayerBackend implements XVideoPlayerBackend {
|
||||
MethodChannelXVideoPlayerBackend();
|
||||
|
||||
static const _channel = MethodChannel('ux/video');
|
||||
static const _eventsChannel = EventChannel('ux/video/events');
|
||||
|
||||
late final Stream<Object?> _rawEvents =
|
||||
_eventsChannel.receiveBroadcastStream();
|
||||
|
||||
@override
|
||||
Future<XVideoPlayerCreateResult> create({required String uri}) async {
|
||||
final m = await _invokeMap('create', {'uri': uri});
|
||||
return XVideoPlayerCreateResult(
|
||||
handle: (m['handle'] as num).toInt(),
|
||||
textureId: (m['textureId'] as num?)?.toInt(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<XVideoPlayerMetadata> initialize(int handle) async {
|
||||
final m = await _invokeMap('initialize', {'handle': handle});
|
||||
final s = (m['size'] as Map).cast<Object?, Object?>();
|
||||
return XVideoPlayerMetadata(
|
||||
size: Size(
|
||||
(s['width'] as num).toDouble(),
|
||||
(s['height'] as num).toDouble(),
|
||||
),
|
||||
duration: Duration(milliseconds: (m['durationMs'] as num).toInt()),
|
||||
rotationQuarterTurns:
|
||||
(m['rotationQuarterTurns'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> disposeInstance(int handle) =>
|
||||
_invokeVoid('dispose', {'handle': handle});
|
||||
|
||||
@override
|
||||
Future<void> play(int handle) =>
|
||||
_invokeVoid('play', {'handle': handle});
|
||||
|
||||
@override
|
||||
Future<void> pause(int handle) =>
|
||||
_invokeVoid('pause', {'handle': handle});
|
||||
|
||||
@override
|
||||
Future<void> seekTo(int handle, Duration position) =>
|
||||
_invokeVoid('seekTo', {
|
||||
'handle': handle,
|
||||
'positionMs': position.inMilliseconds,
|
||||
});
|
||||
|
||||
@override
|
||||
Future<void> setLooping(int handle, bool loop) =>
|
||||
_invokeVoid('setLooping', {'handle': handle, 'loop': loop});
|
||||
|
||||
@override
|
||||
Future<void> setVolume(int handle, double volume) =>
|
||||
_invokeVoid('setVolume', {'handle': handle, 'volume': volume});
|
||||
|
||||
@override
|
||||
Future<void> setPlaybackSpeed(int handle, double rate) =>
|
||||
_invokeVoid('setPlaybackSpeed', {'handle': handle, 'rate': rate});
|
||||
|
||||
@override
|
||||
Stream<XVideoPlayerEvent> events(int handle) {
|
||||
return _rawEvents
|
||||
.map((e) => (e as Map).cast<Object?, Object?>())
|
||||
.where((m) => (m['handle'] as num).toInt() == handle)
|
||||
.map(_decodeEvent);
|
||||
}
|
||||
|
||||
// ---- parsers / arg encoders -------------------------------------
|
||||
|
||||
static XVideoPlayerEvent _decodeEvent(Map<Object?, Object?> m) {
|
||||
final handle = (m['handle'] as num).toInt();
|
||||
switch (m['event'] as String?) {
|
||||
case 'stateChanged':
|
||||
return XVideoPlayerStateChanged(
|
||||
handle,
|
||||
isPlaying: m['isPlaying'] as bool?,
|
||||
isBuffering: m['isBuffering'] as bool?,
|
||||
position: _parseMs(m['positionMs']),
|
||||
buffered: _parseBuffered(m['buffered']),
|
||||
);
|
||||
case 'sizeChanged':
|
||||
final s = (m['size'] as Map).cast<Object?, Object?>();
|
||||
return XVideoPlayerSizeChanged(
|
||||
handle,
|
||||
Size(
|
||||
(s['width'] as num).toDouble(),
|
||||
(s['height'] as num).toDouble(),
|
||||
),
|
||||
);
|
||||
case 'completed':
|
||||
return XVideoPlayerCompleted(handle);
|
||||
case 'error':
|
||||
return XVideoPlayerError(
|
||||
handle,
|
||||
m['code'] as String? ?? 'player_runtime_error',
|
||||
m['description'] as String?,
|
||||
);
|
||||
default:
|
||||
return XVideoPlayerError(handle, 'unknown_event', null);
|
||||
}
|
||||
}
|
||||
|
||||
static Duration? _parseMs(Object? raw) {
|
||||
if (raw == null) return null;
|
||||
return Duration(milliseconds: (raw as num).toInt());
|
||||
}
|
||||
|
||||
static List<XDurationRange>? _parseBuffered(Object? raw) {
|
||||
if (raw == null) return null;
|
||||
return [
|
||||
for (final r in (raw as List).cast<Map<Object?, Object?>>())
|
||||
XDurationRange(
|
||||
Duration(milliseconds: (r['startMs'] as num).toInt()),
|
||||
Duration(milliseconds: (r['endMs'] as num).toInt()),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
// ---- channel adapter --------------------------------------------
|
||||
|
||||
Future<Map<Object?, Object?>> _invokeMap(
|
||||
String method, [
|
||||
Map<String, Object?>? args,
|
||||
]) async {
|
||||
try {
|
||||
final result = await _channel.invokeMethod<Object?>(method, args);
|
||||
return (result as Map).cast<Object?, Object?>();
|
||||
} on PlatformException catch (e) {
|
||||
throw XVideoPlayerException(e.code, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _invokeVoid(String method, [Map<String, Object?>? args]) async {
|
||||
try {
|
||||
await _channel.invokeMethod<void>(method, args);
|
||||
} on PlatformException catch (e) {
|
||||
throw XVideoPlayerException(e.code, e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
57
lib/src/video/x_video_player_view.dart
Normal file
57
lib/src/video/x_video_player_view.dart
Normal file
@@ -0,0 +1,57 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'x_video_player.dart' show XVideoPlayerController, XVideoPlayerValue;
|
||||
|
||||
/// Renders the active frame of [controller] into the parent's box.
|
||||
/// Sizes itself to the parent — wrap in `AspectRatio` / `FittedBox` /
|
||||
/// `Hero` to control framing.
|
||||
///
|
||||
/// Single render path: a Flutter [Texture] widget over the texture
|
||||
/// the native side hands back at `create()` time. Android feeds the
|
||||
/// texture from an [`ExoPlayer`] via a `SurfaceTexture`; Apple feeds
|
||||
/// it from `AVPlayerItemVideoOutput`. Because the rendered content
|
||||
/// lives inside Flutter's compositor:
|
||||
///
|
||||
/// - gallery hero / dismiss animations stay buttery
|
||||
/// - `RenderRepaintBoundary.toImage` (the gallery's `snapshot`
|
||||
/// mechanism) sees the actual frame and can freeze it across
|
||||
/// transitions
|
||||
/// - no per-frame `ImageReader` round-trip — scrub latency stays at
|
||||
/// codec-seek speed
|
||||
///
|
||||
/// Codec crop is handled native-side: Media3's
|
||||
/// `DefaultVideoFrameProcessor` (Android) re-applies the codec's crop
|
||||
/// rect downstream of the broken Huawei `SurfaceTexture` transform
|
||||
/// matrix, so the green right-edge artifact never reaches Flutter's
|
||||
/// sampler. Apple's `AVPlayerItemVideoOutput` is crop-correct by
|
||||
/// construction.
|
||||
class XVideoPlayerView extends StatelessWidget {
|
||||
const XVideoPlayerView({super.key, required this.controller});
|
||||
|
||||
final XVideoPlayerController controller;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder<XVideoPlayerValue>(
|
||||
valueListenable: controller,
|
||||
builder: (context, value, _) {
|
||||
if (!value.isInitialized) return SizedBox.expand();
|
||||
final textureId = controller.textureId;
|
||||
if (textureId == null) return SizedBox.expand();
|
||||
Widget child = Texture(textureId: textureId);
|
||||
// Apple's `AVPlayerItemVideoOutput` hands us `CVPixelBuffer`s in
|
||||
// the file's natural orientation — the video track's
|
||||
// `preferredTransform` rotation is NOT applied. The native side
|
||||
// reports the rotation as quarter-turns; we apply it here so
|
||||
// the rendered frame reads upright. Android always reports 0
|
||||
// because the codec + GLES blit handle rotation upstream of
|
||||
// Flutter.
|
||||
final turns = value.rotationQuarterTurns;
|
||||
if (turns != 0) {
|
||||
child = RotatedBox(quarterTurns: turns, child: child);
|
||||
}
|
||||
return child;
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
132
lib/src/view_padding.dart
Normal file
132
lib/src/view_padding.dart
Normal file
@@ -0,0 +1,132 @@
|
||||
import 'dart:ui' show FlutterView;
|
||||
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// Smoothly-animated view padding (safe-area insets).
|
||||
///
|
||||
/// Replaces a raw `view.viewPadding` read with a value that lerps toward the
|
||||
/// system inset over [duration] when it changes — so when the OS bars toggle
|
||||
/// visibility (e.g. switching out of `SystemUiMode.immersiveSticky` back to
|
||||
/// `edgeToEdge`) bottom- and top-anchored UI slides into place instead of
|
||||
/// snapping by the nav-bar / status-bar height.
|
||||
///
|
||||
/// Use the singleton [instance] and listen via [addListener]; or wrap a
|
||||
/// subtree in [XAnimatedInsets] so descendant `MediaQuery.viewPaddingOf`
|
||||
/// reads pick up the animated value.
|
||||
///
|
||||
/// Tracks only `viewPadding` — keyboard insets (`viewInsets`) are handled by
|
||||
/// [XKeyboard] and intentionally bypass this smoothing.
|
||||
class XInsets with ChangeNotifier, WidgetsBindingObserver {
|
||||
XInsets._() {
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
final view = WidgetsBinding.instance.platformDispatcher.implicitView;
|
||||
_system = _read(view);
|
||||
_current = _system;
|
||||
}
|
||||
|
||||
/// Singleton instance. Constructed lazily on first access; requires
|
||||
/// `WidgetsFlutterBinding.ensureInitialized()` to have been called.
|
||||
static final XInsets instance = XInsets._();
|
||||
|
||||
EdgeInsets _system = EdgeInsets.zero;
|
||||
EdgeInsets _current = EdgeInsets.zero;
|
||||
EdgeInsets _from = EdgeInsets.zero;
|
||||
Duration _start = Duration.zero;
|
||||
bool _ticking = false;
|
||||
|
||||
/// Lerp duration. Set to [Duration.zero] in tests for deterministic
|
||||
/// frame-by-frame goldens.
|
||||
Duration duration = const Duration(milliseconds: 220);
|
||||
|
||||
/// Lerp curve.
|
||||
Curve curve = Curves.easeOut;
|
||||
|
||||
/// Smoothed view-padding in logical pixels.
|
||||
EdgeInsets get viewPadding => _current;
|
||||
|
||||
/// Raw system view-padding (the un-lerped target). Use sparingly — most
|
||||
/// callers want [viewPadding].
|
||||
EdgeInsets get systemViewPadding => _system;
|
||||
|
||||
@override
|
||||
void didChangeMetrics() {
|
||||
final next = _read(WidgetsBinding.instance.platformDispatcher.implicitView);
|
||||
if (next == _system) return;
|
||||
_system = next;
|
||||
if (duration == Duration.zero) {
|
||||
_current = next;
|
||||
_ticking = false;
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
_from = _current;
|
||||
_start = SchedulerBinding.instance.currentSystemFrameTimeStamp;
|
||||
if (!_ticking) {
|
||||
_ticking = true;
|
||||
SchedulerBinding.instance.scheduleFrameCallback(_tick);
|
||||
SchedulerBinding.instance.scheduleFrame();
|
||||
}
|
||||
}
|
||||
|
||||
void _tick(Duration ts) {
|
||||
if (!_ticking) return;
|
||||
final dt = (ts - _start).inMicroseconds / duration.inMicroseconds;
|
||||
final t = curve.transform(dt.clamp(0.0, 1.0));
|
||||
_current = EdgeInsets.lerp(_from, _system, t)!;
|
||||
notifyListeners();
|
||||
if (dt < 1.0) {
|
||||
SchedulerBinding.instance.scheduleFrameCallback(_tick);
|
||||
SchedulerBinding.instance.scheduleFrame();
|
||||
} else {
|
||||
_ticking = false;
|
||||
}
|
||||
}
|
||||
|
||||
static EdgeInsets _read(FlutterView? v) {
|
||||
if (v == null) return EdgeInsets.zero;
|
||||
final r = v.devicePixelRatio;
|
||||
final p = v.viewPadding;
|
||||
return EdgeInsets.fromLTRB(p.left / r, p.top / r, p.right / r, p.bottom / r);
|
||||
}
|
||||
}
|
||||
|
||||
/// Wraps [child] in a [MediaQuery] whose `viewPadding` is sourced from
|
||||
/// [XInsets.instance] (smoothly animated). Use once at the app root so every
|
||||
/// descendant `MediaQuery.viewPaddingOf(context)` read returns the animated
|
||||
/// value.
|
||||
class XAnimatedInsets extends StatefulWidget {
|
||||
const XAnimatedInsets({super.key, required this.child});
|
||||
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
State<XAnimatedInsets> createState() => _XAnimatedInsetsState();
|
||||
}
|
||||
|
||||
class _XAnimatedInsetsState extends State<XAnimatedInsets> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
XInsets.instance.addListener(_onTick);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
XInsets.instance.removeListener(_onTick);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onTick() {
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final mq = MediaQuery.of(context);
|
||||
return MediaQuery(
|
||||
data: mq.copyWith(viewPadding: XInsets.instance.viewPadding),
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user