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:
agra
2026-05-23 15:57:15 +03:00
parent 96df891b9d
commit de4925adf9
28 changed files with 4243 additions and 14 deletions

View 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));
}
}

View 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',
);
}
}
}

View 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;
}

View 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);
}
}
}

View 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
View 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,
);
}
}