fix(linux): GL threading, state management, and animation fixes

This commit is contained in:
edde746
2026-01-05 19:40:04 +01:00
parent a10dfcc004
commit 562b54d1cb
8 changed files with 139 additions and 133 deletions
-15
View File
@@ -1,5 +1,3 @@
import 'package:flutter/services.dart';
import '../player_native.dart';
/// Linux implementation of [Player].
@@ -9,19 +7,6 @@ import '../player_native.dart';
/// the Flutter view using a GtkOverlay, with transparent regions
/// in the Flutter UI allowing the video to show through.
class PlayerLinux extends PlayerNative {
static const _methodChannel = MethodChannel('com.plezy/mpv_player');
@override
int? get textureId => null; // Uses GtkGLArea, not Flutter texture
/// Sets the visibility of the video controls overlay.
///
/// On Linux, due to Flutter's lack of transparency support in GtkOverlay,
/// we hide the video layer when controls are visible and show it when
/// controls are hidden. This provides a workaround for the transparency
/// limitation.
@override
Future<void> setControlsVisible(bool visible) async {
await _methodChannel.invokeMethod('setControlsVisible', {'visible': visible});
}
}
-7
View File
@@ -163,13 +163,6 @@ abstract class Player {
/// Returns true if the operation was successful.
Future<bool> setVisible(bool visible);
/// Notify the player about controls visibility.
///
/// On Linux, due to Flutter's lack of transparency support in GtkOverlay,
/// the video layer is hidden when controls are visible and shown when
/// controls are hidden. On other platforms, this is a no-op.
Future<void> setControlsVisible(bool visible);
/// Update the video frame/surface dimensions.
///
/// On iOS/macOS, this updates the Metal layer's frame to match the current
-5
View File
@@ -570,11 +570,6 @@ class PlayerNative implements Player {
}
}
@override
Future<void> setControlsVisible(bool visible) async {
// No-op on most platforms. Override on Linux for transparency workaround.
}
@override
Future<void> updateFrame() async {
_checkDisposed();
-5
View File
@@ -1049,11 +1049,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
if (completed && _nextEpisode != null && !_showPlayNextDialog && !_completionTriggered) {
_completionTriggered = true;
// On Linux, show the Flutter layer so the overlay is visible
if (Platform.isLinux) {
player?.setControlsVisible(true);
}
setState(() {
_showPlayNextDialog = true;
_autoPlayCountdown = 5;
+59 -70
View File
@@ -131,7 +131,6 @@ class PlexVideoControls extends StatefulWidget {
class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListener, WidgetsBindingObserver {
bool _showControls = true;
bool _controlsFullyHidden = false; // For Linux: true after fade-out completes
List<PlexChapter> _chapters = [];
bool _chaptersLoaded = false;
Timer? _hideTimer;
@@ -173,9 +172,6 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
StreamSubscription<bool>? _playingSubscription;
// Completed subscription to show controls when video ends
StreamSubscription<bool>? _completedSubscription;
// Window resize pause state
Timer? _resizeDebounceTimer;
bool _wasPlayingBeforeResize = false;
// Auto-skip state
bool _autoSkipIntro = true;
bool _autoSkipCredits = true;
@@ -304,10 +300,8 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
// Show controls when video completes (for play next dialog etc.)
setState(() {
_showControls = true;
_controlsFullyHidden = false;
});
_hideTimer?.cancel();
_showLinuxControls();
}
});
}
@@ -445,7 +439,6 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
widget.hasFirstFrame?.removeListener(_onFirstFrameReady);
_hideTimer?.cancel();
_feedbackTimer?.cancel();
_resizeDebounceTimer?.cancel();
_autoSkipTimer?.cancel();
_singleTapTimer?.cancel();
_seekThrottle.cancel();
@@ -513,22 +506,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
@override
void onWindowResize() {
// Pause video while resizing to prevent lag
if (_resizeDebounceTimer == null && widget.player.state.playing) {
_wasPlayingBeforeResize = true;
widget.player.pause();
}
// Reset debounce timer - resume when resizing stops
_resizeDebounceTimer?.cancel();
final slowDuration = tokens(context).slow;
_resizeDebounceTimer = Timer(slowDuration, () {
if (_wasPlayingBeforeResize && mounted) {
widget.player.play();
}
_wasPlayingBeforeResize = false;
_resizeDebounceTimer = null;
});
// Lag during resize is now handled in native code (glViewport + resize signal handler)
}
void _startHideTimer() {
@@ -551,7 +529,6 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
if (Platform.isMacOS) {
_updateTrafficLightVisibility();
}
_hideLinuxControlsAfterAnimation();
}
});
}
@@ -564,35 +541,12 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
}
}
/// Show controls immediately on Linux
void _showLinuxControls() {
if (Platform.isLinux) {
widget.player.setControlsVisible(true);
}
}
/// Hide controls on Linux after animation completes (250ms delay)
void _hideLinuxControlsAfterAnimation() {
if (Platform.isLinux) {
Future.delayed(const Duration(milliseconds: 250), () {
if (mounted && !_showControls) {
setState(() {
_controlsFullyHidden = true;
});
widget.player.setControlsVisible(false);
}
});
}
}
/// Show controls in response to pointer activity (mouse/trackpad movement).
void _showControlsFromPointerActivity() {
if (!_showControls) {
setState(() {
_showControls = true;
_controlsFullyHidden = false;
});
_showLinuxControls();
// On macOS, keep window controls in sync with the overlay
if (Platform.isMacOS) {
_updateTrafficLightVisibility();
@@ -606,17 +560,11 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
void _toggleControls() {
setState(() {
_showControls = !_showControls;
if (_showControls) {
_controlsFullyHidden = false;
_showLinuxControls();
}
});
if (_showControls) {
_startHideTimer();
// Cancel auto-skip when user manually shows controls
_cancelAutoSkipTimer();
} else {
_hideLinuxControlsAfterAnimation();
}
// On macOS, hide/show traffic lights with controls
@@ -1195,9 +1143,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
if (!_showControls) {
setState(() {
_showControls = true;
_controlsFullyHidden = false;
});
_showLinuxControls();
if (Platform.isMacOS) {
_updateTrafficLightVisibility();
}
@@ -1231,7 +1177,6 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
if (Platform.isMacOS) {
_updateTrafficLightVisibility();
}
_hideLinuxControlsAfterAnimation();
}
}
@@ -1330,6 +1275,9 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
onHover: (_) => _showControlsFromPointerActivity(),
child: Stack(
children: [
// Linux keep-alive: 1px widget that continuously repaints to prevent
// Flutter animations from freezing when GTK's frame clock goes idle
if (Platform.isLinux) const Positioned(top: 0, left: 0, child: _LinuxKeepAlive()),
// Invisible tap detector that always covers the full area
// Also handles long-press for 2x speed
Positioned.fill(
@@ -1423,21 +1371,18 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
},
),
),
// Custom controls overlay - use AnimatedOpacity to keep widget tree alive
// On Linux, use Offstage after fade completes to fully hide
// Custom controls overlay
// Positioned AFTER double-tap zones so controls receive taps first
Positioned.fill(
child: Offstage(
offstage: Platform.isLinux && _controlsFullyHidden,
child: IgnorePointer(
ignoring: !_showControls,
child: FocusScope(
// Prevent focus from entering controls when hidden
canRequestFocus: _showControls,
child: AnimatedOpacity(
opacity: _showControls ? 1.0 : 0.0,
duration: const Duration(milliseconds: 200),
child: LayoutBuilder(
child: IgnorePointer(
ignoring: !_showControls,
child: FocusScope(
// Prevent focus from entering controls when hidden
canRequestFocus: _showControls,
child: AnimatedOpacity(
opacity: _showControls ? 1.0 : 0.0,
duration: const Duration(milliseconds: 200),
child: LayoutBuilder(
builder: (context, constraints) {
return GestureDetector(
onTapUp: (details) => _handleControlsOverlayTap(details, constraints),
@@ -1551,7 +1496,6 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
),
),
),
),
// Visual feedback overlay for double-tap
if (isMobile && _showDoubleTapFeedback)
Positioned.fill(
@@ -1721,3 +1665,48 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
}
}
}
/// A 1x1 pixel widget that continuously repaints to keep Flutter's frame clock active on Linux.
/// This prevents animations from freezing when GTK's frame clock goes idle.
class _LinuxKeepAlive extends StatefulWidget {
const _LinuxKeepAlive();
@override
State<_LinuxKeepAlive> createState() => _LinuxKeepAliveState();
}
class _LinuxKeepAliveState extends State<_LinuxKeepAlive> {
Timer? _timer;
int _tick = 0;
@override
void initState() {
super.initState();
// Repaint every 100ms to keep Flutter's frame scheduler active
_timer = Timer.periodic(const Duration(milliseconds: 100), (_) {
if (mounted) {
setState(() {
_tick++;
});
}
});
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
// Use _tick to force rebuild, render a 1x1 transparent pixel
return SizedBox(
width: 1,
height: 1,
child: ColoredBox(
color: Color.fromARGB(_tick % 2, 0, 0, 0), // Alternate 0/1 alpha
),
);
}
}
+10 -3
View File
@@ -135,6 +135,10 @@ bool MpvPlayer::Initialize(GtkGLArea* gl_area) {
}
void MpvPlayer::Dispose() {
// Lock mutex to prevent race with OnMpvWakeup callbacks.
// This ensures no new event processing starts during dispose.
std::lock_guard<std::mutex> lock(callback_mutex_);
// Guard against multiple dispose calls (double-free protection)
if (disposed_.exchange(true)) {
return; // Already disposed
@@ -282,15 +286,18 @@ void MpvPlayer::RequestRedraw() {
void MpvPlayer::OnMpvWakeup(void* ctx) {
auto* player = static_cast<MpvPlayer*>(ctx);
// Don't schedule if already disposed
// Don't schedule if already disposed (atomic check for early exit)
if (player->disposed_) return;
// Schedule event processing on the main thread.
g_idle_add(
[](gpointer data) -> gboolean {
auto* player = static_cast<MpvPlayer*>(data);
// Check disposed again when callback runs
if (!player->disposed_) {
// Check disposed - atomic ensures we see updated value.
// Don't lock mutex here - ProcessEvents() calls SendPropertyChange/SendEvent
// which lock callback_mutex_, causing deadlock if we hold it here.
if (!player->disposed_ && player->mpv_) {
player->ProcessEvents();
}
return G_SOURCE_REMOVE;
+67 -26
View File
@@ -31,6 +31,7 @@ static gboolean on_gl_render(GtkGLArea* area,
gpointer user_data);
static void on_gl_realize(GtkGLArea* area, gpointer user_data);
static void on_gl_unrealize(GtkGLArea* area, gpointer user_data);
static void on_gl_resize(GtkGLArea* area, gint width, gint height, gpointer user_data);
static void mpv_plugin_dispose(GObject* object) {
MpvPlugin* self = MPV_PLUGIN(object);
@@ -103,6 +104,7 @@ MpvPlugin* mpv_plugin_new(FlPluginRegistrar* registrar,
g_signal_connect(gl_area, "render", G_CALLBACK(on_gl_render), self);
g_signal_connect(gl_area, "realize", G_CALLBACK(on_gl_realize), self);
g_signal_connect(gl_area, "unrealize", G_CALLBACK(on_gl_unrealize), self);
g_signal_connect(gl_area, "resize", G_CALLBACK(on_gl_resize), self);
// Set up auto-render to false - we control when to render.
gtk_gl_area_set_auto_render(gl_area, FALSE);
@@ -132,6 +134,17 @@ static gboolean on_gl_render(GtkGLArea* area,
(void)context;
MpvPlugin* self = MPV_PLUGIN(user_data);
// Ensure GL context is current before any GL operations.
// Critical during fullscreen transitions and workspace switches (issue #202).
gtk_gl_area_make_current(area);
// Check for GL context errors (can happen during window state changes)
GError* error = gtk_gl_area_get_error(area);
if (error != nullptr) {
g_warning("MPV Plugin: GL context error in render: %s", error->message);
return FALSE; // Signal failure to GTK
}
if (!self->player || !self->player->IsInitialized() || !self->visible) {
// Clear to transparent when not showing video.
glClearColor(0.0f, 0.0f, 0.0f, 0.0f);
@@ -152,10 +165,41 @@ static gboolean on_gl_render(GtkGLArea* area,
GLint fbo = 0;
glGetIntegerv(GL_FRAMEBUFFER_BINDING, &fbo);
// Render the video frame.
// Save GL state before MPV render (MPV modifies these and doesn't restore them)
// This prevents GL state pollution that corrupts Flutter's rendering.
GLint prev_viewport[4];
GLint prev_scissor_box[4];
GLboolean prev_blend, prev_scissor_test;
GLint prev_blend_src, prev_blend_dst;
glGetIntegerv(GL_VIEWPORT, prev_viewport);
glGetIntegerv(GL_SCISSOR_BOX, prev_scissor_box);
glGetBooleanv(GL_BLEND, &prev_blend);
glGetBooleanv(GL_SCISSOR_TEST, &prev_scissor_test);
glGetIntegerv(GL_BLEND_SRC_ALPHA, &prev_blend_src);
glGetIntegerv(GL_BLEND_DST_ALPHA, &prev_blend_dst);
// Set viewport and render the video frame.
glViewport(0, 0, width, height);
self->player->Render(width, height, fbo);
self->player->ClearRedrawFlag();
// Restore GL state after MPV render to prevent Flutter corruption.
glViewport(prev_viewport[0], prev_viewport[1], prev_viewport[2], prev_viewport[3]);
glScissor(prev_scissor_box[0], prev_scissor_box[1], prev_scissor_box[2], prev_scissor_box[3]);
if (prev_blend) {
glEnable(GL_BLEND);
} else {
glDisable(GL_BLEND);
}
if (prev_scissor_test) {
glEnable(GL_SCISSOR_TEST);
} else {
glDisable(GL_SCISSOR_TEST);
}
glBlendFunc(prev_blend_src, prev_blend_dst);
glBindFramebuffer(GL_FRAMEBUFFER, fbo);
return TRUE;
}
@@ -184,6 +228,13 @@ static void on_gl_unrealize(GtkGLArea* area, gpointer user_data) {
gtk_gl_area_make_current(area);
// Check if context is valid before disposing GL resources (issue #202)
GError* error = gtk_gl_area_get_error(area);
if (error != nullptr) {
g_warning("MPV Plugin: GL context error in unrealize: %s", error->message);
}
// Always try to dispose - Dispose() handles its own safety checks
if (self->player) {
self->player->Dispose();
}
@@ -191,6 +242,21 @@ static void on_gl_unrealize(GtkGLArea* area, gpointer user_data) {
g_message("MPV Plugin: GL area unrealized");
}
/// GtkGLArea resize callback.
static void on_gl_resize(GtkGLArea* area,
gint width,
gint height,
gpointer user_data) {
MpvPlugin* self = MPV_PLUGIN(user_data);
(void)width;
(void)height;
// Force a redraw when size changes to prevent lag during resize.
if (self->visible && self->player && self->player->IsInitialized()) {
gtk_gl_area_queue_render(area);
}
}
/// Method call handler.
static void mpv_plugin_handle_method_call(FlMethodChannel* channel,
FlMethodCall* method_call,
@@ -252,9 +318,7 @@ static void mpv_plugin_handle_method_call(FlMethodChannel* channel,
self->initialized = FALSE;
self->visible = FALSE;
gtk_widget_set_visible(GTK_WIDGET(self->gl_area), FALSE);
// Restore Flutter view opacity to 1.0 (may have been set to 0 by setControlsVisible)
if (self->flutter_view != nullptr) {
gtk_widget_set_opacity(self->flutter_view, 1.0);
// Force Flutter view to redraw
gtk_widget_queue_draw(self->flutter_view);
}
@@ -367,29 +431,6 @@ static void mpv_plugin_handle_method_call(FlMethodChannel* channel,
gtk_gl_area_queue_render(self->gl_area);
}
response = FL_METHOD_RESPONSE(fl_method_success_response_new(nullptr));
}
} else if (strcmp(method, "setControlsVisible") == 0) {
// Set Flutter view opacity when controls are hidden/shown.
// This is a workaround for Flutter's lack of transparency support on Linux.
// When controls are hidden, setting opacity to 0 shows only the video
// while keeping the widget interactive for mouse events.
FlValue* controls_visible_value = fl_value_lookup_string(args, "visible");
if (controls_visible_value == nullptr ||
fl_value_get_type(controls_visible_value) != FL_VALUE_TYPE_BOOL) {
response = FL_METHOD_RESPONSE(fl_method_error_response_new(
"INVALID_ARGS", "Missing 'visible'", nullptr));
} else {
gboolean controls_visible = fl_value_get_bool(controls_visible_value);
// When controls are hidden, set Flutter view opacity to 0.
// When controls are visible, set opacity to 1.
// Using opacity keeps the widget interactive for mouse events.
if (self->flutter_view != nullptr) {
gtk_widget_set_opacity(self->flutter_view, controls_visible ? 1.0 : 0.0);
}
response = FL_METHOD_RESPONSE(fl_method_success_response_new(nullptr));
}
} else if (strcmp(method, "isInitialized") == 0) {
+3 -2
View File
@@ -109,8 +109,9 @@ static void my_application_activate(GApplication* application) {
gtk_widget_set_hexpand(GTK_WIDGET(self->flutter_view), TRUE);
gtk_widget_set_vexpand(GTK_WIDGET(self->flutter_view), TRUE);
// Enable transparency for the Flutter view.
gtk_widget_set_app_paintable(GTK_WIDGET(self->flutter_view), TRUE);
// Set up RGBA visual for transparency support.
// Note: app_paintable is only set on the window (line 73), not on child widgets,
// to avoid redundant composition passes.
setup_rgba_visual(GTK_WIDGET(self->flutter_view));
// Enable transparent background for the Flutter view.