From 9e506d3f68d69866ac233b19120915def3117ced Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sat, 27 Dec 2025 05:32:39 +0100 Subject: [PATCH] refactor: replace macos_window_utils --- lib/services/fullscreen_window_delegate.dart | 57 +---- lib/services/macos_titlebar_service.dart | 64 ++--- lib/services/macos_window_delegate.dart | 16 ++ lib/services/macos_window_service.dart | 99 ++++++++ .../video_controls/video_controls.dart | 18 +- macos/Flutter/GeneratedPluginRegistrant.swift | 2 - macos/Podfile.lock | 6 - macos/Runner.xcodeproj/project.pbxproj | 8 + macos/Runner/MainFlutterWindow.swift | 17 ++ macos/Runner/MpvPlayer/MpvPlayerCore.swift | 2 +- macos/Runner/WindowDelegate.swift | 65 +++++ macos/Runner/WindowUtilsPlugin.swift | 224 ++++++++++++++++++ pubspec.lock | 8 - pubspec.yaml | 1 - 14 files changed, 453 insertions(+), 134 deletions(-) create mode 100644 lib/services/macos_window_delegate.dart create mode 100644 lib/services/macos_window_service.dart create mode 100644 macos/Runner/WindowDelegate.swift create mode 100644 macos/Runner/WindowUtilsPlugin.swift diff --git a/lib/services/fullscreen_window_delegate.dart b/lib/services/fullscreen_window_delegate.dart index 8aad486e..276cd70a 100644 --- a/lib/services/fullscreen_window_delegate.dart +++ b/lib/services/fullscreen_window_delegate.dart @@ -1,64 +1,17 @@ -import 'package:macos_window_utils/macos_window_utils.dart'; -import 'package:macos_window_utils/macos/ns_window_delegate.dart'; -import 'package:macos_window_utils/macos/ns_window_button_type.dart'; -import 'package:flutter/material.dart' show Offset; import 'fullscreen_state_manager.dart'; +import 'macos_window_delegate.dart'; -/// Custom window delegate that manages titlebar configuration during fullscreen transitions -class FullscreenWindowDelegate extends NSWindowDelegate { - static const double _customButtonY = 21.0; - +/// Custom window delegate that manages fullscreen state +/// Note: Window manipulation (toolbar, titlebar, traffic lights) is now handled +/// directly in Swift's WindowDelegate. This class only updates Dart-side state. +class FullscreenWindowDelegate extends MacOSWindowDelegate { @override void windowWillEnterFullScreen() { - // Notify global state manager FullscreenStateManager().setFullscreen(true); - - // Remove toolbar and restore default titlebar before entering fullscreen - _prepareForFullscreen(); - } - - @override - void windowWillExitFullScreen() { - // Hide title and make transparent immediately (safe to do before transition) - WindowManipulator.hideTitle(); - WindowManipulator.makeTitlebarTransparent(); } @override void windowDidExitFullScreen() { - // Notify global state manager FullscreenStateManager().setFullscreen(false); - - // Add toolbar and reposition traffic lights after transition completes - WindowManipulator.addToolbar(); - - // Restore custom traffic light positions - WindowManipulator.overrideStandardWindowButtonPosition( - buttonType: NSWindowButtonType.closeButton, - offset: const Offset(20, _customButtonY), - ); - WindowManipulator.overrideStandardWindowButtonPosition( - buttonType: NSWindowButtonType.miniaturizeButton, - offset: const Offset(40, _customButtonY), - ); - WindowManipulator.overrideStandardWindowButtonPosition( - buttonType: NSWindowButtonType.zoomButton, - offset: const Offset(60, _customButtonY), - ); - } - - /// Prepare titlebar for fullscreen mode - void _prepareForFullscreen() { - WindowManipulator.removeToolbar(); - WindowManipulator.showTitle(); - WindowManipulator.makeTitlebarOpaque(); - - // Set traffic lights to standard fullscreen positions (null = default) - WindowManipulator.overrideStandardWindowButtonPosition(buttonType: NSWindowButtonType.closeButton, offset: null); - WindowManipulator.overrideStandardWindowButtonPosition( - buttonType: NSWindowButtonType.miniaturizeButton, - offset: null, - ); - WindowManipulator.overrideStandardWindowButtonPosition(buttonType: NSWindowButtonType.zoomButton, offset: null); } } diff --git a/lib/services/macos_titlebar_service.dart b/lib/services/macos_titlebar_service.dart index 3ee66cfa..cc302806 100644 --- a/lib/services/macos_titlebar_service.dart +++ b/lib/services/macos_titlebar_service.dart @@ -1,61 +1,25 @@ import 'dart:io' show Platform; -import 'package:flutter/material.dart' show Offset; -import 'package:macos_window_utils/macos_window_utils.dart'; -import 'package:macos_window_utils/macos/ns_window_button_type.dart'; import 'fullscreen_window_delegate.dart'; +import 'macos_window_service.dart'; /// Service to manage macOS titlebar configuration class MacOSTitlebarService { - // Standard button Y position when using custom toolbar - static const double _customButtonY = 21.0; + static bool _initialized = false; - /// Initialize the custom titlebar setup (transparent with toolbar) - /// This configuration automatically handles fullscreen mode natively + /// Initialize the custom titlebar setup. + /// + /// Note: The initial window configuration (transparent titlebar, toolbar, + /// button positions, fullscreen presentation options) is now applied in + /// MainFlutterWindow.swift / WindowDelegate.swift BEFORE frame restoration + /// to prevent the window from shrinking on launch. + /// + /// This method only sets up the Dart-side callbacks. static Future setupCustomTitlebar() async { - if (!Platform.isMacOS) return; + if (!Platform.isMacOS || _initialized) return; + _initialized = true; - // Enable window delegate to use presentation options and fullscreen callbacks - await WindowManipulator.initialize(enableWindowDelegate: true); - - // Register custom delegate to handle fullscreen transitions + await MacOSWindowService.initialize(enableWindowDelegate: true); final delegate = FullscreenWindowDelegate(); - WindowManipulator.addNSWindowDelegate(delegate); - - // Make titlebar transparent but keep it functional - await WindowManipulator.makeTitlebarTransparent(); - await WindowManipulator.hideTitle(); - await WindowManipulator.enableFullSizeContentView(); - - // Add toolbar to create space for traffic lights in normal mode - await WindowManipulator.addToolbar(); - - // Set custom traffic light positions for normal mode - await _setCustomButtonPositions(); - - // Configure fullscreen presentation to auto-hide toolbar and menubar - // This tells macOS to automatically hide the toolbar when entering fullscreen - final presentationOptions = NSAppPresentationOptions.from({ - NSAppPresentationOption.fullScreen, - NSAppPresentationOption.autoHideToolbar, - NSAppPresentationOption.autoHideMenuBar, - NSAppPresentationOption.autoHideDock, - }); - presentationOptions.applyAsFullScreenPresentationOptions(); - } - - /// Set traffic light buttons to custom positions (with toolbar offset) - static Future _setCustomButtonPositions() async { - await WindowManipulator.overrideStandardWindowButtonPosition( - buttonType: NSWindowButtonType.closeButton, - offset: const Offset(20, _customButtonY), - ); - await WindowManipulator.overrideStandardWindowButtonPosition( - buttonType: NSWindowButtonType.miniaturizeButton, - offset: const Offset(40, _customButtonY), - ); - await WindowManipulator.overrideStandardWindowButtonPosition( - buttonType: NSWindowButtonType.zoomButton, - offset: const Offset(60, _customButtonY), - ); + MacOSWindowService.addWindowDelegate(delegate); } } diff --git a/lib/services/macos_window_delegate.dart b/lib/services/macos_window_delegate.dart new file mode 100644 index 00000000..544f3b96 --- /dev/null +++ b/lib/services/macos_window_delegate.dart @@ -0,0 +1,16 @@ +/// Abstract class for receiving macOS window delegate callbacks. +/// Extend this class and register with MacOSWindowService to receive +/// fullscreen transition events. +abstract class MacOSWindowDelegate { + /// Called when the window is about to enter fullscreen mode. + void windowWillEnterFullScreen() {} + + /// Called when the window has entered fullscreen mode. + void windowDidEnterFullScreen() {} + + /// Called when the window is about to exit fullscreen mode. + void windowWillExitFullScreen() {} + + /// Called when the window has exited fullscreen mode. + void windowDidExitFullScreen() {} +} diff --git a/lib/services/macos_window_service.dart b/lib/services/macos_window_service.dart new file mode 100644 index 00000000..f184012a --- /dev/null +++ b/lib/services/macos_window_service.dart @@ -0,0 +1,99 @@ +import 'dart:io' show Platform; +import 'package:flutter/services.dart'; +import 'macos_window_delegate.dart'; + +/// Service for manipulating macOS window properties. +/// This is a native implementation replacing the macos_window_utils package. +/// +/// Note: Titlebar, toolbar, and traffic light position management is now handled +/// directly in Swift (WindowDelegate.swift) during fullscreen transitions. +/// This service only exposes what's needed externally: +/// - Traffic light visibility (for video controls) +/// - Fullscreen enter/exit (for video controls) +/// - Delegate registration (for FullscreenStateManager updates) +class MacOSWindowService { + static const _channel = MethodChannel('com.plezy/window_utils'); + static bool _initialized = false; + static bool _delegateEnabled = false; + static final List _delegates = []; + + // MARK: - Private Helpers + + static Future _invoke(String method, [Map? args]) async { + if (!Platform.isMacOS) return; + await _channel.invokeMethod(method, args); + } + + static void _notifyDelegates(void Function(MacOSWindowDelegate) callback) { + for (final delegate in _delegates) { + callback(delegate); + } + } + + static Future _handleMethodCall(MethodCall call) async { + switch (call.method) { + case 'windowWillEnterFullScreen': + _notifyDelegates((d) => d.windowWillEnterFullScreen()); + case 'windowDidEnterFullScreen': + _notifyDelegates((d) => d.windowDidEnterFullScreen()); + case 'windowWillExitFullScreen': + _notifyDelegates((d) => d.windowWillExitFullScreen()); + case 'windowDidExitFullScreen': + _notifyDelegates((d) => d.windowDidExitFullScreen()); + } + } + + // MARK: - Initialization + + /// Initialize the window service. + /// Must be called before using other methods. + /// Set [enableWindowDelegate] to true to receive fullscreen callbacks. + static Future initialize({bool enableWindowDelegate = false}) async { + if (!Platform.isMacOS) return; + + if (!_initialized) { + await _channel.invokeMethod('initialize', { + 'enableWindowDelegate': enableWindowDelegate, + }); + _initialized = true; + } + + // Set up handler if not already done and delegate is requested + if (enableWindowDelegate && !_delegateEnabled) { + _channel.setMethodCallHandler(_handleMethodCall); + _delegateEnabled = true; + } + } + + /// Add a delegate to receive window events. + static void addWindowDelegate(MacOSWindowDelegate delegate) { + if (!_delegates.contains(delegate)) { + _delegates.add(delegate); + } + } + + /// Remove a previously added delegate. + static void removeWindowDelegate(MacOSWindowDelegate delegate) { + _delegates.remove(delegate); + } + + // MARK: - Traffic Light Buttons + + /// Show or hide all traffic light buttons (close, miniaturize, zoom). + static Future setTrafficLightsVisible(bool visible) => + _invoke('setTrafficLightsVisible', {'visible': visible}); + + // MARK: - Fullscreen + + /// Enter fullscreen mode. + static Future enterFullscreen() => _invoke('enterFullscreen'); + + /// Exit fullscreen mode. + static Future exitFullscreen() => _invoke('exitFullscreen'); + + /// Check if the window is in fullscreen mode. + static Future isFullscreen() async { + if (!Platform.isMacOS) return false; + return await _channel.invokeMethod('isFullscreen') ?? false; + } +} diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index ebe9a00f..03d82376 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -14,7 +14,7 @@ import 'package:flutter/services.dart' KeyEvent, KeyDownEvent, HardwareKeyboard; -import 'package:macos_window_utils/macos_window_utils.dart'; +import '../../services/macos_window_service.dart'; import 'package:window_manager/window_manager.dart'; import '../../mpv/mpv.dart'; @@ -633,17 +633,7 @@ class _PlexVideoControlsState extends State with WindowListen } void _updateTrafficLightVisibility() async { - if (Platform.isMacOS) { - if (_showControls) { - await WindowManipulator.showCloseButton(); - await WindowManipulator.showMiniaturizeButton(); - await WindowManipulator.showZoomButton(); - } else { - await WindowManipulator.hideCloseButton(); - await WindowManipulator.hideMiniaturizeButton(); - await WindowManipulator.hideZoomButton(); - } - } + await MacOSWindowService.setTrafficLightsVisible(_showControls); } Future _loadPlaybackExtras() async { @@ -1081,9 +1071,9 @@ class _PlexVideoControlsState extends State with WindowListen // Use native macOS fullscreen - titlebar is handled automatically // Window listener will update _isFullscreen for UI if (isCurrentlyFullscreen) { - await WindowManipulator.exitFullscreen(); + await MacOSWindowService.exitFullscreen(); } else { - await WindowManipulator.enterFullscreen(); + await MacOSWindowService.enterFullscreen(); } } else { // For Windows/Linux, use window_manager diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 6321eca2..0235d5e7 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -12,7 +12,6 @@ import flutter_webrtc import gamepads_darwin import hotkey_manager_macos import in_app_review -import macos_window_utils import os_media_controls import package_info_plus import path_provider_foundation @@ -32,7 +31,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { GamepadsDarwinPlugin.register(with: registry.registrar(forPlugin: "GamepadsDarwinPlugin")) HotkeyManagerMacosPlugin.register(with: registry.registrar(forPlugin: "HotkeyManagerMacosPlugin")) InAppReviewPlugin.register(with: registry.registrar(forPlugin: "InAppReviewPlugin")) - MacOSWindowUtilsPlugin.register(with: registry.registrar(forPlugin: "MacOSWindowUtilsPlugin")) OsMediaControlsPlugin.register(with: registry.registrar(forPlugin: "OsMediaControlsPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) diff --git a/macos/Podfile.lock b/macos/Podfile.lock index 36535ff8..779e10d8 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -17,8 +17,6 @@ PODS: - HotKey - in_app_review (2.0.0): - FlutterMacOS - - macos_window_utils (1.0.0): - - FlutterMacOS - os_media_controls (0.0.1): - FlutterMacOS - package_info_plus (0.0.1): @@ -76,7 +74,6 @@ DEPENDENCIES: - gamepads_darwin (from `Flutter/ephemeral/.symlinks/plugins/gamepads_darwin/macos`) - hotkey_manager_macos (from `Flutter/ephemeral/.symlinks/plugins/hotkey_manager_macos/macos`) - in_app_review (from `Flutter/ephemeral/.symlinks/plugins/in_app_review/macos`) - - macos_window_utils (from `Flutter/ephemeral/.symlinks/plugins/macos_window_utils/macos`) - os_media_controls (from `Flutter/ephemeral/.symlinks/plugins/os_media_controls/macos`) - package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`) - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`) @@ -111,8 +108,6 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/hotkey_manager_macos/macos in_app_review: :path: Flutter/ephemeral/.symlinks/plugins/in_app_review/macos - macos_window_utils: - :path: Flutter/ephemeral/.symlinks/plugins/macos_window_utils/macos os_media_controls: :path: Flutter/ephemeral/.symlinks/plugins/os_media_controls/macos package_info_plus: @@ -144,7 +139,6 @@ SPEC CHECKSUMS: HotKey: 400beb7caa29054ea8d864c96f5ba7e5b4852277 hotkey_manager_macos: a4317849af96d2430fa89944d3c58977ca089fbe in_app_review: 66e7680752b632d83f4f0e88b34d52ed303fbff4 - macos_window_utils: 23f54331a0fd51eea9e0ed347253bf48fd379d1d os_media_controls: c07c04c4afdf59dda0a3f398457a46823c4ce0ed package_info_plus: f0052d280d17aa382b932f399edf32507174e870 path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880 diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index 8175de2f..1ac8c870 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -30,6 +30,8 @@ 6AC86ED72EA70B4C0067BC66 /* plezy.icon in Resources */ = {isa = PBXBuildFile; fileRef = 6AC86ED62EA70B4C0067BC66 /* plezy.icon */; }; 6AD8B1612ED7B50000E9E1B4 /* MpvPlayerCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AD8B1632ED7B50000E9E1B4 /* MpvPlayerCore.swift */; }; 6AD8B1622ED7B50000E9E1B4 /* MpvPlayerPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AD8B1642ED7B50000E9E1B4 /* MpvPlayerPlugin.swift */; }; + 6AD8B1662ED7B50000E9E1B5 /* WindowUtilsPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AD8B1682ED7B50000E9E1B5 /* WindowUtilsPlugin.swift */; }; + 6AD8B1672ED7B50000E9E1B5 /* WindowDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AD8B1692ED7B50000E9E1B5 /* WindowDelegate.swift */; }; 9AECA605E2E1BECE3CA5BD01 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 78F9F23331B540A25C4A70C7 /* Pods_Runner.framework */; }; FE52EE1D2D489FA88507D14E /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 84260356652A3270E664FED4 /* Pods_RunnerTests.framework */; }; /* End PBXBuildFile section */ @@ -86,6 +88,8 @@ 6AC86ED62EA70B4C0067BC66 /* plezy.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = plezy.icon; sourceTree = ""; }; 6AD8B1632ED7B50000E9E1B4 /* MpvPlayerCore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MpvPlayerCore.swift; sourceTree = ""; }; 6AD8B1642ED7B50000E9E1B4 /* MpvPlayerPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MpvPlayerPlugin.swift; sourceTree = ""; }; + 6AD8B1682ED7B50000E9E1B5 /* WindowUtilsPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowUtilsPlugin.swift; sourceTree = ""; }; + 6AD8B1692ED7B50000E9E1B5 /* WindowDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowDelegate.swift; sourceTree = ""; }; 78F9F23331B540A25C4A70C7 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; 84260356652A3270E664FED4 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -196,6 +200,8 @@ children = ( 33CC10F02044A3C60003C045 /* AppDelegate.swift */, 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 6AD8B1682ED7B50000E9E1B5 /* WindowUtilsPlugin.swift */, + 6AD8B1692ED7B50000E9E1B5 /* WindowDelegate.swift */, 6AD8B1652ED7B50000E9E1B4 /* MpvPlayer */, 33E51913231747F40026EE4D /* DebugProfile.entitlements */, 33E51914231749380026EE4D /* Release.entitlements */, @@ -460,6 +466,8 @@ 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, 6AD8B1612ED7B50000E9E1B4 /* MpvPlayerCore.swift in Sources */, 6AD8B1622ED7B50000E9E1B4 /* MpvPlayerPlugin.swift in Sources */, + 6AD8B1662ED7B50000E9E1B5 /* WindowUtilsPlugin.swift in Sources */, + 6AD8B1672ED7B50000E9E1B5 /* WindowDelegate.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/macos/Runner/MainFlutterWindow.swift b/macos/Runner/MainFlutterWindow.swift index b77c4b2a..b0cf08eb 100644 --- a/macos/Runner/MainFlutterWindow.swift +++ b/macos/Runner/MainFlutterWindow.swift @@ -13,9 +13,26 @@ class MainFlutterWindow: NSWindow { self.contentViewController = flutterViewController self.setFrame(windowFrame, display: true) + // Apply initial window configuration BEFORE frame restoration + // This prevents the window from shrinking on launch + self.titlebarAppearsTransparent = true + self.titleVisibility = .hidden + self.styleMask.insert(.fullSizeContentView) + + // Add forwarding toolbar for click-through in titlebar area + let toolbar = ForwardingToolbar(flutterViewController: flutterViewController) + self.toolbar = toolbar + // Register MPV player plugin for video playback MpvPlayerPlugin.register(with: flutterViewController.registrar(forPlugin: "MpvPlayerPlugin")) + // Register window utils plugin for dynamic titlebar/fullscreen control from Dart + WindowUtilsPlugin.register(with: flutterViewController.registrar(forPlugin: "WindowUtilsPlugin")) + WindowUtilsPlugin.setWindow(self) + + // Set custom traffic light positions using centralized values from plugin + WindowUtilsPlugin.setInitialTrafficLightPositions() + RegisterGeneratedPlugins(registry: flutterViewController) super.awakeFromNib() diff --git a/macos/Runner/MpvPlayer/MpvPlayerCore.swift b/macos/Runner/MpvPlayer/MpvPlayerCore.swift index dcc3a931..aa1b2000 100644 --- a/macos/Runner/MpvPlayer/MpvPlayerCore.swift +++ b/macos/Runner/MpvPlayer/MpvPlayerCore.swift @@ -213,7 +213,7 @@ class MpvPlayerCore: NSObject { // Re-insert after background layer but before Flutter control views layer.removeFromSuperlayer() if let superlayer = window?.contentView?.layer { - superlayer.insertSublayer(layer, at: 1) + superlayer.insertSublayer(layer, at: 0) } } diff --git a/macos/Runner/WindowDelegate.swift b/macos/Runner/WindowDelegate.swift new file mode 100644 index 00000000..394a1d49 --- /dev/null +++ b/macos/Runner/WindowDelegate.swift @@ -0,0 +1,65 @@ +import Cocoa +import FlutterMacOS + +class WindowDelegate: NSObject, NSWindowDelegate { + weak var channel: FlutterMethodChannel? + weak var window: NSWindow? + + // Hardcoded presentation options for fullscreen mode + // Auto-hide toolbar, menu bar, and dock when in fullscreen + private let fullScreenPresentationOptions: NSApplication.PresentationOptions = [ + .fullScreen, + .autoHideToolbar, + .autoHideMenuBar, + .autoHideDock + ] + + // MARK: - Private Helpers + + private func emit(_ method: String) { + channel?.invokeMethod(method, arguments: nil) + } + + // MARK: - NSWindowDelegate + + func window(_ window: NSWindow, willUseFullScreenPresentationOptions proposedOptions: NSApplication.PresentationOptions) -> NSApplication.PresentationOptions { + return fullScreenPresentationOptions + } + + func windowWillEnterFullScreen(_ notification: Notification) { + guard let window = window else { return } + // Remove toolbar before entering fullscreen + window.toolbar = nil + // Show title and make titlebar opaque for native fullscreen look + window.titleVisibility = .visible + window.titlebarAppearsTransparent = false + // Reset traffic light positions to default + WindowUtilsPlugin.setTrafficLightPositions(custom: false, window: window) + // Notify Dart for state management only + emit("windowWillEnterFullScreen") + } + + func windowDidEnterFullScreen(_ notification: Notification) { + emit("windowDidEnterFullScreen") + } + + func windowWillExitFullScreen(_ notification: Notification) { + guard let window = window else { return } + // Hide title and make titlebar transparent BEFORE exiting + window.titleVisibility = .hidden + window.titlebarAppearsTransparent = true + emit("windowWillExitFullScreen") + } + + func windowDidExitFullScreen(_ notification: Notification) { + guard let window = window else { return } + // Restore toolbar + if let flutterVC = window.contentViewController { + let toolbar = ForwardingToolbar(flutterViewController: flutterVC) + window.toolbar = toolbar + } + // Restore custom traffic light positions + WindowUtilsPlugin.setTrafficLightPositions(custom: true, window: window) + emit("windowDidExitFullScreen") + } +} diff --git a/macos/Runner/WindowUtilsPlugin.swift b/macos/Runner/WindowUtilsPlugin.swift new file mode 100644 index 00000000..b3e84962 --- /dev/null +++ b/macos/Runner/WindowUtilsPlugin.swift @@ -0,0 +1,224 @@ +import Cocoa +import FlutterMacOS + +// MARK: - ForwardingView +// A view that forwards mouse events to the Flutter view controller +class ForwardingView: NSView { + weak var flutterViewController: NSViewController? + + override func mouseDown(with event: NSEvent) { + flutterViewController?.mouseDown(with: event) + } + + override func mouseUp(with event: NSEvent) { + flutterViewController?.mouseUp(with: event) + } +} + +// MARK: - ForwardingToolbar +// A custom toolbar that forwards mouse events from the toolbar area to Flutter +class ForwardingToolbar: NSToolbar, NSToolbarDelegate { + let flutterViewController: NSViewController + + init(flutterViewController: NSViewController) { + self.flutterViewController = flutterViewController + super.init(identifier: "ForwardingToolbar") + self.delegate = self + self.showsBaselineSeparator = false + + // Prevent toolbar customization UI (the "rounded box") + self.allowsUserCustomization = false + self.allowsExtensionItems = false + if #available(macOS 15.0, *) { + self.allowsDisplayModeCustomization = false + } + } + + func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { + [.flexibleSpace, NSToolbarItem.Identifier("ForwardingItem")] + } + + func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { + toolbarDefaultItemIdentifiers(toolbar) + } + + func toolbar(_ toolbar: NSToolbar, itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier, willBeInsertedIntoToolbar flag: Bool) -> NSToolbarItem? { + if itemIdentifier == NSToolbarItem.Identifier("ForwardingItem") { + let item = NSToolbarItem(itemIdentifier: itemIdentifier) + item.isBordered = false // Remove the rounded box appearance + let view = ForwardingView() + view.flutterViewController = flutterViewController + view.widthAnchor.constraint(lessThanOrEqualToConstant: 100000).isActive = true + view.widthAnchor.constraint(greaterThanOrEqualToConstant: 1).isActive = true + item.view = view + return item + } + return nil + } +} + +// MARK: - WindowUtilsPlugin +class WindowUtilsPlugin: NSObject, FlutterPlugin { + private static var instance: WindowUtilsPlugin? + private var channel: FlutterMethodChannel? + private weak var window: NSWindow? + private var windowDelegate: WindowDelegate? + private var originalButtonConstraints: [NSWindow.ButtonType: [NSLayoutConstraint]] = [:] + + // Centralized traffic light positions - the single source of truth + private static let customButtonPositions: [(NSWindow.ButtonType, CGPoint)] = [ + (.closeButton, CGPoint(x: 20, y: 21)), + (.miniaturizeButton, CGPoint(x: 40, y: 21)), + (.zoomButton, CGPoint(x: 60, y: 21)) + ] + + static func register(with registrar: FlutterPluginRegistrar) { + let channel = FlutterMethodChannel( + name: "com.plezy/window_utils", + binaryMessenger: registrar.messenger + ) + let instance = WindowUtilsPlugin() + instance.channel = channel + registrar.addMethodCallDelegate(instance, channel: channel) + self.instance = instance + } + + static func setWindow(_ window: NSWindow) { + instance?.window = window + } + + /// Apply custom traffic light positions. Called by MainFlutterWindow on startup. + static func setInitialTrafficLightPositions() { + guard let instance = instance, let window = instance.window else { return } + instance.applyTrafficLightPositions(custom: true, window: window) + } + + /// Apply traffic light positions. Called by WindowDelegate during fullscreen transitions. + static func setTrafficLightPositions(custom: Bool, window: NSWindow) { + guard let instance = instance else { return } + instance.applyTrafficLightPositions(custom: custom, window: window) + } + + private func applyTrafficLightPositions(custom: Bool, window: NSWindow) { + if custom { + for (buttonType, offset) in WindowUtilsPlugin.customButtonPositions { + overrideButtonPosition(window: window, buttonType: buttonType, offset: offset) + } + } else { + for (buttonType, _) in WindowUtilsPlugin.customButtonPositions { + resetButtonPosition(window: window, buttonType: buttonType) + } + } + } + + func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + guard let window = window else { + result(FlutterError(code: "NO_WINDOW", message: "Window not available", details: nil)) + return + } + + switch call.method { + case "initialize": + let args = call.arguments as? [String: Any] + let enableWindowDelegate = args?["enableWindowDelegate"] as? Bool ?? false + initialize(window: window, enableWindowDelegate: enableWindowDelegate) + result(nil) + + case "setTrafficLightsVisible": + let args = call.arguments as? [String: Any] + let visible = args?["visible"] as? Bool ?? true + for buttonType in [NSWindow.ButtonType.closeButton, .miniaturizeButton, .zoomButton] { + window.standardWindowButton(buttonType)?.isHidden = !visible + } + result(nil) + + case "enterFullscreen": + if !window.styleMask.contains(.fullScreen) { + window.toggleFullScreen(nil) + } + result(nil) + + case "exitFullscreen": + if window.styleMask.contains(.fullScreen) { + window.toggleFullScreen(nil) + } + result(nil) + + case "isFullscreen": + result(window.styleMask.contains(.fullScreen)) + + default: + result(FlutterMethodNotImplemented) + } + } + + private func initialize(window: NSWindow, enableWindowDelegate: Bool) { + self.window = window + + if enableWindowDelegate { + let delegate = WindowDelegate() + delegate.channel = channel + delegate.window = window + windowDelegate = delegate + window.delegate = delegate + } + } + + private func withButton( + _ buttonType: NSWindow.ButtonType, + in window: NSWindow, + action: (NSButton, NSView) -> Void + ) { + guard let button = window.standardWindowButton(buttonType), + let superview = button.superview else { return } + action(button, superview) + } + + private func positionConstraints(for button: NSButton, in superview: NSView) -> [NSLayoutConstraint] { + superview.constraints.filter { constraint in + ((constraint.firstItem as? NSButton) == button || (constraint.secondItem as? NSButton) == button) && + (constraint.firstAttribute == .left || constraint.firstAttribute == .leading || + constraint.firstAttribute == .top || constraint.firstAttribute == .centerY) + } + } + + private func overrideButtonPosition(window: NSWindow, buttonType: NSWindow.ButtonType, offset: CGPoint) { + withButton(buttonType, in: window) { button, superview in + // Store original constraints if not already stored + if originalButtonConstraints[buttonType] == nil { + let constraints = superview.constraints.filter { constraint in + (constraint.firstItem as? NSButton) == button || (constraint.secondItem as? NSButton) == button + } + originalButtonConstraints[buttonType] = constraints + } + + // Remove existing position constraints + superview.removeConstraints(positionConstraints(for: button, in: superview)) + + button.translatesAutoresizingMaskIntoConstraints = false + + // Add new positioning constraints + superview.addConstraints([ + button.leftAnchor.constraint(equalTo: superview.leftAnchor, constant: offset.x), + button.topAnchor.constraint(equalTo: superview.topAnchor, constant: offset.y) + ]) + superview.layoutSubtreeIfNeeded() + } + } + + private func resetButtonPosition(window: NSWindow, buttonType: NSWindow.ButtonType) { + withButton(buttonType, in: window) { button, superview in + // Remove custom constraints + superview.removeConstraints(positionConstraints(for: button, in: superview)) + + // Restore original constraints if we have them + if let originalConstraints = originalButtonConstraints[buttonType] { + superview.addConstraints(originalConstraints) + originalButtonConstraints.removeValue(forKey: buttonType) + } + + button.translatesAutoresizingMaskIntoConstraints = true + superview.layoutSubtreeIfNeeded() + } + } +} diff --git a/pubspec.lock b/pubspec.lock index 56619830..fd421a50 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -768,14 +768,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.0" - macos_window_utils: - dependency: "direct main" - description: - name: macos_window_utils - sha256: d4df3501fd32ac0d2d7590cb6a8e4758337d061c8fa0db816fdd636be63a8438 - url: "https://pub.dev" - source: hosted - version: "1.9.0" matcher: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 751f660a..3bf76613 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -13,7 +13,6 @@ dependencies: json_annotation: ^4.8.1 shared_preferences: ^2.2.2 cached_network_image: ^3.4.1 - macos_window_utils: ^1.9.0 url_launcher: ^6.3.0 uuid: ^4.4.0 window_manager: ^0.4.3