Files
plezy/lib/mpv/video.dart
edde746 be863a0c1c fix(linux): remove the Flutter-texture fallback permanently
The display-agnostic texture renderer restored by 9cdfe759 is deleted
again, this time for good: it is a second, SDR-only rendering stack
(isolated EGL context on Flutter's display plus EGL-image handoff) kept
alive solely to host sessions that cannot bring up the Wayland plane -
X11/XWayland or a failed plane bootstrap - and it was the source of the
native lifecycle and EGL state-churn fixes of the 9f2e0507 era. Linux
video now requires a Wayland compositor; a session that cannot host the
plane fails initialization with VIDEO_PLANE_UNSUPPORTED instead of
silently rendering through the second stack.

Reverts the restore commit's machinery: mpv_texture.cc/.h and
mpv_gpu_bootstrap.cc/.h deleted, the plugin's texture-registrar,
bootstrap, and waitForVideoReady paths removed, MpvPlayer's texture-mode
render-context API dropped, and the Dart-side renderMode setting, its
settings tile, translation keys, and the Player textureId member
withdrawn. The #1874 HDR diagnostic work is unaffected.
2026-08-15 01:20:44 +02:00

216 lines
7.3 KiB
Dart

import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'models.dart';
import 'player/player.dart';
import 'player/video_rect_support.dart';
/// Video widget for displaying player output.
///
/// This widget displays the video output from a [Player] instance
/// and optionally overlays custom controls.
///
/// Example usage:
/// ```dart
/// final player = Player();
///
/// Video(
/// player: player,
/// controls: (context) => MyCustomControls(),
/// )
/// ```
class Video extends StatefulWidget {
final Player player;
final Widget Function(BuildContext context)? controls;
final Color backgroundColor;
final ValueListenable<bool>? hasFirstFrame;
const Video({
super.key,
required this.player,
this.controls,
this.backgroundColor = Colors.black,
this.hasFirstFrame,
});
@override
State<Video> createState() => _VideoState();
}
class _VideoState extends State<Video> {
// The integer physical bounds last handed to the native side, and the scale
// that went with them. Cached as what was *sent*, not as the logical rect it
// was derived from, because the rounding in _updateVideoRect is what decides
// whether a layout change is visible to the plane at all.
bool _hasSentRect = false;
int _sentLeft = 0;
int _sentTop = 0;
int _sentRight = 0;
int _sentBottom = 0;
double _sentDevicePixelRatio = 0;
bool _hasFirstFrame = false;
StreamSubscription<void>? _playbackRestartSubscription;
@override
void initState() {
super.initState();
_hasFirstFrame = widget.hasFirstFrame?.value ?? false;
widget.hasFirstFrame?.addListener(_syncExternalFirstFrame);
_listenForPlaybackRestart();
}
@override
void didUpdateWidget(covariant Video oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.hasFirstFrame != widget.hasFirstFrame) {
oldWidget.hasFirstFrame?.removeListener(_syncExternalFirstFrame);
widget.hasFirstFrame?.addListener(_syncExternalFirstFrame);
_syncExternalFirstFrame();
}
if (oldWidget.player != widget.player) {
_playbackRestartSubscription?.cancel();
_listenForPlaybackRestart();
_syncExternalFirstFrame();
// The cache describes the old player's native surface. Keeping it would
// let the next frame short-circuit as "geometry unchanged", and the
// replacement surface stays sizeless — invisible, with no Texture
// fallback left to cover for it.
_hasSentRect = false;
}
}
@override
void dispose() {
widget.hasFirstFrame?.removeListener(_syncExternalFirstFrame);
_playbackRestartSubscription?.cancel();
super.dispose();
}
void _listenForPlaybackRestart() {
_playbackRestartSubscription = widget.player.streams.playbackRestart.listen((_) {
_setHasFirstFrame(true);
});
}
void _syncExternalFirstFrame() {
final external = widget.hasFirstFrame;
if (external == null) return;
_setHasFirstFrame(external.value);
}
void _setHasFirstFrame(bool value) {
if (_hasFirstFrame == value || !mounted) return;
setState(() => _hasFirstFrame = value);
}
@override
Widget build(BuildContext context) {
return ColoredBox(
color: _hasFirstFrame ? Colors.transparent : widget.backgroundColor,
child: Stack(
fit: StackFit.expand,
children: [
// Video rendering area
_buildVideoSurface(),
// Controls overlay
if (widget.controls != null) widget.controls!(context),
],
),
);
}
Widget _buildVideoSurface() {
if (widget.player is VideoRectSupport) {
return LayoutBuilder(
builder: (context, constraints) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_updateVideoRect(context, constraints);
});
return const SizedBox.expand();
},
);
}
return const SizedBox.expand();
}
void _updateVideoRect(BuildContext context, BoxConstraints _) {
final renderBox = context.findRenderObject() as RenderBox?;
if (renderBox == null || !renderBox.hasSize) return;
final position = renderBox.localToGlobal(Offset.zero);
final size = renderBox.size;
final dpr = MediaQuery.devicePixelRatioOf(context);
// Rounded outward, the same way the native SetRect biases: it floors the
// position and rounds the buffer size up so the plane always covers at
// least the region Flutter cut out for it. Truncating the far edges here
// would undo that a layer earlier - at a fractional layout position the
// plane comes up a physical pixel short and the desktop shows through the
// seam, where the point of the plane is that the seam is black. Ceil and
// floor are identity on an already-integral value, so an integral layout
// sends exactly the numbers it sent before.
final left = (position.dx * dpr).floor();
final top = (position.dy * dpr).floor();
final right = ((position.dx + size.width) * dpr).ceil();
final bottom = ((position.dy + size.height) * dpr).ceil();
// Keyed on the four integers actually sent rather than on a logical-pixel
// tolerance. A sub-logical-pixel move is a real move at scale 2 or 3 -
// worth up to three physical pixels of stale placement - while anything too
// small to change one of these numbers cannot reach the plane at all and is
// not worth the channel round-trip.
//
// The scale is part of what the native side is being told, so it has to be
// part of what decides whether to tell it. Moving a window between a
// scale-1 and a scale-2 output can leave all four bounds identical while
// devicePixelRatio changes, and dropping that call leaves the native
// surface at the old buffer resolution: soft at half resolution after
// docking to a HiDPI output, overdrawn at double after undocking. The Steam
// Deck's dock is exactly this.
if (_hasSentRect &&
_sentLeft == left &&
_sentTop == top &&
_sentRight == right &&
_sentBottom == bottom &&
_sentDevicePixelRatio == dpr) {
return;
}
_hasSentRect = true;
_sentLeft = left;
_sentTop = top;
_sentRight = right;
_sentBottom = bottom;
_sentDevicePixelRatio = dpr;
final player = widget.player as VideoRectSupport;
player.setVideoRect(left: left, top: top, right: right, bottom: bottom, devicePixelRatio: dpr).catchError((
Object e,
) {
// Geometry is the only thing that makes the native surface visible,
// so a rejected rect is a black video area, not a cosmetic glitch.
// Post-frame callbacks have nobody to rethrow to, so route it to the
// player's error stream rather than leaving an unhandled async error.
//
// Drop the sent-rect cache too: it was recorded before the call
// resolved, and keeping it would short-circuit every later identical
// layout pass, freezing the failure in place. Cleared, the next layout
// or resize retries for free.
if (mounted &&
_sentLeft == left &&
_sentTop == top &&
_sentRight == right &&
_sentBottom == bottom &&
_sentDevicePixelRatio == dpr) {
_hasSentRect = false;
}
if (!player.errorController.isClosed) {
player.errorController.add(PlayerError('Failed to set video rect: $e'));
}
});
}
}