diff --git a/lib/main.dart b/lib/main.dart index 77c99dcd..6b8f37c4 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -167,7 +167,11 @@ Future _bootstrapApp() async { final futures = >[]; if (PlatformDetector.isDesktopOS()) { - futures.add(windowManager.ensureInitialized()); + if (Platform.isMacOS) { + futures.add(windowManager.ensureInitialized().then((_) => MacOSWindowService.setupCustomTitlebar())); + } else { + futures.add(windowManager.ensureInitialized()); + } } // Initialize TV detection (Android leanback or Apple TV) and PiP on Android. @@ -178,8 +182,6 @@ Future _bootstrapApp() async { PipService(); } - futures.add(MacOSWindowService.setupCustomTitlebar()); - // Hook Windows native fullscreen callback (no-op elsewhere). NativeWindowService.initialize(); diff --git a/lib/services/fullscreen_state_manager.dart b/lib/services/fullscreen_state_manager.dart index 891957ee..e1a36567 100644 --- a/lib/services/fullscreen_state_manager.dart +++ b/lib/services/fullscreen_state_manager.dart @@ -29,7 +29,7 @@ class FullscreenStateManager extends ChangeNotifier with WindowListener { /// Toggle fullscreen state, handling maximized-to-fullscreen transition on Windows/Linux Future toggleFullscreen() async { if (Platform.isMacOS) { - final isCurrentlyFullscreen = await windowManager.isFullScreen(); + final isCurrentlyFullscreen = await MacOSWindowService.isFullscreen(); if (isCurrentlyFullscreen) { await MacOSWindowService.exitFullscreen(); } else { diff --git a/lib/services/macos_window_service.dart b/lib/services/macos_window_service.dart index 1a7d5123..701525eb 100644 --- a/lib/services/macos_window_service.dart +++ b/lib/services/macos_window_service.dart @@ -37,6 +37,7 @@ class MacOSWindowService { static bool _initialized = false; static bool _delegateEnabled = false; static final List _delegates = []; + static final MacOSWindowDelegate _fullscreenDelegate = _FullscreenWindowDelegate(); static Future _invoke(String method, [Map? args]) async { if (!Platform.isMacOS) return; @@ -71,10 +72,18 @@ class MacOSWindowService { /// /// This method sets up the Dart-side callbacks for fullscreen state tracking. static Future setupCustomTitlebar() async { - if (!Platform.isMacOS || _initialized) return; + if (!Platform.isMacOS) return; + + if (_initialized && _delegateEnabled) { + await syncWindowChrome(); + FullscreenStateManager().setFullscreen(await isFullscreen()); + return; + } await initialize(enableWindowDelegate: true); - addWindowDelegate(_FullscreenWindowDelegate()); + addWindowDelegate(_fullscreenDelegate); + await syncWindowChrome(); + FullscreenStateManager().setFullscreen(await isFullscreen()); } /// Initialize the window service. @@ -83,7 +92,7 @@ class MacOSWindowService { static Future initialize({bool enableWindowDelegate = false}) async { if (!Platform.isMacOS) return; - if (!_initialized) { + if (!_initialized || (enableWindowDelegate && !_delegateEnabled)) { await _channel.invokeMethod('initialize', {'enableWindowDelegate': enableWindowDelegate}); _initialized = true; } @@ -107,6 +116,8 @@ class MacOSWindowService { static Future setTrafficLightsVisible(bool visible) => _invoke('setTrafficLightsVisible', {'visible': visible}); + static Future syncWindowChrome() => _invoke('syncWindowChrome'); + static Future enterFullscreen() => _invoke('enterFullscreen'); static Future exitFullscreen() => _invoke('exitFullscreen'); diff --git a/lib/widgets/video_controls/parts/visibility.dart b/lib/widgets/video_controls/parts/visibility.dart index a4dbd0e0..922e586d 100644 --- a/lib/widgets/video_controls/parts/visibility.dart +++ b/lib/widgets/video_controls/parts/visibility.dart @@ -256,7 +256,7 @@ extension _PlexVideoControlsVisibilityMethods on _PlexVideoControlsState { // When maximized or fullscreen, always keep traffic lights visible so the // user can reach them without the controls-hide-on-mouse-leave race. // In normal windowed mode, toggle with controls as before. - final isMaximizedOrFullscreen = await windowManager.isMaximized() || await windowManager.isFullScreen(); + final isMaximizedOrFullscreen = await windowManager.isMaximized() || await MacOSWindowService.isFullscreen(); if (!mounted || generation != _trafficLightVisibilityGeneration) return; final visible = isMaximizedOrFullscreen || _forceShowControls ? true : _showControls; await MacOSWindowService.setTrafficLightsVisible(visible); diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index 098f9ce0..49b61602 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -470,6 +470,10 @@ class _PlexVideoControlsState extends State with WindowListen _checkPipSupport(); // Add window listener for tracking fullscreen state (for button icon) if (PlatformDetector.isDesktopOS()) { + if (Platform.isMacOS) { + _isFullscreen = FullscreenStateManager().isFullscreen; + FullscreenStateManager().addListener(_onFullscreenStateChanged); + } windowManager.addListener(this); _initAlwaysOnTopState(); } @@ -530,6 +534,7 @@ class _PlexVideoControlsState extends State with WindowListen } } if (Platform.isMacOS) { + FullscreenStateManager().removeListener(_onFullscreenStateChanged); _pipService.isPipActive.removeListener(_onMacPipChanged); _trafficLightVisibilityGeneration++; unawaited(MacOSWindowService.setTrafficLightsVisible(true)); @@ -537,6 +542,15 @@ class _PlexVideoControlsState extends State with WindowListen super.dispose(); } + void _onFullscreenStateChanged() { + final isFullscreen = FullscreenStateManager().isFullscreen; + if (!mounted || _isFullscreen == isFullscreen) return; + setState(() { + _isFullscreen = isFullscreen; + }); + _updateTrafficLightVisibility(); + } + @override void onWindowEnterFullScreen() { if (mounted) { diff --git a/macos/Runner/MainFlutterWindow.swift b/macos/Runner/MainFlutterWindow.swift index ff3a4c0b..7c203119 100644 --- a/macos/Runner/MainFlutterWindow.swift +++ b/macos/Runner/MainFlutterWindow.swift @@ -31,14 +31,14 @@ class MainFlutterWindow: NSWindow { WindowUtilsPlugin.register( with: flutterViewController.registrar(forPlugin: "WindowUtilsPlugin")) WindowUtilsPlugin.setWindow(self) - - // Set custom traffic light positions using centralized values from plugin - WindowUtilsPlugin.setInitialTrafficLightPositions() + WindowUtilsPlugin.installWindowDelegate() + WindowUtilsPlugin.syncWindowChrome() RegisterGeneratedPlugins(registry: flutterViewController) // Enable window position/size persistence self.setFrameAutosaveName("com.edde746.plezy.MainWindow") + WindowUtilsPlugin.syncWindowChrome() super.awakeFromNib() } diff --git a/macos/Runner/WindowDelegate.swift b/macos/Runner/WindowDelegate.swift index d07ca0fb..1c435149 100644 --- a/macos/Runner/WindowDelegate.swift +++ b/macos/Runner/WindowDelegate.swift @@ -20,6 +20,36 @@ class WindowDelegate: NSObject, NSWindowDelegate { channel?.invokeMethod(method, arguments: nil) } + func syncWindowChrome() { + guard let window = window else { return } + if window.styleMask.contains(.fullScreen) { + applyFullScreenChrome(to: window) + } else { + applyWindowedChrome(to: window) + } + } + + private func applyFullScreenChrome(to window: NSWindow) { + window.toolbar = nil + window.titleVisibility = .visible + window.titlebarAppearsTransparent = false + WindowUtilsPlugin.setTrafficLightsVisible(true, window: window) + WindowUtilsPlugin.setTrafficLightPositions(custom: false, window: window) + } + + private func applyWindowedChrome(to window: NSWindow) { + window.titleVisibility = .hidden + window.titlebarAppearsTransparent = true + window.styleMask.insert(.fullSizeContentView) + + if window.toolbar == nil, let flutterVC = window.contentViewController { + window.toolbar = ForwardingToolbar(flutterViewController: flutterVC) + } + + WindowUtilsPlugin.setTrafficLightsVisible(true, window: window) + WindowUtilsPlugin.setTrafficLightPositions(custom: true, window: window) + } + // MARK: - NSWindowDelegate func window( @@ -31,13 +61,7 @@ class WindowDelegate: NSObject, NSWindowDelegate { 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) + applyFullScreenChrome(to: window) // Notify Dart for state management only emit("windowWillEnterFullScreen") } @@ -56,13 +80,7 @@ class WindowDelegate: NSObject, NSWindowDelegate { 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) + applyWindowedChrome(to: window) emit("windowDidExitFullScreen") } } diff --git a/macos/Runner/WindowUtilsPlugin.swift b/macos/Runner/WindowUtilsPlugin.swift index 2c2ac970..eedaa6c8 100644 --- a/macos/Runner/WindowUtilsPlugin.swift +++ b/macos/Runner/WindowUtilsPlugin.swift @@ -88,12 +88,17 @@ class WindowUtilsPlugin: NSObject, FlutterPlugin { static func setWindow(_ window: NSWindow) { instance?.window = window + instance?.windowDelegate?.window = window } - /// Apply custom traffic light positions. Called by MainFlutterWindow on startup. - static func setInitialTrafficLightPositions() { + static func installWindowDelegate() { guard let instance = instance, let window = instance.window else { return } - instance.applyTrafficLightPositions(custom: true, window: window) + instance.installWindowDelegate(window: window) + } + + static func syncWindowChrome() { + guard let instance = instance else { return } + instance.syncWindowChrome() } /// Apply traffic light positions. Called by WindowDelegate during fullscreen transitions. @@ -102,6 +107,12 @@ class WindowUtilsPlugin: NSObject, FlutterPlugin { instance.applyTrafficLightPositions(custom: custom, window: window) } + static func setTrafficLightsVisible(_ visible: Bool, window: NSWindow) { + for buttonType in [NSWindow.ButtonType.closeButton, .miniaturizeButton, .zoomButton] { + window.standardWindowButton(buttonType)?.isHidden = !visible + } + } + private func applyTrafficLightPositions(custom: Bool, window: NSWindow) { if custom { for (buttonType, offset) in WindowUtilsPlugin.customButtonPositions { @@ -130,9 +141,11 @@ class WindowUtilsPlugin: NSObject, FlutterPlugin { 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 - } + WindowUtilsPlugin.setTrafficLightsVisible(visible, window: window) + result(nil) + + case "syncWindowChrome": + syncWindowChrome() result(nil) case "enterFullscreen": @@ -159,12 +172,30 @@ class WindowUtilsPlugin: NSObject, FlutterPlugin { self.window = window if enableWindowDelegate { - let delegate = WindowDelegate() - delegate.channel = channel - delegate.window = window - windowDelegate = delegate - window.delegate = delegate + installWindowDelegate(window: window) } + + windowDelegate?.syncWindowChrome() + } + + private func installWindowDelegate(window: NSWindow) { + let delegate = windowDelegate ?? WindowDelegate() + delegate.channel = channel + delegate.window = window + windowDelegate = delegate + window.delegate = delegate + } + + private func syncWindowChrome() { + guard let window = window else { return } + if windowDelegate == nil { + installWindowDelegate(window: window) + } else { + windowDelegate?.channel = channel + windowDelegate?.window = window + window.delegate = windowDelegate + } + windowDelegate?.syncWindowChrome() } private func withButton(