diff --git a/lib/mpv/player/platform/player_linux.dart b/lib/mpv/player/platform/player_linux.dart index 285d9a3f..ae0c5737 100644 --- a/lib/mpv/player/platform/player_linux.dart +++ b/lib/mpv/player/platform/player_linux.dart @@ -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 setControlsVisible(bool visible) async { - await _methodChannel.invokeMethod('setControlsVisible', {'visible': visible}); - } } diff --git a/lib/mpv/player/player.dart b/lib/mpv/player/player.dart index d22a5f12..7e55fffd 100644 --- a/lib/mpv/player/player.dart +++ b/lib/mpv/player/player.dart @@ -163,13 +163,6 @@ abstract class Player { /// Returns true if the operation was successful. Future 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 setControlsVisible(bool visible); - /// Update the video frame/surface dimensions. /// /// On iOS/macOS, this updates the Metal layer's frame to match the current diff --git a/lib/mpv/player/player_native.dart b/lib/mpv/player/player_native.dart index 8870b00f..358ea9af 100644 --- a/lib/mpv/player/player_native.dart +++ b/lib/mpv/player/player_native.dart @@ -570,11 +570,6 @@ class PlayerNative implements Player { } } - @override - Future setControlsVisible(bool visible) async { - // No-op on most platforms. Override on Linux for transparency workaround. - } - @override Future updateFrame() async { _checkDisposed(); diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 4ab8a374..02c02e3e 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -1049,11 +1049,6 @@ class VideoPlayerScreenState extends State 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; diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index f0423a13..78566a46 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -131,7 +131,6 @@ class PlexVideoControls extends StatefulWidget { class _PlexVideoControlsState extends State with WindowListener, WidgetsBindingObserver { bool _showControls = true; - bool _controlsFullyHidden = false; // For Linux: true after fade-out completes List _chapters = []; bool _chaptersLoaded = false; Timer? _hideTimer; @@ -173,9 +172,6 @@ class _PlexVideoControlsState extends State with WindowListen StreamSubscription? _playingSubscription; // Completed subscription to show controls when video ends StreamSubscription? _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 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 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 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 with WindowListen if (Platform.isMacOS) { _updateTrafficLightVisibility(); } - _hideLinuxControlsAfterAnimation(); } }); } @@ -564,35 +541,12 @@ class _PlexVideoControlsState extends State 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 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 with WindowListen if (!_showControls) { setState(() { _showControls = true; - _controlsFullyHidden = false; }); - _showLinuxControls(); if (Platform.isMacOS) { _updateTrafficLightVisibility(); } @@ -1231,7 +1177,6 @@ class _PlexVideoControlsState extends State with WindowListen if (Platform.isMacOS) { _updateTrafficLightVisibility(); } - _hideLinuxControlsAfterAnimation(); } } @@ -1330,6 +1275,9 @@ class _PlexVideoControlsState extends State 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 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 with WindowListen ), ), ), - ), // Visual feedback overlay for double-tap if (isMobile && _showDoubleTapFeedback) Positioned.fill( @@ -1721,3 +1665,48 @@ class _PlexVideoControlsState extends State 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 + ), + ); + } +} diff --git a/linux/runner/mpv/mpv_player.cc b/linux/runner/mpv/mpv_player.cc index 0c7c994c..462c2a77 100644 --- a/linux/runner/mpv/mpv_player.cc +++ b/linux/runner/mpv/mpv_player.cc @@ -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 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(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(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; diff --git a/linux/runner/mpv/mpv_plugin.cc b/linux/runner/mpv/mpv_plugin.cc index 97105619..2c18ec32 100644 --- a/linux/runner/mpv/mpv_plugin.cc +++ b/linux/runner/mpv/mpv_plugin.cc @@ -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) { diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index dfecf1e7..ccba0ef7 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -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.