From ae4673bde76e269187a4dbddce6bab9c80d73b84 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Tue, 2 Dec 2025 15:26:10 +0100 Subject: [PATCH] linux --- lib/mpv/src/player/mpv_player.dart | 13 +- lib/mpv/src/player/mpv_player_linux.dart | 50 ++ lib/mpv/src/player/mpv_player_native.dart | 5 + lib/mpv/src/player/mpv_player_stub.dart | 5 + lib/mpv/src/video/mpv_video.dart | 53 ++ .../sheets/audio_track_sheet.dart | 4 + .../sheets/base_video_control_sheet.dart | 7 +- .../video_controls/sheets/chapter_sheet.dart | 4 + .../sheets/subtitle_track_sheet.dart | 4 + .../sheets/track_selection_sheet.dart | 4 + .../video_controls/sheets/version_sheet.dart | 8 +- .../sheets/video_settings_sheet.dart | 11 +- .../video_controls/video_controls.dart | 106 +++- .../widgets/track_chapter_controls.dart | 14 + linux/runner/CMakeLists.txt | 10 + linux/runner/mpv/mpv_player.cc | 464 ++++++++++++++++++ linux/runner/mpv/mpv_player.h | 134 +++++ linux/runner/mpv/mpv_plugin.cc | 411 ++++++++++++++++ linux/runner/mpv/mpv_plugin.h | 40 ++ linux/runner/my_application.cc | 114 ++++- 20 files changed, 1418 insertions(+), 43 deletions(-) create mode 100644 lib/mpv/src/player/mpv_player_linux.dart create mode 100644 linux/runner/mpv/mpv_player.cc create mode 100644 linux/runner/mpv/mpv_player.h create mode 100644 linux/runner/mpv/mpv_plugin.cc create mode 100644 linux/runner/mpv/mpv_plugin.h diff --git a/lib/mpv/src/player/mpv_player.dart b/lib/mpv/src/player/mpv_player.dart index 61ca8ddf..2d518b71 100644 --- a/lib/mpv/src/player/mpv_player.dart +++ b/lib/mpv/src/player/mpv_player.dart @@ -6,6 +6,7 @@ import '../models/mpv_audio_track.dart'; import '../models/mpv_subtitle_track.dart'; import 'mpv_player_android.dart'; import 'mpv_player_ios.dart'; +import 'mpv_player_linux.dart'; import 'mpv_player_macos.dart'; import 'mpv_player_state.dart'; import 'mpv_player_streams.dart'; @@ -173,6 +174,13 @@ abstract class MpvPlayer { /// 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); + // ============================================ // Lifecycle // ============================================ @@ -193,6 +201,7 @@ abstract class MpvPlayer { /// - iOS: [MpvPlayerIOS] using MPVKit with Metal rendering /// - Android: [MpvPlayerAndroid] using libmpv /// - Windows: [MpvPlayerWindows] using libmpv with native window embedding + /// - Linux: [MpvPlayerLinux] using libmpv with OpenGL rendering via GtkGLArea /// - Other platforms: [MpvPlayerStub] (placeholder) factory MpvPlayer() { if (Platform.isMacOS) { @@ -207,7 +216,9 @@ abstract class MpvPlayer { if (Platform.isWindows) { return MpvPlayerWindows(); } - // Future: Add Linux implementation + if (Platform.isLinux) { + return MpvPlayerLinux(); + } return MpvPlayerStub(); } } diff --git a/lib/mpv/src/player/mpv_player_linux.dart b/lib/mpv/src/player/mpv_player_linux.dart new file mode 100644 index 00000000..fa94a0cb --- /dev/null +++ b/lib/mpv/src/player/mpv_player_linux.dart @@ -0,0 +1,50 @@ +import 'package:flutter/services.dart'; + +import 'mpv_player_native.dart'; + +/// Linux implementation of [MpvPlayer]. +/// +/// Uses libmpv with OpenGL rendering via GtkGLArea. +/// The mpv video is rendered to a GtkGLArea positioned behind +/// the Flutter view using a GtkOverlay, with transparent regions +/// in the Flutter UI allowing the video to show through. +class MpvPlayerLinux extends MpvPlayerNative { + static const _methodChannel = MethodChannel('com.plezy/mpv_player'); + + @override + int? get textureId => null; // Uses GtkGLArea, not Flutter texture + + /// Updates the video rendering area. + /// + /// On Linux, the GtkGLArea fills the entire overlay area, + /// and mpv handles its own aspect ratio. This method triggers + /// a redraw if needed. + Future setVideoRect({ + required int left, + required int top, + required int right, + required int bottom, + required double devicePixelRatio, + }) async { + await _methodChannel.invokeMethod('setVideoRect', { + 'left': left, + 'top': top, + 'right': right, + 'bottom': bottom, + 'devicePixelRatio': devicePixelRatio, + }); + } + + /// 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/src/player/mpv_player_native.dart b/lib/mpv/src/player/mpv_player_native.dart index 6bcbc7d9..3707ec1f 100644 --- a/lib/mpv/src/player/mpv_player_native.dart +++ b/lib/mpv/src/player/mpv_player_native.dart @@ -479,6 +479,11 @@ class MpvPlayerNative implements MpvPlayer { } } + @override + Future setControlsVisible(bool visible) async { + // No-op on most platforms. Override on Linux for transparency workaround. + } + // ============================================ // Lifecycle // ============================================ diff --git a/lib/mpv/src/player/mpv_player_stub.dart b/lib/mpv/src/player/mpv_player_stub.dart index 397c4050..f4bce796 100644 --- a/lib/mpv/src/player/mpv_player_stub.dart +++ b/lib/mpv/src/player/mpv_player_stub.dart @@ -215,6 +215,11 @@ class MpvPlayerStub implements MpvPlayer { return false; } + @override + Future setControlsVisible(bool visible) async { + // No-op on unsupported platforms + } + // ============================================ // Lifecycle // ============================================ diff --git a/lib/mpv/src/video/mpv_video.dart b/lib/mpv/src/video/mpv_video.dart index c6df6f0a..80233ff1 100644 --- a/lib/mpv/src/video/mpv_video.dart +++ b/lib/mpv/src/video/mpv_video.dart @@ -3,6 +3,7 @@ import 'dart:io' show Platform; import 'package:flutter/material.dart'; import '../player/mpv_player.dart'; +import '../player/mpv_player_linux.dart'; import '../player/mpv_player_windows.dart'; /// Video widget for displaying MPV player output. @@ -79,6 +80,19 @@ class _MpvVideoState extends State { }, ); } + if (Platform.isLinux) { + // On Linux, use GtkGLArea behind the Flutter view. + // The GL area fills the entire overlay, and mpv handles aspect ratio. + // We still communicate the rect for potential future use. + return LayoutBuilder( + builder: (context, constraints) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _updateVideoRectLinux(context, constraints); + }); + return const SizedBox.expand(); + }, + ); + } return const SizedBox.expand(); } @@ -120,4 +134,43 @@ class _MpvVideoState extends State { ); } } + + void _updateVideoRectLinux(BuildContext context, BoxConstraints constraints) { + 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.of(context).devicePixelRatio; + + final newRect = Rect.fromLTWH( + position.dx, + position.dy, + size.width, + size.height, + ); + + // Only update if the rect has changed significantly + if (_lastRect != null && + (newRect.left - _lastRect!.left).abs() < 1 && + (newRect.top - _lastRect!.top).abs() < 1 && + (newRect.width - _lastRect!.width).abs() < 1 && + (newRect.height - _lastRect!.height).abs() < 1) { + return; + } + + _lastRect = newRect; + + // Update the Linux mpv player (triggers redraw) + if (widget.player is MpvPlayerLinux) { + final linuxPlayer = widget.player as MpvPlayerLinux; + linuxPlayer.setVideoRect( + left: (position.dx * dpr).toInt(), + top: (position.dy * dpr).toInt(), + right: ((position.dx + size.width) * dpr).toInt(), + bottom: ((position.dy + size.height) * dpr).toInt(), + devicePixelRatio: dpr, + ); + } + } } diff --git a/lib/widgets/video_controls/sheets/audio_track_sheet.dart b/lib/widgets/video_controls/sheets/audio_track_sheet.dart index 65897683..affa9230 100644 --- a/lib/widgets/video_controls/sheets/audio_track_sheet.dart +++ b/lib/widgets/video_controls/sheets/audio_track_sheet.dart @@ -10,6 +10,8 @@ class AudioTrackSheet { BuildContext context, MpvPlayer player, { Function(MpvAudioTrack)? onTrackChanged, + VoidCallback? onOpen, + VoidCallback? onClose, }) { TrackSelectionSheet.show( context: context, @@ -36,6 +38,8 @@ class AudioTrackSheet { }, setTrack: (track) => player.selectAudioTrack(track), onTrackChanged: onTrackChanged, + onOpen: onOpen, + onClose: onClose, ); } } diff --git a/lib/widgets/video_controls/sheets/base_video_control_sheet.dart b/lib/widgets/video_controls/sheets/base_video_control_sheet.dart index f15b756c..d7a7d770 100644 --- a/lib/widgets/video_controls/sheets/base_video_control_sheet.dart +++ b/lib/widgets/video_controls/sheets/base_video_control_sheet.dart @@ -34,14 +34,19 @@ class BaseVideoControlSheet extends StatelessWidget { static Future showSheet({ required BuildContext context, required WidgetBuilder builder, + VoidCallback? onOpen, + VoidCallback? onClose, }) { + onOpen?.call(); return showModalBottomSheet( context: context, backgroundColor: Colors.grey[900], isScrollControlled: true, constraints: getBottomSheetConstraints(context), builder: builder, - ); + ).whenComplete(() { + onClose?.call(); + }); } @override diff --git a/lib/widgets/video_controls/sheets/chapter_sheet.dart b/lib/widgets/video_controls/sheets/chapter_sheet.dart index 65d34179..8126f561 100644 --- a/lib/widgets/video_controls/sheets/chapter_sheet.dart +++ b/lib/widgets/video_controls/sheets/chapter_sheet.dart @@ -28,9 +28,13 @@ class ChapterSheet extends StatelessWidget { List chapters, bool chaptersLoaded, { String? serverId, + VoidCallback? onOpen, + VoidCallback? onClose, }) { BaseVideoControlSheet.showSheet( context: context, + onOpen: onOpen, + onClose: onClose, builder: (context) => ChapterSheet( player: player, chapters: chapters, diff --git a/lib/widgets/video_controls/sheets/subtitle_track_sheet.dart b/lib/widgets/video_controls/sheets/subtitle_track_sheet.dart index 4630116f..900b86b9 100644 --- a/lib/widgets/video_controls/sheets/subtitle_track_sheet.dart +++ b/lib/widgets/video_controls/sheets/subtitle_track_sheet.dart @@ -10,6 +10,8 @@ class SubtitleTrackSheet { BuildContext context, MpvPlayer player, { Function(MpvSubtitleTrack)? onTrackChanged, + VoidCallback? onOpen, + VoidCallback? onClose, }) { TrackSelectionSheet.show( context: context, @@ -47,6 +49,8 @@ class SubtitleTrackSheet { showOffOption: true, createOffTrack: () => MpvSubtitleTrack.off, isOffTrack: (track) => track.id == 'no', + onOpen: onOpen, + onClose: onClose, ); } } diff --git a/lib/widgets/video_controls/sheets/track_selection_sheet.dart b/lib/widgets/video_controls/sheets/track_selection_sheet.dart index b7bae665..c9b47ad3 100644 --- a/lib/widgets/video_controls/sheets/track_selection_sheet.dart +++ b/lib/widgets/video_controls/sheets/track_selection_sheet.dart @@ -47,9 +47,13 @@ class TrackSelectionSheet extends StatelessWidget { bool showOffOption = false, T Function()? createOffTrack, bool Function(T track)? isOffTrack, + VoidCallback? onOpen, + VoidCallback? onClose, }) { BaseVideoControlSheet.showSheet( context: context, + onOpen: onOpen, + onClose: onClose, builder: (context) => TrackSelectionSheet( player: player, title: title, diff --git a/lib/widgets/video_controls/sheets/version_sheet.dart b/lib/widgets/video_controls/sheets/version_sheet.dart index 70b45e9d..8caf23f6 100644 --- a/lib/widgets/video_controls/sheets/version_sheet.dart +++ b/lib/widgets/video_controls/sheets/version_sheet.dart @@ -19,10 +19,14 @@ class VersionSheet extends StatelessWidget { BuildContext context, List availableVersions, int selectedMediaIndex, - Function(int) onVersionSelected, - ) { + Function(int) onVersionSelected, { + VoidCallback? onOpen, + VoidCallback? onClose, + }) { BaseVideoControlSheet.showSheet( context: context, + onOpen: onOpen, + onClose: onClose, builder: (context) => VersionSheet( availableVersions: availableVersions, selectedMediaIndex: selectedMediaIndex, diff --git a/lib/widgets/video_controls/sheets/video_settings_sheet.dart b/lib/widgets/video_controls/sheets/video_settings_sheet.dart index 47fd1493..24e7ef1d 100644 --- a/lib/widgets/video_controls/sheets/video_settings_sheet.dart +++ b/lib/widgets/video_controls/sheets/video_settings_sheet.dart @@ -74,8 +74,11 @@ class VideoSettingsSheet extends StatefulWidget { BuildContext context, MpvPlayer player, int audioSyncOffset, - int subtitleSyncOffset, - ) { + int subtitleSyncOffset, { + VoidCallback? onOpen, + VoidCallback? onClose, + }) { + onOpen?.call(); return showModalBottomSheet( context: context, backgroundColor: Colors.grey[900], @@ -86,7 +89,9 @@ class VideoSettingsSheet extends StatefulWidget { audioSyncOffset: audioSyncOffset, subtitleSyncOffset: subtitleSyncOffset, ), - ); + ).whenComplete(() { + onClose?.call(); + }); } @override diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index e26c2aa7..c61a97d7 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -86,6 +86,7 @@ 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; @@ -115,6 +116,8 @@ class _PlexVideoControlsState extends State bool _markersLoaded = false; // Playback state subscription for auto-hide timer StreamSubscription? _playingSubscription; + // Completed subscription to show controls when video ends + StreamSubscription? _completedSubscription; // Window resize pause state Timer? _resizeDebounceTimer; bool _wasPlayingBeforeResize = false; @@ -136,6 +139,7 @@ class _PlexVideoControlsState extends State _initKeyboardService(); _listenToPosition(); _listenToPlayingState(); + _listenToCompleted(); // Add lifecycle observer to reload settings when app resumes WidgetsBinding.instance.addObserver(this); // Add window listener for tracking fullscreen state (for button icon) @@ -183,6 +187,24 @@ class _PlexVideoControlsState extends State }); } + /// Listen to completed stream to show controls when video ends + void _listenToCompleted() { + _completedSubscription = widget.player.streams.completed.listen((completed) { + if (completed && mounted) { + // Show controls when video completes (for play next dialog etc.) + setState(() { + _showControls = true; + _controlsFullyHidden = false; + }); + _hideTimer?.cancel(); + // On Linux, ensure Flutter view is visible + if (Platform.isLinux) { + widget.player.setControlsVisible(true); + } + } + }); + } + void _skipMarker() { if (_currentMarker != null) { widget.player.seek(_currentMarker!.endTime); @@ -247,6 +269,7 @@ class _PlexVideoControlsState extends State _resizeDebounceTimer?.cancel(); _seekThrottle.cancel(); _playingSubscription?.cancel(); + _completedSubscription?.cancel(); _focusNode.dispose(); // Remove lifecycle observer WidgetsBinding.instance.removeObserver(this); @@ -335,6 +358,18 @@ class _PlexVideoControlsState extends State if (Platform.isMacOS) { _updateTrafficLightVisibility(); } + // On Linux, fully hide after animation completes (200ms) + if (Platform.isLinux) { + Future.delayed(const Duration(milliseconds: 250), () { + if (mounted && !_showControls) { + setState(() { + _controlsFullyHidden = true; + }); + // Hide Flutter view to show only video + widget.player.setControlsVisible(false); + } + }); + } } }); } @@ -350,9 +385,27 @@ class _PlexVideoControlsState extends State void _toggleControls() { setState(() { _showControls = !_showControls; + if (_showControls) { + _controlsFullyHidden = false; + // On Linux, show Flutter view when controls are shown + if (Platform.isLinux) { + widget.player.setControlsVisible(true); + } + } }); if (_showControls) { _startHideTimer(); + } else if (Platform.isLinux) { + // On Linux, fully hide after animation completes (200ms) + Future.delayed(const Duration(milliseconds: 250), () { + if (mounted && !_showControls) { + setState(() { + _controlsFullyHidden = true; + }); + // Hide Flutter view to show only video + widget.player.setControlsVisible(false); + } + }); } // On macOS, hide/show traffic lights with controls @@ -444,6 +497,8 @@ class _PlexVideoControlsState extends State await _loadSeekTimes(); } }, + onCancelAutoHide: () => _hideTimer?.cancel(), + onStartAutoHide: _startHideTimer, serverId: widget.metadata.serverId ?? '', ); } @@ -620,7 +675,12 @@ class _PlexVideoControlsState extends State if (!_showControls) { setState(() { _showControls = true; + _controlsFullyHidden = false; }); + // On Linux, show Flutter view when controls are shown + if (Platform.isLinux) { + widget.player.setControlsVisible(true); + } _startHideTimer(); // On macOS, show traffic lights when controls appear if (Platform.isMacOS) { @@ -639,29 +699,32 @@ class _PlexVideoControlsState extends State ), ), // Custom controls overlay - use AnimatedOpacity to keep widget tree alive + // On Linux, use Offstage after fade completes to fully hide Positioned.fill( - child: IgnorePointer( - ignoring: !_showControls, - child: AnimatedOpacity( - opacity: _showControls ? 1.0 : 0.0, - duration: const Duration(milliseconds: 200), - child: GestureDetector( - onTap: _toggleControls, - behavior: HitTestBehavior.deferToChild, - child: Container( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Colors.black.withValues(alpha: 0.7), - Colors.transparent, - Colors.transparent, - Colors.black.withValues(alpha: 0.7), - ], - stops: const [0.0, 0.2, 0.8, 1.0], + child: Offstage( + offstage: Platform.isLinux && _controlsFullyHidden, + child: IgnorePointer( + ignoring: !_showControls, + child: AnimatedOpacity( + opacity: _showControls ? 1.0 : 0.0, + duration: const Duration(milliseconds: 200), + child: GestureDetector( + onTap: _toggleControls, + behavior: HitTestBehavior.deferToChild, + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.black.withValues(alpha: 0.7), + Colors.transparent, + Colors.transparent, + Colors.black.withValues(alpha: 0.7), + ], + stops: const [0.0, 0.2, 0.8, 1.0], + ), ), - ), child: isMobile ? Listener( behavior: HitTestBehavior.translucent, @@ -708,6 +771,7 @@ class _PlexVideoControlsState extends State getForwardIcon: getForwardIcon, ), ), + ), ), ), ), diff --git a/lib/widgets/video_controls/widgets/track_chapter_controls.dart b/lib/widgets/video_controls/widgets/track_chapter_controls.dart index 07cc2ee9..f55e25f4 100644 --- a/lib/widgets/video_controls/widgets/track_chapter_controls.dart +++ b/lib/widgets/video_controls/widgets/track_chapter_controls.dart @@ -34,6 +34,8 @@ class TrackChapterControls extends StatelessWidget { final Function(MpvAudioTrack)? onAudioTrackChanged; final Function(MpvSubtitleTrack)? onSubtitleTrackChanged; final VoidCallback? onLoadSeekTimes; + final VoidCallback? onCancelAutoHide; + final VoidCallback? onStartAutoHide; final String serverId; const TrackChapterControls({ @@ -56,6 +58,8 @@ class TrackChapterControls extends StatelessWidget { this.onAudioTrackChanged, this.onSubtitleTrackChanged, this.onLoadSeekTimes, + this.onCancelAutoHide, + this.onStartAutoHide, }); @override @@ -89,6 +93,8 @@ class TrackChapterControls extends StatelessWidget { player, audioSyncOffset, subtitleSyncOffset, + onOpen: onCancelAutoHide, + onClose: onStartAutoHide, ); // Sheet is now closed, reload immediately onLoadSeekTimes?.call(); @@ -104,6 +110,8 @@ class TrackChapterControls extends StatelessWidget { context, player, onTrackChanged: onAudioTrackChanged, + onOpen: onCancelAutoHide, + onClose: onStartAutoHide, ), ), if (_hasSubtitles(tracks)) @@ -114,6 +122,8 @@ class TrackChapterControls extends StatelessWidget { context, player, onTrackChanged: onSubtitleTrackChanged, + onOpen: onCancelAutoHide, + onClose: onStartAutoHide, ), ), if (chapters.isNotEmpty) @@ -126,6 +136,8 @@ class TrackChapterControls extends StatelessWidget { chapters, chaptersLoaded, serverId: serverId, + onOpen: onCancelAutoHide, + onClose: onStartAutoHide, ), ), if (availableVersions.length > 1 && onSwitchVersion != null) @@ -137,6 +149,8 @@ class TrackChapterControls extends StatelessWidget { availableVersions, selectedMediaIndex, onSwitchVersion!, + onOpen: onCancelAutoHide, + onClose: onStartAutoHide, ), ), // BoxFit mode cycle button diff --git a/linux/runner/CMakeLists.txt b/linux/runner/CMakeLists.txt index e97dabc7..db4ed130 100644 --- a/linux/runner/CMakeLists.txt +++ b/linux/runner/CMakeLists.txt @@ -9,6 +9,8 @@ project(runner LANGUAGES CXX) add_executable(${BINARY_NAME} "main.cc" "my_application.cc" + "mpv/mpv_player.cc" + "mpv/mpv_plugin.cc" "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" ) @@ -19,8 +21,16 @@ apply_standard_settings(${BINARY_NAME}) # Add preprocessor definitions for the application ID. add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") +# Find mpv library. +pkg_check_modules(MPV REQUIRED IMPORTED_TARGET mpv) + +# Find epoxy (OpenGL loader). +pkg_check_modules(EPOXY REQUIRED IMPORTED_TARGET epoxy) + # Add dependency libraries. Add any application-specific dependencies here. target_link_libraries(${BINARY_NAME} PRIVATE flutter) target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::MPV) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::EPOXY) target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") diff --git a/linux/runner/mpv/mpv_player.cc b/linux/runner/mpv/mpv_player.cc new file mode 100644 index 00000000..7aae6e2a --- /dev/null +++ b/linux/runner/mpv/mpv_player.cc @@ -0,0 +1,464 @@ +#include "mpv_player.h" + +#include +#include +#include +#include +#include +#ifdef GDK_WINDOWING_X11 +#include +#endif +#ifdef GDK_WINDOWING_WAYLAND +#include +#endif +#include +#include +#include + +// Static helper to get proc address - must be defined outside the namespace +// to have the correct function signature. +static void* get_opengl_proc_address(void* ctx, const char* name) { + (void)ctx; +#ifdef GDK_WINDOWING_WAYLAND + // On Wayland, use EGL + if (epoxy_has_egl()) { + return reinterpret_cast(eglGetProcAddress(name)); + } +#endif +#ifdef GDK_WINDOWING_X11 + // On X11, use GLX + return reinterpret_cast(glXGetProcAddressARB( + reinterpret_cast(name))); +#else + // Fallback: try EGL + return reinterpret_cast(eglGetProcAddress(name)); +#endif +} + +namespace mpv { + +MpvPlayer::MpvPlayer() {} + +MpvPlayer::~MpvPlayer() { + Dispose(); +} + +bool MpvPlayer::Initialize(GtkGLArea* gl_area) { + if (mpv_) { + return true; // Already initialized. + } + + gl_area_ = gl_area; + + // Check if GL area is realized + if (!gtk_widget_get_realized(GTK_WIDGET(gl_area))) { + g_warning("MPV: GL area not realized yet"); + return false; + } + + // MPV requires C locale for numeric formatting + std::setlocale(LC_NUMERIC, "C"); + + // Create mpv instance. + mpv_ = mpv_create(); + if (!mpv_) { + g_warning("MPV: mpv_create() failed"); + return false; + } + + // Configure mpv for embedded playback. + mpv_set_option_string(mpv_, "vo", "libmpv"); // Render via mpv_render_context_render() + mpv_set_option_string(mpv_, "hwdec", "auto"); + mpv_set_option_string(mpv_, "keep-open", "yes"); + mpv_set_option_string(mpv_, "idle", "yes"); + mpv_set_option_string(mpv_, "input-default-bindings", "no"); + mpv_set_option_string(mpv_, "input-vo-keyboard", "no"); + mpv_set_option_string(mpv_, "osc", "no"); + mpv_set_option_string(mpv_, "terminal", "no"); + + // Enable verbose logging for debugging. + mpv_request_log_messages(mpv_, "v"); + + // Initialize mpv. + int err = mpv_initialize(mpv_); + if (err < 0) { + g_warning("MPV: mpv_initialize() failed: %s", mpv_error_string(err)); + mpv_destroy(mpv_); + mpv_ = nullptr; + return false; + } + + // Make the GL context current. + gtk_gl_area_make_current(gl_area_); + if (gtk_gl_area_get_error(gl_area_) != nullptr) { + g_warning("MPV: Failed to make GL context current"); + mpv_terminate_destroy(mpv_); + mpv_ = nullptr; + return false; + } + + // Set up OpenGL parameters for mpv. + mpv_opengl_init_params gl_init_params{ + .get_proc_address = get_opengl_proc_address, + .get_proc_address_ctx = nullptr, + }; + + mpv_render_param params[] = { + {MPV_RENDER_PARAM_API_TYPE, + const_cast(MPV_RENDER_API_TYPE_OPENGL)}, + {MPV_RENDER_PARAM_OPENGL_INIT_PARAMS, &gl_init_params}, + {MPV_RENDER_PARAM_INVALID, nullptr}, + }; + + err = mpv_render_context_create(&mpv_gl_, mpv_, params); + if (err < 0) { + g_warning("MPV: mpv_render_context_create() failed: %s", + mpv_error_string(err)); + mpv_terminate_destroy(mpv_); + mpv_ = nullptr; + return false; + } + + // Set up event wakeup callback. + mpv_set_wakeup_callback(mpv_, OnMpvWakeup, this); + + // Set up render update callback. + mpv_render_context_set_update_callback(mpv_gl_, OnMpvRenderUpdate, this); + + g_message("MPV: Initialization successful"); + return true; +} + +void MpvPlayer::Dispose() { + // Guard against multiple dispose calls (double-free protection) + if (disposed_.exchange(true)) { + return; // Already disposed + } + + // Clear mpv callbacks BEFORE freeing to prevent new callbacks being scheduled + if (mpv_gl_) { + mpv_render_context_set_update_callback(mpv_gl_, nullptr, nullptr); + } + if (mpv_) { + mpv_set_wakeup_callback(mpv_, nullptr, nullptr); + } + + // Remove pending idle callbacks + if (event_source_id_ != 0) { + g_source_remove(event_source_id_); + event_source_id_ = 0; + } + + // Now safe to free render context + if (mpv_gl_) { + mpv_render_context_free(mpv_gl_); + mpv_gl_ = nullptr; + } + + // And terminate mpv + if (mpv_) { + mpv_terminate_destroy(mpv_); + mpv_ = nullptr; + } + + observed_properties_.clear(); + gl_area_ = nullptr; +} + +void MpvPlayer::Command(const std::vector& args) { + if (disposed_ || !mpv_) return; + + std::vector c_args; + c_args.reserve(args.size() + 1); + for (const auto& arg : args) { + c_args.push_back(arg.c_str()); + } + c_args.push_back(nullptr); + + mpv_command(mpv_, c_args.data()); +} + +void MpvPlayer::SetProperty(const std::string& name, const std::string& value) { + if (disposed_ || !mpv_) return; + mpv_set_property_string(mpv_, name.c_str(), value.c_str()); +} + +std::string MpvPlayer::GetProperty(const std::string& name) { + if (disposed_ || !mpv_) return ""; + + char* value = mpv_get_property_string(mpv_, name.c_str()); + if (!value) return ""; + + std::string result(value); + mpv_free(value); + return result; +} + +void MpvPlayer::ObserveProperty(const std::string& name, + const std::string& format) { + if (disposed_ || !mpv_) return; + + // Check if already observing. + if (observed_properties_.find(name) != observed_properties_.end()) { + return; + } + + mpv_format mpv_fmt = MPV_FORMAT_NONE; + if (format == "string") { + mpv_fmt = MPV_FORMAT_STRING; + } else if (format == "flag" || format == "bool") { + mpv_fmt = MPV_FORMAT_FLAG; + } else if (format == "int64") { + mpv_fmt = MPV_FORMAT_INT64; + } else if (format == "double") { + mpv_fmt = MPV_FORMAT_DOUBLE; + } else if (format == "node") { + mpv_fmt = MPV_FORMAT_NODE; + } + + uint64_t userdata = next_reply_userdata_++; + observed_properties_[name] = userdata; + mpv_observe_property(mpv_, userdata, name.c_str(), mpv_fmt); +} + +void MpvPlayer::Render(int width, int height, int fbo) { + if (disposed_ || !mpv_gl_) return; + + mpv_opengl_fbo mpv_fbo{ + .fbo = fbo, + .w = width, + .h = height, + .internal_format = 0, + }; + + int flip_y = 1; + + mpv_render_param params[] = { + {MPV_RENDER_PARAM_OPENGL_FBO, &mpv_fbo}, + {MPV_RENDER_PARAM_FLIP_Y, &flip_y}, + {MPV_RENDER_PARAM_INVALID, nullptr}, + }; + + mpv_render_context_render(mpv_gl_, params); +} + +void MpvPlayer::ReportMouseMove(int x, int y) { + if (disposed_ || !mpv_) return; + std::string x_str = std::to_string(x); + std::string y_str = std::to_string(y); + const char* args[] = {"mouse", x_str.c_str(), y_str.c_str(), nullptr}; + mpv_command_async(mpv_, 0, args); +} + +void MpvPlayer::SetEventCallback(EventCallback callback) { + std::lock_guard lock(callback_mutex_); + event_callback_ = std::move(callback); +} + +void MpvPlayer::RequestRedraw() { + if (disposed_) return; + + needs_redraw_.store(true); + if (gl_area_) { + // Queue redraw on main thread + GtkGLArea* area = gl_area_; + g_idle_add( + [](gpointer data) -> gboolean { + GtkGLArea* area = static_cast(data); + if (GTK_IS_GL_AREA(area)) { + gtk_gl_area_queue_render(area); + } + return G_SOURCE_REMOVE; + }, + area); + } +} + +void MpvPlayer::OnMpvWakeup(void* ctx) { + auto* player = static_cast(ctx); + + // Don't schedule if already disposed + 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_) { + player->ProcessEvents(); + } + return G_SOURCE_REMOVE; + }, + player); +} + +void MpvPlayer::OnMpvRenderUpdate(void* ctx) { + auto* player = static_cast(ctx); + // RequestRedraw already checks disposed_ + player->RequestRedraw(); +} + +bool MpvPlayer::ProcessEvents() { + if (disposed_ || !mpv_) return false; + + while (true) { + mpv_event* event = mpv_wait_event(mpv_, 0); + if (event->event_id == MPV_EVENT_NONE) { + break; + } + if (event->event_id == MPV_EVENT_SHUTDOWN) { + return false; + } + HandleMpvEvent(event); + } + return true; +} + +void MpvPlayer::HandleMpvEvent(mpv_event* event) { + switch (event->event_id) { + case MPV_EVENT_LOG_MESSAGE: { + auto* msg = static_cast(event->data); + g_message("MPV [%s] %s: %s", msg->level, msg->prefix, msg->text); + + FlValue* data = fl_value_new_map(); + fl_value_set_string_take(data, "prefix", + fl_value_new_string(msg->prefix ? msg->prefix : "")); + fl_value_set_string_take(data, "level", + fl_value_new_string(msg->level ? msg->level : "")); + fl_value_set_string_take(data, "text", + fl_value_new_string(msg->text ? msg->text : "")); + SendEvent("log-message", data); + fl_value_unref(data); + break; + } + case MPV_EVENT_PROPERTY_CHANGE: { + auto* prop = static_cast(event->data); + mpv_node node; + node.format = prop->format; + + switch (prop->format) { + case MPV_FORMAT_STRING: + node.u.string = + prop->data ? *static_cast(prop->data) : nullptr; + break; + case MPV_FORMAT_FLAG: + node.u.flag = prop->data ? *static_cast(prop->data) : 0; + break; + case MPV_FORMAT_INT64: + node.u.int64 = prop->data ? *static_cast(prop->data) : 0; + break; + case MPV_FORMAT_DOUBLE: + node.u.double_ = prop->data ? *static_cast(prop->data) : 0.0; + break; + case MPV_FORMAT_NODE: + if (prop->data) { + node = *static_cast(prop->data); + } + break; + default: + node.format = MPV_FORMAT_NONE; + break; + } + + SendPropertyChange(prop->name, &node); + break; + } + case MPV_EVENT_END_FILE: { + auto* end = static_cast(event->data); + FlValue* data = fl_value_new_map(); + fl_value_set_string_take(data, "reason", + fl_value_new_int(static_cast(end->reason))); + if (end->reason == MPV_END_FILE_REASON_ERROR) { + fl_value_set_string_take(data, "error", + fl_value_new_int(static_cast(end->error))); + } + SendEvent("end-file", data); + fl_value_unref(data); + break; + } + case MPV_EVENT_FILE_LOADED: { + SendEvent("file-loaded"); + break; + } + case MPV_EVENT_PLAYBACK_RESTART: { + SendEvent("playback-restart"); + break; + } + case MPV_EVENT_SEEK: { + SendEvent("seek"); + break; + } + default: + break; + } +} + +FlValue* MpvPlayer::NodeToFlValue(mpv_node* node) { + if (!node) return fl_value_new_null(); + + switch (node->format) { + case MPV_FORMAT_STRING: + return fl_value_new_string(node->u.string ? node->u.string : ""); + case MPV_FORMAT_FLAG: + return fl_value_new_bool(node->u.flag != 0); + case MPV_FORMAT_INT64: + return fl_value_new_int(node->u.int64); + case MPV_FORMAT_DOUBLE: + return fl_value_new_float(node->u.double_); + case MPV_FORMAT_NODE_ARRAY: { + FlValue* list = fl_value_new_list(); + for (int i = 0; i < node->u.list->num; i++) { + fl_value_append_take(list, NodeToFlValue(&node->u.list->values[i])); + } + return list; + } + case MPV_FORMAT_NODE_MAP: { + FlValue* map = fl_value_new_map(); + for (int i = 0; i < node->u.list->num; i++) { + fl_value_set_string_take( + map, node->u.list->keys[i], + NodeToFlValue(&node->u.list->values[i])); + } + return map; + } + default: + return fl_value_new_null(); + } +} + +void MpvPlayer::SendPropertyChange(const char* name, mpv_node* data) { + FlValue* event_map = fl_value_new_map(); + fl_value_set_string_take(event_map, "type", fl_value_new_string("property")); + fl_value_set_string_take(event_map, "name", + fl_value_new_string(name ? name : "")); + + if (data) { + fl_value_set_string_take(event_map, "value", NodeToFlValue(data)); + } else { + fl_value_set_string_take(event_map, "value", fl_value_new_null()); + } + + std::lock_guard lock(callback_mutex_); + if (event_callback_) { + event_callback_(event_map); + } + fl_value_unref(event_map); +} + +void MpvPlayer::SendEvent(const std::string& name, FlValue* data) { + FlValue* event_map = fl_value_new_map(); + fl_value_set_string_take(event_map, "type", fl_value_new_string("event")); + fl_value_set_string_take(event_map, "name", fl_value_new_string(name.c_str())); + if (data) { + fl_value_set_string_take(event_map, "data", fl_value_ref(data)); + } + + std::lock_guard lock(callback_mutex_); + if (event_callback_) { + event_callback_(event_map); + } + fl_value_unref(event_map); +} + +} // namespace mpv diff --git a/linux/runner/mpv/mpv_player.h b/linux/runner/mpv/mpv_player.h new file mode 100644 index 00000000..926fc0ff --- /dev/null +++ b/linux/runner/mpv/mpv_player.h @@ -0,0 +1,134 @@ +#ifndef MPV_PLAYER_H_ +#define MPV_PLAYER_H_ + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +// Forward declaration for Flutter types +struct _FlValue; + +namespace mpv { + +/// Callback function type for mpv events. +/// Note: FlValue* is passed from the global namespace, not mpv namespace. +using EventCallback = std::function; + +/// Wrapper for libmpv that handles initialization, OpenGL rendering, +/// commands, properties, and event dispatching. +class MpvPlayer { + public: + MpvPlayer(); + ~MpvPlayer(); + + /// Initializes mpv with OpenGL rendering context. + /// Must be called from the GTK main thread after GL context is available. + /// @param gl_area The GtkGLArea widget for rendering. + /// @return true if initialization succeeded. + bool Initialize(GtkGLArea* gl_area); + + /// Disposes mpv and releases resources. + void Dispose(); + + /// Returns true if mpv is initialized. + bool IsInitialized() const { return mpv_ != nullptr && mpv_gl_ != nullptr; } + + /// Executes an mpv command. + /// @param args Command arguments (e.g., ["loadfile", "url", "replace"]). + void Command(const std::vector& args); + + /// Sets an mpv property by name. + /// @param name Property name. + /// @param value Property value as string. + void SetProperty(const std::string& name, const std::string& value); + + /// Gets an mpv property value by name. + /// @param name Property name. + /// @return Property value as string, or empty if not found. + std::string GetProperty(const std::string& name); + + /// Observes an mpv property for changes. + /// Changes will be reported via the event callback. + /// @param name Property name to observe. + /// @param format Format type ("string", "flag", "int64", "double", "node"). + void ObserveProperty(const std::string& name, const std::string& format); + + /// Renders a frame to the current OpenGL context. + /// Must be called from the GTK render callback. + /// @param width Viewport width. + /// @param height Viewport height. + /// @param fbo Framebuffer object to render into (0 for default). + void Render(int width, int height, int fbo = 0); + + /// Reports that the mouse has moved. + /// This is used to show/hide the cursor. + void ReportMouseMove(int x, int y); + + /// Sets the event callback for property changes and events. + void SetEventCallback(EventCallback callback); + + /// Returns the GtkGLArea widget. + GtkGLArea* GetGLArea() const { return gl_area_; } + + /// Returns true if a redraw is needed. + bool NeedsRedraw() const { return needs_redraw_.load(); } + + /// Clears the redraw flag. + void ClearRedrawFlag() { needs_redraw_.store(false); } + + /// Request a redraw. + void RequestRedraw(); + + private: + /// MPV event wakeup callback (called from mpv thread). + static void OnMpvWakeup(void* ctx); + + /// MPV render update callback (called when frame is ready). + static void OnMpvRenderUpdate(void* ctx); + + /// Processes pending mpv events. + /// @return true to keep processing, false if shutdown. + bool ProcessEvents(); + + /// Handles a single mpv event. + void HandleMpvEvent(mpv_event* event); + + /// Sends a property change notification. + void SendPropertyChange(const char* name, mpv_node* data); + + /// Sends an event notification. + void SendEvent(const std::string& name, ::_FlValue* data = nullptr); + + /// Helper to convert mpv_node to FlValue. + ::_FlValue* NodeToFlValue(mpv_node* node); + + mpv_handle* mpv_ = nullptr; + mpv_render_context* mpv_gl_ = nullptr; + GtkGLArea* gl_area_ = nullptr; + + std::atomic needs_redraw_{false}; + std::atomic disposed_{false}; + EventCallback event_callback_; + std::mutex callback_mutex_; + + uint64_t next_reply_userdata_ = 1; + std::map observed_properties_; + + // GSource for processing events on main thread + guint event_source_id_ = 0; +}; + +} // namespace mpv + +#endif // MPV_PLAYER_H_ diff --git a/linux/runner/mpv/mpv_plugin.cc b/linux/runner/mpv/mpv_plugin.cc new file mode 100644 index 00000000..d6d02bd9 --- /dev/null +++ b/linux/runner/mpv/mpv_plugin.cc @@ -0,0 +1,411 @@ +#include "mpv_plugin.h" + +#include + +/// Plugin structure definition. +struct _MpvPlugin { + GObject parent_instance; + + FlPluginRegistrar* registrar; + FlMethodChannel* method_channel; + FlEventChannel* event_channel; + FlBasicMessageChannel* event_message_channel; + + GtkOverlay* overlay; + GtkGLArea* gl_area; + GtkWidget* flutter_view; + + std::unique_ptr player; + gboolean visible; + gboolean initialized; +}; + +G_DEFINE_TYPE(MpvPlugin, mpv_plugin, G_TYPE_OBJECT) + +// Forward declarations +static void mpv_plugin_handle_method_call(FlMethodChannel* channel, + FlMethodCall* method_call, + gpointer user_data); +static gboolean on_gl_render(GtkGLArea* area, + GdkGLContext* context, + 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 mpv_plugin_dispose(GObject* object) { + MpvPlugin* self = MPV_PLUGIN(object); + + if (self->player) { + self->player->Dispose(); + self->player.reset(); + } + + g_clear_object(&self->method_channel); + g_clear_object(&self->event_channel); + g_clear_object(&self->registrar); + + G_OBJECT_CLASS(mpv_plugin_parent_class)->dispose(object); +} + +static void mpv_plugin_class_init(MpvPluginClass* klass) { + G_OBJECT_CLASS(klass)->dispose = mpv_plugin_dispose; +} + +static void mpv_plugin_init(MpvPlugin* self) { + self->visible = FALSE; + self->initialized = FALSE; +} + +/// Send an event through the event channel. +static void send_event(MpvPlugin* self, FlValue* event) { + if (self->event_channel) { + g_autoptr(GError) error = nullptr; + if (!fl_event_channel_send(self->event_channel, event, nullptr, &error)) { + if (error != nullptr) { + g_warning("Failed to send event: %s", error->message); + } + } + } +} + +MpvPlugin* mpv_plugin_new(FlPluginRegistrar* registrar, + GtkOverlay* overlay, + GtkGLArea* gl_area, + GtkWidget* flutter_view) { + MpvPlugin* self = MPV_PLUGIN(g_object_new(MPV_PLUGIN_TYPE, nullptr)); + + self->registrar = FL_PLUGIN_REGISTRAR(g_object_ref(registrar)); + self->overlay = overlay; + self->gl_area = gl_area; + self->flutter_view = flutter_view; + self->player = std::make_unique(); + + // Create method channel. + g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new(); + self->method_channel = fl_method_channel_new( + fl_plugin_registrar_get_messenger(registrar), + "com.plezy/mpv_player", + FL_METHOD_CODEC(codec)); + + fl_method_channel_set_method_call_handler( + self->method_channel, + mpv_plugin_handle_method_call, + self, + nullptr); + + // Create event channel. + self->event_channel = fl_event_channel_new( + fl_plugin_registrar_get_messenger(registrar), + "com.plezy/mpv_player/events", + FL_METHOD_CODEC(codec)); + + // Connect GtkGLArea signals. + 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); + + // Set up auto-render to false - we control when to render. + gtk_gl_area_set_auto_render(gl_area, FALSE); + + // Use OpenGL 3.3 core profile. + gtk_gl_area_set_required_version(gl_area, 3, 3); + + return self; +} + +// Static reference to keep the plugin alive for the lifetime of the app. +// The plugin will be disposed when the GL area is unrealized. +static MpvPlugin* g_mpv_plugin = nullptr; + +void mpv_plugin_register_with_registrar(FlPluginRegistrar* registrar, + GtkOverlay* overlay, + GtkGLArea* gl_area, + GtkWidget* flutter_view) { + g_mpv_plugin = mpv_plugin_new(registrar, overlay, gl_area, flutter_view); + // Keep a reference - the plugin will be cleaned up when the app exits +} + +/// GtkGLArea render callback. +static gboolean on_gl_render(GtkGLArea* area, + GdkGLContext* context, + gpointer user_data) { + (void)context; + MpvPlugin* self = MPV_PLUGIN(user_data); + + if (!self->player || !self->player->IsInitialized() || !self->visible) { + // Clear to transparent when not showing video. + glClearColor(0.0f, 0.0f, 0.0f, 0.0f); + glClear(GL_COLOR_BUFFER_BIT); + return TRUE; + } + + int width = gtk_widget_get_allocated_width(GTK_WIDGET(area)); + int height = gtk_widget_get_allocated_height(GTK_WIDGET(area)); + + // Get the scale factor for HiDPI support. + int scale = gtk_widget_get_scale_factor(GTK_WIDGET(area)); + width *= scale; + height *= scale; + + // Get the FBO that GtkGLArea is rendering to. + // GtkGLArea uses its own FBO, not the default framebuffer (0). + GLint fbo = 0; + glGetIntegerv(GL_FRAMEBUFFER_BINDING, &fbo); + + // Render the video frame. + self->player->Render(width, height, fbo); + self->player->ClearRedrawFlag(); + + return TRUE; +} + +/// GtkGLArea realize callback. +static void on_gl_realize(GtkGLArea* area, gpointer user_data) { + (void)user_data; + gtk_gl_area_make_current(area); + + // Check for GL errors. + GError* error = gtk_gl_area_get_error(area); + if (error != nullptr) { + g_warning("MPV Plugin: GL area error: %s", error->message); + return; + } + + // Enable blending for transparency support. + glEnable(GL_BLEND); + glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); + + g_message("MPV Plugin: GL area realized"); +} + +/// GtkGLArea unrealize callback. +static void on_gl_unrealize(GtkGLArea* area, gpointer user_data) { + MpvPlugin* self = MPV_PLUGIN(user_data); + + gtk_gl_area_make_current(area); + + if (self->player) { + self->player->Dispose(); + } + + g_message("MPV Plugin: GL area unrealized"); +} + +/// Method call handler. +static void mpv_plugin_handle_method_call(FlMethodChannel* channel, + FlMethodCall* method_call, + gpointer user_data) { + (void)channel; + MpvPlugin* self = MPV_PLUGIN(user_data); + const gchar* method = fl_method_call_get_name(method_call); + FlValue* args = fl_method_call_get_args(method_call); + + g_autoptr(FlMethodResponse) response = nullptr; + + if (strcmp(method, "initialize") == 0) { + if (self->initialized) { + response = FL_METHOD_RESPONSE( + fl_method_success_response_new(fl_value_new_bool(TRUE))); + } else { + // Create player if it was disposed + if (!self->player) { + self->player = std::make_unique(); + } + + // Check if GL area is realized before trying to use it + if (!gtk_widget_get_realized(GTK_WIDGET(self->gl_area))) { + // Force realization of the GL area + gtk_widget_realize(GTK_WIDGET(self->gl_area)); + } + + // Initialize the player with the GL area. + gtk_gl_area_make_current(self->gl_area); + + GError* error = gtk_gl_area_get_error(self->gl_area); + if (error != nullptr) { + response = FL_METHOD_RESPONSE(fl_method_error_response_new( + "GL_ERROR", error->message, nullptr)); + } else if (self->player->Initialize(self->gl_area)) { + self->initialized = TRUE; + + // Set up event callback. + self->player->SetEventCallback([self](FlValue* event) { + // Send event - must be called from main thread + // The event is already created on the main thread via g_idle_add in mpv_player.cc + send_event(self, event); + }); + + response = FL_METHOD_RESPONSE( + fl_method_success_response_new(fl_value_new_bool(TRUE))); + } else { + response = FL_METHOD_RESPONSE(fl_method_error_response_new( + "INIT_FAILED", "Failed to initialize MPV player", nullptr)); + } + } + } else if (strcmp(method, "dispose") == 0) { + if (self->player) { + // Make GL context current before disposing mpv GL resources + gtk_gl_area_make_current(self->gl_area); + self->player->Dispose(); + self->player.reset(); + } + 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); + } + response = FL_METHOD_RESPONSE(fl_method_success_response_new(nullptr)); + } else if (strcmp(method, "command") == 0) { + if (!self->player || !self->initialized) { + response = FL_METHOD_RESPONSE(fl_method_error_response_new( + "NOT_INITIALIZED", "Player not initialized", nullptr)); + } else { + FlValue* args_value = fl_value_lookup_string(args, "args"); + if (args_value == nullptr || + fl_value_get_type(args_value) != FL_VALUE_TYPE_LIST) { + response = FL_METHOD_RESPONSE(fl_method_error_response_new( + "INVALID_ARGS", "Missing 'args' list", nullptr)); + } else { + std::vector command_args; + size_t len = fl_value_get_length(args_value); + for (size_t i = 0; i < len; i++) { + FlValue* item = fl_value_get_list_value(args_value, i); + if (fl_value_get_type(item) == FL_VALUE_TYPE_STRING) { + command_args.push_back(fl_value_get_string(item)); + } + } + self->player->Command(command_args); + response = FL_METHOD_RESPONSE(fl_method_success_response_new(nullptr)); + } + } + } else if (strcmp(method, "setProperty") == 0) { + if (!self->player || !self->initialized) { + response = FL_METHOD_RESPONSE(fl_method_error_response_new( + "NOT_INITIALIZED", "Player not initialized", nullptr)); + } else { + FlValue* name_value = fl_value_lookup_string(args, "name"); + FlValue* value_value = fl_value_lookup_string(args, "value"); + + if (name_value == nullptr || + fl_value_get_type(name_value) != FL_VALUE_TYPE_STRING) { + response = FL_METHOD_RESPONSE(fl_method_error_response_new( + "INVALID_ARGS", "Missing 'name'", nullptr)); + } else if (value_value == nullptr || + fl_value_get_type(value_value) != FL_VALUE_TYPE_STRING) { + response = FL_METHOD_RESPONSE(fl_method_error_response_new( + "INVALID_ARGS", "Missing 'value'", nullptr)); + } else { + self->player->SetProperty(fl_value_get_string(name_value), + fl_value_get_string(value_value)); + response = FL_METHOD_RESPONSE(fl_method_success_response_new(nullptr)); + } + } + } else if (strcmp(method, "getProperty") == 0) { + if (!self->player || !self->initialized) { + response = FL_METHOD_RESPONSE(fl_method_error_response_new( + "NOT_INITIALIZED", "Player not initialized", nullptr)); + } else { + FlValue* name_value = fl_value_lookup_string(args, "name"); + + if (name_value == nullptr || + fl_value_get_type(name_value) != FL_VALUE_TYPE_STRING) { + response = FL_METHOD_RESPONSE(fl_method_error_response_new( + "INVALID_ARGS", "Missing 'name'", nullptr)); + } else { + std::string value = + self->player->GetProperty(fl_value_get_string(name_value)); + if (value.empty()) { + response = + FL_METHOD_RESPONSE(fl_method_success_response_new(nullptr)); + } else { + response = FL_METHOD_RESPONSE(fl_method_success_response_new( + fl_value_new_string(value.c_str()))); + } + } + } + } else if (strcmp(method, "observeProperty") == 0) { + if (!self->player || !self->initialized) { + response = FL_METHOD_RESPONSE(fl_method_error_response_new( + "NOT_INITIALIZED", "Player not initialized", nullptr)); + } else { + FlValue* name_value = fl_value_lookup_string(args, "name"); + FlValue* format_value = fl_value_lookup_string(args, "format"); + + if (name_value == nullptr || + fl_value_get_type(name_value) != FL_VALUE_TYPE_STRING) { + response = FL_METHOD_RESPONSE(fl_method_error_response_new( + "INVALID_ARGS", "Missing 'name'", nullptr)); + } else if (format_value == nullptr || + fl_value_get_type(format_value) != FL_VALUE_TYPE_STRING) { + response = FL_METHOD_RESPONSE(fl_method_error_response_new( + "INVALID_ARGS", "Missing 'format'", nullptr)); + } else { + self->player->ObserveProperty(fl_value_get_string(name_value), + fl_value_get_string(format_value)); + response = FL_METHOD_RESPONSE(fl_method_success_response_new(nullptr)); + } + } + } else if (strcmp(method, "setVisible") == 0) { + FlValue* visible_value = fl_value_lookup_string(args, "visible"); + + if (visible_value == nullptr || + fl_value_get_type(visible_value) != FL_VALUE_TYPE_BOOL) { + response = FL_METHOD_RESPONSE(fl_method_error_response_new( + "INVALID_ARGS", "Missing 'visible'", nullptr)); + } else { + gboolean visible = fl_value_get_bool(visible_value); + self->visible = visible; + + // Show/hide the GL area. + gtk_widget_set_visible(GTK_WIDGET(self->gl_area), visible); + + if (visible) { + gtk_gl_area_queue_render(self->gl_area); + } + + response = FL_METHOD_RESPONSE(fl_method_success_response_new(nullptr)); + } + } else if (strcmp(method, "setVideoRect") == 0) { + // On Linux, the GtkGLArea fills the entire overlay area, + // and mpv handles its own aspect ratio. So we just trigger a redraw. + if (self->player && self->initialized && self->visible) { + 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) { + gboolean initialized = self->player && self->initialized; + response = FL_METHOD_RESPONSE( + fl_method_success_response_new(fl_value_new_bool(initialized))); + } else { + response = FL_METHOD_RESPONSE(fl_method_not_implemented_response_new()); + } + + fl_method_call_respond(method_call, response, nullptr); +} diff --git a/linux/runner/mpv/mpv_plugin.h b/linux/runner/mpv/mpv_plugin.h new file mode 100644 index 00000000..ceb55742 --- /dev/null +++ b/linux/runner/mpv/mpv_plugin.h @@ -0,0 +1,40 @@ +#ifndef MPV_PLUGIN_H_ +#define MPV_PLUGIN_H_ + +#include +#include + +#include + +#include "mpv_player.h" + +G_BEGIN_DECLS + +/// Plugin for MPV video playback on Linux. +/// +/// This plugin uses OpenGL rendering via GtkGLArea, +/// positioned behind the Flutter view using a GtkOverlay. + +#define MPV_PLUGIN_TYPE (mpv_plugin_get_type()) + +G_DECLARE_FINAL_TYPE(MpvPlugin, mpv_plugin, MPV, PLUGIN, GObject) + +/// Creates a new MpvPlugin instance. +/// @param registrar The Flutter plugin registrar. +/// @param overlay The GtkOverlay containing the GtkGLArea and FlView. +/// @param gl_area The GtkGLArea widget for video rendering. +/// @param flutter_view The Flutter view widget (for visibility control). +MpvPlugin* mpv_plugin_new(FlPluginRegistrar* registrar, + GtkOverlay* overlay, + GtkGLArea* gl_area, + GtkWidget* flutter_view); + +/// Registers the plugin with Flutter. +void mpv_plugin_register_with_registrar(FlPluginRegistrar* registrar, + GtkOverlay* overlay, + GtkGLArea* gl_area, + GtkWidget* flutter_view); + +G_END_DECLS + +#endif // MPV_PLUGIN_H_ diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index 41942947..dfecf1e7 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -6,14 +6,34 @@ #endif #include "flutter/generated_plugin_registrant.h" +#include "mpv/mpv_plugin.h" struct _MyApplication { GtkApplication parent_instance; char** dart_entrypoint_arguments; + + // MPV-related widgets + GtkOverlay* overlay; + GtkGLArea* gl_area; + FlView* flutter_view; }; G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) +/// Sets up an RGBA visual for transparency support. +static void setup_rgba_visual(GtkWidget* widget) { + GdkScreen* screen = gtk_widget_get_screen(widget); + if (!gdk_screen_is_composited(screen)) { + g_warning("Screen is not composited - transparency may not work"); + } + GdkVisual* visual = gdk_screen_get_rgba_visual(screen); + if (visual != nullptr) { + gtk_widget_set_visual(widget, visual); + } else { + g_warning("No RGBA visual available"); + } +} + // Implements GApplication::activate. static void my_application_activate(GApplication* application) { MyApplication* self = MY_APPLICATION(application); @@ -48,31 +68,90 @@ static void my_application_activate(GApplication* application) { } gtk_window_set_default_size(window, 1280, 720); - gtk_widget_show(GTK_WIDGET(window)); + // Set up RGBA visual for transparency support. + gtk_widget_set_app_paintable(GTK_WIDGET(window), TRUE); + setup_rgba_visual(GTK_WIDGET(window)); + + // Create the overlay container. + // The overlay allows us to layer widgets on top of each other: + // - Bottom layer: GtkGLArea for mpv video rendering + // - Top layer: FlView (Flutter) with transparent background + self->overlay = GTK_OVERLAY(gtk_overlay_new()); + gtk_widget_show(GTK_WIDGET(self->overlay)); + + // Create the GtkGLArea for mpv video rendering. + // This will be the bottom layer (behind Flutter). + self->gl_area = GTK_GL_AREA(gtk_gl_area_new()); + gtk_widget_set_hexpand(GTK_WIDGET(self->gl_area), TRUE); + gtk_widget_set_vexpand(GTK_WIDGET(self->gl_area), TRUE); + + // Configure GL area for transparency and proper rendering. + gtk_gl_area_set_has_alpha(self->gl_area, TRUE); + gtk_gl_area_set_has_depth_buffer(self->gl_area, FALSE); + gtk_gl_area_set_has_stencil_buffer(self->gl_area, FALSE); + + // Make GL area non-interactive so mouse events pass through to Flutter. + gtk_widget_set_can_focus(GTK_WIDGET(self->gl_area), FALSE); + gtk_widget_set_sensitive(GTK_WIDGET(self->gl_area), FALSE); + + // Set the GL area as the base widget of the overlay. + // Initially hidden - will be shown when video playback starts. + gtk_widget_set_visible(GTK_WIDGET(self->gl_area), FALSE); + gtk_container_add(GTK_CONTAINER(self->overlay), GTK_WIDGET(self->gl_area)); + + // Create the Flutter view. g_autoptr(FlDartProject) project = fl_dart_project_new(); - fl_dart_project_set_dart_entrypoint_arguments(project, self->dart_entrypoint_arguments); + fl_dart_project_set_dart_entrypoint_arguments(project, + self->dart_entrypoint_arguments); - FlView* view = fl_view_new(project); - gtk_widget_show(GTK_WIDGET(view)); - gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + self->flutter_view = fl_view_new(project); + gtk_widget_set_hexpand(GTK_WIDGET(self->flutter_view), TRUE); + gtk_widget_set_vexpand(GTK_WIDGET(self->flutter_view), TRUE); - fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + // Enable transparency for the Flutter view. + gtk_widget_set_app_paintable(GTK_WIDGET(self->flutter_view), TRUE); + setup_rgba_visual(GTK_WIDGET(self->flutter_view)); - gtk_widget_grab_focus(GTK_WIDGET(view)); + // Enable transparent background for the Flutter view. + // This allows the mpv video to show through transparent areas. + GdkRGBA transparent = {0.0, 0.0, 0.0, 0.0}; + fl_view_set_background_color(self->flutter_view, &transparent); + + // Add the Flutter view as an overlay on top of the GL area. + gtk_widget_show(GTK_WIDGET(self->flutter_view)); + gtk_overlay_add_overlay(self->overlay, GTK_WIDGET(self->flutter_view)); + + // Add the overlay to the window. + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(self->overlay)); + + // Register Flutter plugins. + fl_register_plugins(FL_PLUGIN_REGISTRY(self->flutter_view)); + + // Register the MPV plugin with the GL area and Flutter view for video rendering. + FlPluginRegistrar* registrar = + fl_plugin_registry_get_registrar_for_plugin(FL_PLUGIN_REGISTRY(self->flutter_view), + "MpvPlugin"); + mpv_plugin_register_with_registrar(registrar, self->overlay, self->gl_area, + GTK_WIDGET(self->flutter_view)); + + gtk_widget_show(GTK_WIDGET(window)); + gtk_widget_grab_focus(GTK_WIDGET(self->flutter_view)); } // Implements GApplication::local_command_line. -static gboolean my_application_local_command_line(GApplication* application, gchar*** arguments, int* exit_status) { +static gboolean my_application_local_command_line(GApplication* application, + gchar*** arguments, + int* exit_status) { MyApplication* self = MY_APPLICATION(application); // Strip out the first argument as it is the binary name. self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); g_autoptr(GError) error = nullptr; if (!g_application_register(application, nullptr, &error)) { - g_warning("Failed to register: %s", error->message); - *exit_status = 1; - return TRUE; + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; } g_application_activate(application); @@ -83,7 +162,7 @@ static gboolean my_application_local_command_line(GApplication* application, gch // Implements GApplication::startup. static void my_application_startup(GApplication* application) { - //MyApplication* self = MY_APPLICATION(object); + // MyApplication* self = MY_APPLICATION(object); // Perform any actions required at application startup. @@ -92,7 +171,7 @@ static void my_application_startup(GApplication* application) { // Implements GApplication::shutdown. static void my_application_shutdown(GApplication* application) { - //MyApplication* self = MY_APPLICATION(object); + // MyApplication* self = MY_APPLICATION(object); // Perform any actions required at application shutdown. @@ -108,13 +187,18 @@ static void my_application_dispose(GObject* object) { static void my_application_class_init(MyApplicationClass* klass) { G_APPLICATION_CLASS(klass)->activate = my_application_activate; - G_APPLICATION_CLASS(klass)->local_command_line = my_application_local_command_line; + G_APPLICATION_CLASS(klass)->local_command_line = + my_application_local_command_line; G_APPLICATION_CLASS(klass)->startup = my_application_startup; G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; G_OBJECT_CLASS(klass)->dispose = my_application_dispose; } -static void my_application_init(MyApplication* self) {} +static void my_application_init(MyApplication* self) { + self->overlay = nullptr; + self->gl_area = nullptr; + self->flutter_view = nullptr; +} MyApplication* my_application_new() { // Set the program name to the application ID, which helps various systems