From 82fbc5fb6d939ca9025bf9446fe58698695181ff Mon Sep 17 00:00:00 2001 From: Vicente Higino Date: Wed, 29 Jul 2026 13:06:14 -0300 Subject: [PATCH 1/2] fix(windows): prevent startup window freezes --- .../platform/desktop_platform_wrapper.dart | 88 +++++++--- lib/providers/video_player_provider.dart | 50 +++++- lib/util/single_flight_initializer.dart | 31 ++++ lib/util/window_helper.dart | 134 +++++++++++++++- lib/wrappers/media_control_wrapper.dart | 151 +++++++++++------- test/single_flight_initializer_test.dart | 54 +++++++ test/video_player_initialization_test.dart | 82 ++++++++++ test/window_helper_test.dart | 96 +++++++++++ windows/runner/flutter_window.cpp | 30 +++- windows/runner/flutter_window.h | 4 +- windows/runner/main.cpp | 2 +- windows/runner/win32_window.cpp | 17 +- windows/runner/win32_window.h | 2 +- 13 files changed, 642 insertions(+), 99 deletions(-) create mode 100644 lib/util/single_flight_initializer.dart create mode 100644 test/single_flight_initializer_test.dart create mode 100644 test/video_player_initialization_test.dart create mode 100644 test/window_helper_test.dart diff --git a/lib/bootstrap/platform/desktop_platform_wrapper.dart b/lib/bootstrap/platform/desktop_platform_wrapper.dart index 9a84a4b1d..398cda82b 100644 --- a/lib/bootstrap/platform/desktop_platform_wrapper.dart +++ b/lib/bootstrap/platform/desktop_platform_wrapper.dart @@ -5,7 +5,6 @@ import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:macos_window_utils/window_manipulator.dart'; import 'package:package_info_plus/package_info_plus.dart'; -import 'package:smtc_windows/smtc_windows.dart' if (dart.library.html) 'package:fladder/stubs/web/smtc_web.dart'; import 'package:window_manager/window_manager.dart'; import 'package:fladder/bootstrap/platform/base_app_wrapper.dart'; @@ -24,12 +23,14 @@ class DesktopAppWrapper extends BaseAppWrapper { ConsumerState createState() => _DesktopAppWrapperState(); } -class _DesktopAppWrapperState extends BaseAppWrapperState with WindowListener { +class _DesktopAppWrapperState extends BaseAppWrapperState + with WindowListener { + bool _windowPlacementInitialized = false; + bool _windowIsMaximized = false; + bool _windowIsFullScreen = false; + @override Future platformInit() async { - if (defaultTargetPlatform == TargetPlatform.windows) { - await SMTCWindows.initialize(); - } if (defaultTargetPlatform == TargetPlatform.macOS) { await WindowManipulator.initialize(enableWindowDelegate: true); } @@ -38,6 +39,8 @@ class _DesktopAppWrapperState extends BaseAppWrapperState wit await WindowManager.instance.ensureInitialized(); windowManager.addListener(this); + _windowIsMaximized = await windowManager.isMaximized(); + _windowIsFullScreen = await windowManager.isFullScreen(); final packageInfo = await PackageInfo.fromPlatform(); final clientSettings = ref.read(clientSettingsProvider); @@ -47,9 +50,24 @@ class _DesktopAppWrapperState extends BaseAppWrapperState wit clientSettings, packageInfo, ); + if (defaultTargetPlatform == TargetPlatform.windows) { + unawaited(_enableWindowPlacementPersistenceAfterStartup()); + } else { + _windowPlacementInitialized = true; + } await toggleMacTrafficLights(await windowManager.isFullScreen()); } + Future _enableWindowPlacementPersistenceAfterStartup() async { + await Future.delayed(windowsWindowPlacementPersistenceDelay); + if (!mounted) return; + + _windowIsMaximized = await windowManager.isMaximized(); + _windowIsFullScreen = await windowManager.isFullScreen(); + if (!mounted) return; + _windowPlacementInitialized = true; + } + @override void dispose() { windowManager.removeListener(this); @@ -63,45 +81,79 @@ class _DesktopAppWrapperState extends BaseAppWrapperState wit super.onWindowClose(); } - @override - void onWindowResize() async { + bool get _canPersistWindowBounds => shouldPersistWindowBounds( + startupSettled: _windowPlacementInitialized, + isFullScreen: _windowIsFullScreen, + isMaximized: _windowIsMaximized, + ); + + Future _persistWindowSize() async { + if (!_canPersistWindowBounds) return; final size = await windowManager.getSize(); + if (!_canPersistWindowBounds) return; ref.read(clientSettingsProvider.notifier).setWindowSize(size); + } + + Future _persistWindowPosition() async { + if (!_canPersistWindowBounds) return; + final position = await windowManager.getPosition(); + if (!_canPersistWindowBounds) return; + ref.read(clientSettingsProvider.notifier).setWindowPosition(position); + } + + @override + void onWindowResize() { + unawaited(_persistWindowSize()); super.onWindowResize(); } @override - void onWindowResized() async { - final size = await windowManager.getSize(); - ref.read(clientSettingsProvider.notifier).setWindowSize(size); + void onWindowResized() { + unawaited(_persistWindowSize()); super.onWindowResized(); } @override - void onWindowMove() async { - final position = await windowManager.getPosition(); - ref.read(clientSettingsProvider.notifier).setWindowPosition(position); + void onWindowMove() { + unawaited(_persistWindowPosition()); super.onWindowMove(); } @override - void onWindowMoved() async { - final position = await windowManager.getPosition(); - ref.read(clientSettingsProvider.notifier).setWindowPosition(position); + void onWindowMoved() { + unawaited(_persistWindowPosition()); super.onWindowMoved(); } + @override + void onWindowMaximize() { + _windowIsMaximized = true; + super.onWindowMaximize(); + } + + @override + void onWindowUnmaximize() { + _windowIsMaximized = false; + super.onWindowUnmaximize(); + } + @override void onWindowEnterFullScreen() { - ref.read(mediaPlaybackProvider.notifier).update((state) => state.copyWith(fullScreen: true)); + _windowIsFullScreen = true; + ref + .read(mediaPlaybackProvider.notifier) + .update((state) => state.copyWith(fullScreen: true)); unawaited(toggleMacTrafficLights(true)); super.onWindowEnterFullScreen(); } @override void onWindowLeaveFullScreen() { + _windowIsFullScreen = false; unawaited(toggleMacTrafficLights(false)); - ref.read(mediaPlaybackProvider.notifier).update((state) => state.copyWith(fullScreen: false)); + ref + .read(mediaPlaybackProvider.notifier) + .update((state) => state.copyWith(fullScreen: false)); super.onWindowLeaveFullScreen(); } } diff --git a/lib/providers/video_player_provider.dart b/lib/providers/video_player_provider.dart index 904bc6484..54f684967 100644 --- a/lib/providers/video_player_provider.dart +++ b/lib/providers/video_player_provider.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:io'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -18,31 +19,70 @@ final mediaPlaybackProvider = StateProvider((ref) => MediaPl final playBackModel = StateProvider((ref) => null); -final videoPlayerProvider = StateNotifierProvider((ref) { +final videoPlayerProvider = + StateNotifierProvider((ref) { final videoPlayer = VideoPlayerNotifier(ref); - videoPlayer.init(); + if (defaultTargetPlatform != TargetPlatform.windows) { + unawaited(videoPlayer.init()); + } return videoPlayer; }); +typedef VideoPlayerInitializer = Future Function(); + class VideoPlayerNotifier extends StateNotifier { - VideoPlayerNotifier(this.ref) : super(MediaControlsWrapper(ref: ref)); + VideoPlayerNotifier( + this.ref, { + VideoPlayerInitializer? initializeOverride, + }) : _initializeOverride = initializeOverride, + super(MediaControlsWrapper(ref: ref)); final Ref ref; + final VideoPlayerInitializer? _initializeOverride; List subscriptions = []; + Future? _initialization; + bool _hasCompletedInitialization = false; late final mediaState = ref.read(mediaPlaybackProvider.notifier); MediaPlaybackModel get playbackState => ref.read(mediaPlaybackProvider); + bool get initializationInProgress => _initialization != null; + bool get hasCompletedInitialization => _hasCompletedInitialization; + + Future init() { + final activeInitialization = _initialization; + if (activeInitialization != null) return activeInitialization; + + late final Future operation; + operation = _runInitialization().whenComplete(() { + if (identical(_initialization, operation)) { + _initialization = null; + } + }); + _initialization = operation; + return operation; + } + + Future _runInitialization() async { + final initializeOverride = _initializeOverride; + if (initializeOverride != null) { + await initializeOverride(); + } else { + await _initializePlayer(); + } + _hasCompletedInitialization = true; + } - Future init() async { + Future _initializePlayer() async { await state.stop(); await state.dispose(); await state.init(); for (final s in subscriptions) { - s.cancel(); + await s.cancel(); } + subscriptions.clear(); final subscription = state.stateStream.listen((value) { updateBuffering(value.buffering); diff --git a/lib/util/single_flight_initializer.dart b/lib/util/single_flight_initializer.dart new file mode 100644 index 000000000..822d2340d --- /dev/null +++ b/lib/util/single_flight_initializer.dart @@ -0,0 +1,31 @@ +/// Runs an asynchronous initializer once and shares the in-flight operation. +/// +/// A failed attempt is not cached, so a later call can retry safely. +class SingleFlightInitializer { + Future? _active; + T? _value; + bool _hasValue = false; + + bool get isRunning => _active != null; + bool get hasValue => _hasValue; + + Future run(Future Function() initialize) { + if (_hasValue) return Future.value(_value as T); + + final active = _active; + if (active != null) return active; + + late final Future operation; + operation = Future.sync(initialize).then((value) { + _value = value; + _hasValue = true; + return value; + }).whenComplete(() { + if (identical(_active, operation)) { + _active = null; + } + }); + _active = operation; + return operation; + } +} diff --git a/lib/util/window_helper.dart b/lib/util/window_helper.dart index 3eb33c5d2..a7439166d 100644 --- a/lib/util/window_helper.dart +++ b/lib/util/window_helper.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -8,18 +10,104 @@ import 'package:fladder/models/settings/arguments_model.dart'; import 'package:fladder/models/settings/client_settings_model.dart'; import 'package:fladder/util/string_extensions.dart'; +const windowsStartupBackgroundColor = Color(0xFF101114); +const windowsNativeStartupBounds = Rect.fromLTWH(10, 10, 1280, 720); +const windowsPostNativeShowDelay = Duration(milliseconds: 600); +const windowsExternalPlacementSettleDelay = Duration(milliseconds: 750); +const windowsWindowPlacementPersistenceDelay = Duration(milliseconds: 2500); + +@visibleForTesting +Color fladderStartupBackgroundColor(TargetPlatform platform) => + platform == TargetPlatform.windows + ? windowsStartupBackgroundColor + : Colors.transparent; + +@visibleForTesting +bool shouldUseWaitUntilReadyToShow( + TargetPlatform platform, { + required bool debugMode, +}) { + if (platform == TargetPlatform.windows) return false; + if (platform == TargetPlatform.macOS && debugMode) return false; + return true; +} + +@visibleForTesting +bool shouldSetTaskbarVisibilityDuringStartup(TargetPlatform platform) => + platform != TargetPlatform.windows; + +@visibleForTesting +bool shouldApplyStoredWindowBounds({ + required bool isFullScreen, + required bool isMaximized, +}) => + !isFullScreen && !isMaximized; + +bool shouldPersistWindowBounds({ + required bool startupSettled, + required bool isFullScreen, + required bool isMaximized, +}) => + startupSettled && + shouldApplyStoredWindowBounds( + isFullScreen: isFullScreen, + isMaximized: isMaximized, + ); + +@visibleForTesting +bool hasExternalWindowsPlacement(Rect bounds, {double tolerance = 2}) => + (bounds.left - windowsNativeStartupBounds.left).abs() > tolerance || + (bounds.top - windowsNativeStartupBounds.top).abs() > tolerance || + (bounds.width - windowsNativeStartupBounds.width).abs() > tolerance || + (bounds.height - windowsNativeStartupBounds.height).abs() > tolerance; + extension WindowHelperSetup on WindowManager { + Future _settleWindowsAfterNativeShow({ + required bool restoreStoredBounds, + required Size storedSize, + }) async { + await Future.delayed(windowsPostNativeShowDelay); + await windowManager.focus(); + + if (restoreStoredBounds) { + await _restoreWindowsBoundsAfterExternalManagers(storedSize); + } + } + + Future _restoreWindowsBoundsAfterExternalManagers( + Size storedSize, + ) async { + await Future.delayed(windowsExternalPlacementSettleDelay); + + final isCurrentlyFullScreen = await windowManager.isFullScreen(); + final isCurrentlyMaximized = await windowManager.isMaximized(); + final currentBounds = await windowManager.getBounds(); + final externallyPositioned = hasExternalWindowsPlacement(currentBounds); + + if (!shouldApplyStoredWindowBounds( + isFullScreen: isCurrentlyFullScreen, + isMaximized: isCurrentlyMaximized, + ) || + externallyPositioned) { + return; + } + + await windowManager.setSize(storedSize); + await windowManager.center(); + } + Future setupFladderWindowChrome( ArgumentsModel startupArguments, ClientSettingsModel clientSettings, PackageInfo packageInfo, ) async { final isFullScreen = await windowManager.isFullScreen(); - final isMacDebug = defaultTargetPlatform == TargetPlatform.macOS && kDebugMode; + final isMacDebug = + defaultTargetPlatform == TargetPlatform.macOS && kDebugMode; final shouldResizeAndShow = !isMacDebug || !isFullScreen; final options = WindowOptions( - backgroundColor: Colors.transparent, + backgroundColor: fladderStartupBackgroundColor(defaultTargetPlatform), skipTaskbar: false, titleBarStyle: TitleBarStyle.hidden, title: packageInfo.appName.capitalize(), @@ -27,23 +115,53 @@ extension WindowHelperSetup on WindowManager { // Apply window chrome consistently; only skip waitUntilReadyToShow on macOS debug to avoid breaking full-screen during hot reloads. Future applyWindowState() async { - if (shouldResizeAndShow) { - await windowManager.setSize(Size(clientSettings.size.x, clientSettings.size.y)); + final isCurrentlyFullScreen = await windowManager.isFullScreen(); + final isCurrentlyMaximized = await windowManager.isMaximized(); + final applyStoredBounds = shouldApplyStoredWindowBounds( + isFullScreen: isCurrentlyFullScreen, + isMaximized: isCurrentlyMaximized, + ); + final isWindows = defaultTargetPlatform == TargetPlatform.windows; + + if (shouldResizeAndShow && isWindows) { + // The native runner owns the first show on Windows. Let external + // window managers place the visible window before considering saved + // bounds, so Fladder does not immediately undo their placement. + unawaited( + _settleWindowsAfterNativeShow( + restoreStoredBounds: applyStoredBounds, + storedSize: Size(clientSettings.size.x, clientSettings.size.y), + ), + ); + } else if (shouldResizeAndShow && applyStoredBounds) { + await windowManager.setSize( + Size(clientSettings.size.x, clientSettings.size.y), + ); await windowManager.center(); await windowManager.show(); await windowManager.focus(); } - if (startupArguments.htpcMode && !isFullScreen) { + if (startupArguments.htpcMode && !isCurrentlyFullScreen) { await windowManager.setFullScreen(true); } } - if (isMacDebug) { + if (!shouldUseWaitUntilReadyToShow( + defaultTargetPlatform, + debugMode: kDebugMode, + )) { await windowManager.setBackgroundColor(options.backgroundColor!); - await windowManager.setSkipTaskbar(options.skipTaskbar ?? false); + // setSkipTaskbar(false) initializes taskbar COM inside window_manager + // and can block the Windows platform channel during startup. A newly + // created runner window is already taskbar-visible. + if (shouldSetTaskbarVisibilityDuringStartup(defaultTargetPlatform)) { + await windowManager.setSkipTaskbar(options.skipTaskbar ?? false); + } await windowManager.setTitleBarStyle(options.titleBarStyle!); - await windowManager.setTitle(options.title ?? packageInfo.appName.capitalize()); + await windowManager.setTitle( + options.title ?? packageInfo.appName.capitalize(), + ); await applyWindowState(); } else { await windowManager.waitUntilReadyToShow(options, applyWindowState); diff --git a/lib/wrappers/media_control_wrapper.dart b/lib/wrappers/media_control_wrapper.dart index 2300e3afb..9756c895b 100644 --- a/lib/wrappers/media_control_wrapper.dart +++ b/lib/wrappers/media_control_wrapper.dart @@ -31,6 +31,7 @@ import 'package:fladder/providers/video_player_provider.dart'; import 'package:fladder/providers/window_title_provider.dart'; import 'package:fladder/src/video_player_helper.g.dart' hide PlaybackState; import 'package:fladder/util/localization_helper.dart'; +import 'package:fladder/util/single_flight_initializer.dart'; import 'package:fladder/wrappers/players/base_player.dart'; import 'package:fladder/wrappers/players/lib_mdk.dart' if (dart.library.html) 'package:fladder/stubs/web/lib_mdk_web.dart'; @@ -68,6 +69,8 @@ class MediaControlsWrapper extends BaseAudioHandler implements VideoPlayerContro List subscriptions = []; ProviderSubscription? _subtitleSettingsSubscription; SMTCWindows? smtc; + final SingleFlightInitializer _smtcInitializer = + SingleFlightInitializer(); bool initializedWrapper = false; bool _isStopped = false; @@ -87,7 +90,6 @@ class MediaControlsWrapper extends BaseAudioHandler implements VideoPlayerContro Future init() async { if (!initializedWrapper) { - initializedWrapper = true; if (!kIsWeb && Platform.isAndroid) { VideoPlayerControlsCallback.setUp(this); } @@ -105,6 +107,7 @@ class MediaControlsWrapper extends BaseAudioHandler implements VideoPlayerContro androidShowNotificationBadge: true, ), ); + initializedWrapper = true; } final player = switch (ref.read(videoPlayerSettingsProvider).wantedPlayer) { @@ -113,7 +116,7 @@ class MediaControlsWrapper extends BaseAudioHandler implements VideoPlayerContro PlayerOptions.nativePlayer => NativePlayer(), }; - setup(player); + await setup(player); } Future dispose() async { @@ -130,17 +133,19 @@ class MediaControlsWrapper extends BaseAudioHandler implements VideoPlayerContro _player = newPlayer; await newPlayer.init(ref.read(videoPlayerSettingsProvider)); - _initPlayer(); + await _initPlayer(); _subscribePlayerState(); } - void _initPlayer() { + Future _initPlayer() async { _subtitleSettingsSubscription?.close(); for (var element in subscriptions) { - element.cancel(); + await element.cancel(); } - _subscribePlayer(); - _subtitleSettingsSubscription = ref.listen(subtitleSettingsProvider, (_, next) { + subscriptions.clear(); + await _subscribePlayer(); + _subtitleSettingsSubscription = + ref.listen(subtitleSettingsProvider, (_, next) { _player?.applySubtitleSettings(next); }); } @@ -204,72 +209,93 @@ class MediaControlsWrapper extends BaseAudioHandler implements VideoPlayerContro } } - void _subscribePlayer() { + Future _subscribePlayer() async { if (!kIsWeb && Platform.isWindows) { - smtc = SMTCWindows( - config: const SMTCConfig( - fastForwardEnabled: true, - nextEnabled: false, - pauseEnabled: true, - playEnabled: true, - rewindEnabled: true, - prevEnabled: false, - stopEnabled: true, - ), - ); - - if (smtc != null) { - subscriptions.add( - smtc!.buttonPressStream.listen((event) { - switch (event) { - case PressedButton.play: - play(); - break; - case PressedButton.pause: - pause(); - break; - case PressedButton.fastForward: - fastForward(); - break; - case PressedButton.rewind: - rewind(); - break; - case PressedButton.stop: - stop(); - break; - case PressedButton.previous: - skipToPrevious(); - break; - case PressedButton.next: - skipToNext(); - break; - case PressedButton.record: - break; - case PressedButton.channelUp: - break; - case PressedButton.channelDown: - break; - } - }), - ); + if (ref.read(clientSettingsProvider).enableMediaKeys) { + try { + await _ensureSmtcInitialized(); + } catch (error, stackTrace) { + log( + 'Windows media controls failed to initialize: $error\n$stackTrace', + ); + } + if (smtc != null) { + subscriptions.add( + smtc!.buttonPressStream.listen((event) { + switch (event) { + case PressedButton.play: + play(); + break; + case PressedButton.pause: + pause(); + break; + case PressedButton.fastForward: + fastForward(); + break; + case PressedButton.rewind: + rewind(); + break; + case PressedButton.stop: + stop(); + break; + case PressedButton.previous: + skipToPrevious(); + break; + case PressedButton.next: + skipToNext(); + break; + case PressedButton.record: + break; + case PressedButton.channelUp: + break; + case PressedButton.channelDown: + break; + } + }), + ); + } + } else { + await smtc?.disableSmtc(); } } subscriptions.add(_player!.stateStream.listen((value) { playbackState.add(playbackState.value.copyWith( bufferedPosition: value.buffer, - processingState: value.buffering ? AudioProcessingState.buffering : AudioProcessingState.ready, + processingState: value.buffering + ? AudioProcessingState.buffering + : AudioProcessingState.ready, updatePosition: value.position, playing: value.playing, )); smtc?.setPosition(value.position); - smtc?.setPlaybackStatus(value.playing ? PlaybackStatus.playing : PlaybackStatus.paused); + smtc?.setPlaybackStatus( + value.playing ? PlaybackStatus.playing : PlaybackStatus.paused); if (value.completed && !_audioQueueTransitioning) { _onAudioTrackCompleted(); } })); } + Future _ensureSmtcInitialized() { + return _smtcInitializer.run(() async { + await SMTCWindows.initialize(); + return SMTCWindows( + config: const SMTCConfig( + fastForwardEnabled: true, + nextEnabled: false, + pauseEnabled: true, + playEnabled: true, + rewindEnabled: true, + prevEnabled: false, + stopEnabled: true, + ), + ); + }).then((instance) { + smtc = instance; + }); + } + @override Future skipToNext() async { if (_isAudioQueueMode) { @@ -543,7 +569,10 @@ class MediaControlsWrapper extends BaseAudioHandler implements VideoPlayerContro final playbackModel = ref.read(playBackModel); if (playbackModel == null) return false; if (_mpvPlaylistItems.isEmpty) return false; - if (_mpvPlaylistCurrentIndex < 0 || _mpvPlaylistCurrentIndex >= _mpvPlaylistItems.length) return false; + if (_mpvPlaylistCurrentIndex < 0 || + _mpvPlaylistCurrentIndex >= _mpvPlaylistItems.length) { + return false; + } return _mpvPlaylistItems[_mpvPlaylistCurrentIndex].id == playbackModel.item.id; } @@ -575,14 +604,18 @@ class MediaControlsWrapper extends BaseAudioHandler implements VideoPlayerContro Future loadNextVideo() async { final nextVideo = ref.read(playBackModel.select((value) => value?.nextVideo)); final buffering = ref.read(mediaPlaybackProvider.select((value) => value.buffering)); - if (nextVideo != null && !buffering) ref.read(playbackModelHelper).loadNewVideo(nextVideo); + if (nextVideo != null && !buffering) { + ref.read(playbackModelHelper).loadNewVideo(nextVideo); + } } @override Future loadPreviousVideo() async { final previousVideo = ref.read(playBackModel.select((value) => value?.previousVideo)); final buffering = ref.read(mediaPlaybackProvider.select((value) => value.buffering)); - if (previousVideo != null && !buffering) ref.read(playbackModelHelper).loadNewVideo(previousVideo); + if (previousVideo != null && !buffering) { + ref.read(playbackModelHelper).loadNewVideo(previousVideo); + } } @override diff --git a/test/single_flight_initializer_test.dart b/test/single_flight_initializer_test.dart new file mode 100644 index 000000000..6421bf9c0 --- /dev/null +++ b/test/single_flight_initializer_test.dart @@ -0,0 +1,54 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:fladder/util/single_flight_initializer.dart'; + +void main() { + test('concurrent initialization creates one native instance', () async { + final initializer = SingleFlightInitializer(); + final gate = Completer(); + var initializationCount = 0; + var instanceCount = 0; + + Future initialize() async { + initializationCount++; + await gate.future; + instanceCount++; + return Object(); + } + + final first = initializer.run(initialize); + final second = initializer.run(initialize); + + expect(identical(first, second), isTrue); + expect(initializationCount, 1); + expect(initializer.isRunning, isTrue); + + gate.complete(); + final instances = await Future.wait([first, second]); + + expect(instanceCount, 1); + expect(identical(instances.first, instances.last), isTrue); + expect(initializer.hasValue, isTrue); + }); + + test('failed initialization remains retryable', () async { + final initializer = SingleFlightInitializer(); + var initializationCount = 0; + + Future initialize() async { + initializationCount++; + if (initializationCount == 1) { + throw StateError('first attempt failed'); + } + return Object(); + } + + await expectLater(initializer.run(initialize), throwsStateError); + await initializer.run(initialize); + + expect(initializationCount, 2); + expect(initializer.hasValue, isTrue); + }); +} diff --git a/test/video_player_initialization_test.dart b/test/video_player_initialization_test.dart new file mode 100644 index 000000000..4192c9130 --- /dev/null +++ b/test/video_player_initialization_test.dart @@ -0,0 +1,82 @@ +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:fladder/providers/video_player_provider.dart'; + +void main() { + test('concurrent initialization requests share one operation', () async { + final gate = Completer(); + var initializationCount = 0; + final testProvider = Provider((ref) { + return VideoPlayerNotifier( + ref, + initializeOverride: () async { + initializationCount++; + await gate.future; + }, + ); + }); + final container = ProviderContainer(); + addTearDown(container.dispose); + final notifier = container.read(testProvider); + + final first = notifier.init(); + final second = notifier.init(); + + expect(identical(first, second), isTrue); + expect(initializationCount, 1); + expect(notifier.initializationInProgress, isTrue); + + gate.complete(); + await Future.wait([first, second]); + + expect(notifier.initializationInProgress, isFalse); + expect(notifier.hasCompletedInitialization, isTrue); + }); + + test('a completed initialization can be intentionally run again', () async { + var initializationCount = 0; + final testProvider = Provider((ref) { + return VideoPlayerNotifier( + ref, + initializeOverride: () async { + initializationCount++; + }, + ); + }); + final container = ProviderContainer(); + addTearDown(container.dispose); + final notifier = container.read(testProvider); + + await notifier.init(); + await notifier.init(); + + expect(initializationCount, 2); + }); + + test('failed initialization is retryable', () async { + var initializationCount = 0; + final testProvider = Provider((ref) { + return VideoPlayerNotifier( + ref, + initializeOverride: () async { + initializationCount++; + if (initializationCount == 1) { + throw StateError('first attempt failed'); + } + }, + ); + }); + final container = ProviderContainer(); + addTearDown(container.dispose); + final notifier = container.read(testProvider); + + await expectLater(notifier.init(), throwsStateError); + await notifier.init(); + + expect(initializationCount, 2); + expect(notifier.hasCompletedInitialization, isTrue); + }); +} diff --git a/test/window_helper_test.dart b/test/window_helper_test.dart new file mode 100644 index 000000000..cc570bbfd --- /dev/null +++ b/test/window_helper_test.dart @@ -0,0 +1,96 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:fladder/util/window_helper.dart'; + +void main() { + test('Windows startup uses an opaque background', () { + final color = fladderStartupBackgroundColor(TargetPlatform.windows); + + expect(color, windowsStartupBackgroundColor); + expect(color.a, 1.0); + }); + + test('Windows bypasses waitUntilReadyToShow', () { + expect( + shouldUseWaitUntilReadyToShow(TargetPlatform.windows, debugMode: false), + isFalse, + ); + }); + + test('Windows keeps native taskbar visibility during startup', () { + expect( + shouldSetTaskbarVisibilityDuringStartup(TargetPlatform.windows), + isFalse, + ); + expect( + shouldSetTaskbarVisibilityDuringStartup(TargetPlatform.macOS), + isTrue, + ); + }); + + test('stored bounds do not override maximized or fullscreen windows', () { + expect( + shouldApplyStoredWindowBounds(isFullScreen: false, isMaximized: true), + isFalse, + ); + expect( + shouldApplyStoredWindowBounds(isFullScreen: true, isMaximized: false), + isFalse, + ); + expect( + shouldApplyStoredWindowBounds(isFullScreen: false, isMaximized: false), + isTrue, + ); + }); + + test('window bounds are persisted only after startup settles', () { + expect( + shouldPersistWindowBounds( + startupSettled: false, + isFullScreen: false, + isMaximized: false, + ), + isFalse, + ); + expect( + shouldPersistWindowBounds( + startupSettled: true, + isFullScreen: false, + isMaximized: false, + ), + isTrue, + ); + expect( + shouldPersistWindowBounds( + startupSettled: true, + isFullScreen: false, + isMaximized: true, + ), + isFalse, + ); + }); + + test('detects normal-window placement by an external window manager', () { + expect(hasExternalWindowsPlacement(windowsNativeStartupBounds), isFalse); + expect( + hasExternalWindowsPlacement(const Rect.fromLTWH(-7, 0, 2574, 1393)), + isTrue, + ); + expect( + hasExternalWindowsPlacement(const Rect.fromLTWH(10, 10, 1281, 721)), + isFalse, + ); + }); + + test('other release desktop platforms retain ready-to-show behavior', () { + expect( + shouldUseWaitUntilReadyToShow(TargetPlatform.linux, debugMode: false), + isTrue, + ); + expect( + shouldUseWaitUntilReadyToShow(TargetPlatform.macOS, debugMode: false), + isTrue, + ); + }); +} diff --git a/windows/runner/flutter_window.cpp b/windows/runner/flutter_window.cpp index bf32e8a19..91caba3f0 100644 --- a/windows/runner/flutter_window.cpp +++ b/windows/runner/flutter_window.cpp @@ -5,8 +5,9 @@ #include "desktop_multi_window/desktop_multi_window_plugin.h" #include "flutter/generated_plugin_registrant.h" -FlutterWindow::FlutterWindow(const flutter::DartProject& project) - : project_(project) {} +FlutterWindow::FlutterWindow(const flutter::DartProject& project, + int initial_show_command) + : project_(project), initial_show_command_(initial_show_command) {} FlutterWindow::~FlutterWindow() {} @@ -35,8 +36,17 @@ bool FlutterWindow::OnCreate() { }); SetChildContent(flutter_controller_->view()->GetNativeWindow()); - flutter_controller_->engine()->SetNextFrameCallback([&]() { - this->Show(); + flutter_controller_->engine()->SetNextFrameCallback([this]() { + const HWND window = GetHandle(); + const HWND flutter_view = + flutter_controller_->view()->GetNativeWindow(); + // FancyZones and similar tools can reveal or place the top-level HWND + // while Flutter is producing its first frame. Explicitly show the hosted + // view before revealing the parent so its first frame stays visible. + ::ShowWindow(flutter_view, SW_SHOW); + if (window != nullptr && !::IsWindowVisible(window)) { + this->Show(initial_show_command_); + } }); // Flutter can complete the first frame before the "show window" callback is @@ -60,10 +70,22 @@ FlutterWindow::MessageHandler(HWND hwnd, UINT const message, WPARAM const wparam, LPARAM const lparam) noexcept { // Give Flutter, including plugins, an opportunity to handle window messages. + // + // WM_SIZE is special: window_manager may report it as handled, but the + // runner must still resize the hosted FLUTTERVIEW. External window managers + // resize the top-level HWND as soon as it appears. if (flutter_controller_) { std::optional result = flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, lparam); + if (message == WM_SIZE) { + const LRESULT resize_result = + Win32Window::MessageHandler(hwnd, message, wparam, lparam); + if (wparam != SIZE_MINIMIZED) { + flutter_controller_->ForceRedraw(); + } + return result.value_or(resize_result); + } if (result) { return *result; } diff --git a/windows/runner/flutter_window.h b/windows/runner/flutter_window.h index 6da0652f0..4ed5bd062 100644 --- a/windows/runner/flutter_window.h +++ b/windows/runner/flutter_window.h @@ -12,7 +12,8 @@ class FlutterWindow : public Win32Window { public: // Creates a new FlutterWindow hosting a Flutter view running |project|. - explicit FlutterWindow(const flutter::DartProject& project); + explicit FlutterWindow(const flutter::DartProject& project, + int initial_show_command = SW_SHOWNORMAL); virtual ~FlutterWindow(); protected: @@ -25,6 +26,7 @@ class FlutterWindow : public Win32Window { private: // The project to run. flutter::DartProject project_; + int initial_show_command_; // The Flutter instance hosted by this window. std::unique_ptr flutter_controller_; diff --git a/windows/runner/main.cpp b/windows/runner/main.cpp index 0090a4ba5..1b00bdf8e 100644 --- a/windows/runner/main.cpp +++ b/windows/runner/main.cpp @@ -24,7 +24,7 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); - FlutterWindow window(project); + FlutterWindow window(project, show_command); Win32Window::Point origin(10, 10); Win32Window::Size size(1280, 720); if (!window.Create(L"Fladder", origin, size)) { diff --git a/windows/runner/win32_window.cpp b/windows/runner/win32_window.cpp index 60608d0fe..fc9d59407 100644 --- a/windows/runner/win32_window.cpp +++ b/windows/runner/win32_window.cpp @@ -149,8 +149,17 @@ bool Win32Window::Create(const std::wstring& title, return OnCreate(); } -bool Win32Window::Show() { - return ShowWindow(window_handle_, SW_SHOWNORMAL); +bool Win32Window::Show(int requested_show_command) { + WINDOWPLACEMENT placement = {}; + placement.length = sizeof(WINDOWPLACEMENT); + const bool externally_maximized = + GetWindowPlacement(window_handle_, &placement) && + placement.showCmd == SW_SHOWMAXIMIZED; + const int effective_show_command = + requested_show_command == SW_SHOWMAXIMIZED || externally_maximized + ? SW_SHOWMAXIMIZED + : SW_SHOWNORMAL; + return ShowWindow(window_handle_, effective_show_command); } // static @@ -246,6 +255,10 @@ void Win32Window::SetChildContent(HWND content) { MoveWindow(content, frame.left, frame.top, frame.right - frame.left, frame.bottom - frame.top, true); + // An external window manager can show the parent while Flutter is still + // creating its child view. Keep the child explicitly visible so the + // responsive top-level window cannot become visually blank. + ShowWindow(content, SW_SHOW); SetFocus(child_content_); } diff --git a/windows/runner/win32_window.h b/windows/runner/win32_window.h index e901dde68..2621b4862 100644 --- a/windows/runner/win32_window.h +++ b/windows/runner/win32_window.h @@ -37,7 +37,7 @@ class Win32Window { bool Create(const std::wstring& title, const Point& origin, const Size& size); // Show the current window. Returns true if the window was successfully shown. - bool Show(); + bool Show(int requested_show_command = SW_SHOWNORMAL); // Release OS resources associated with window. void Destroy(); From d0357b435f4f1c013d02993c246dbf500973795a Mon Sep 17 00:00:00 2001 From: Vicente Higino Date: Wed, 29 Jul 2026 14:01:32 -0300 Subject: [PATCH 2/2] style: apply project Dart formatting --- .../platform/desktop_platform_wrapper.dart | 11 +++-------- lib/providers/video_player_provider.dart | 3 +-- lib/util/window_helper.dart | 10 +++------- lib/wrappers/media_control_wrapper.dart | 16 +++++----------- 4 files changed, 12 insertions(+), 28 deletions(-) diff --git a/lib/bootstrap/platform/desktop_platform_wrapper.dart b/lib/bootstrap/platform/desktop_platform_wrapper.dart index 398cda82b..7a01e62ec 100644 --- a/lib/bootstrap/platform/desktop_platform_wrapper.dart +++ b/lib/bootstrap/platform/desktop_platform_wrapper.dart @@ -23,8 +23,7 @@ class DesktopAppWrapper extends BaseAppWrapper { ConsumerState createState() => _DesktopAppWrapperState(); } -class _DesktopAppWrapperState extends BaseAppWrapperState - with WindowListener { +class _DesktopAppWrapperState extends BaseAppWrapperState with WindowListener { bool _windowPlacementInitialized = false; bool _windowIsMaximized = false; bool _windowIsFullScreen = false; @@ -140,9 +139,7 @@ class _DesktopAppWrapperState extends BaseAppWrapperState @override void onWindowEnterFullScreen() { _windowIsFullScreen = true; - ref - .read(mediaPlaybackProvider.notifier) - .update((state) => state.copyWith(fullScreen: true)); + ref.read(mediaPlaybackProvider.notifier).update((state) => state.copyWith(fullScreen: true)); unawaited(toggleMacTrafficLights(true)); super.onWindowEnterFullScreen(); } @@ -151,9 +148,7 @@ class _DesktopAppWrapperState extends BaseAppWrapperState void onWindowLeaveFullScreen() { _windowIsFullScreen = false; unawaited(toggleMacTrafficLights(false)); - ref - .read(mediaPlaybackProvider.notifier) - .update((state) => state.copyWith(fullScreen: false)); + ref.read(mediaPlaybackProvider.notifier).update((state) => state.copyWith(fullScreen: false)); super.onWindowLeaveFullScreen(); } } diff --git a/lib/providers/video_player_provider.dart b/lib/providers/video_player_provider.dart index 54f684967..e4dcd6d54 100644 --- a/lib/providers/video_player_provider.dart +++ b/lib/providers/video_player_provider.dart @@ -19,8 +19,7 @@ final mediaPlaybackProvider = StateProvider((ref) => MediaPl final playBackModel = StateProvider((ref) => null); -final videoPlayerProvider = - StateNotifierProvider((ref) { +final videoPlayerProvider = StateNotifierProvider((ref) { final videoPlayer = VideoPlayerNotifier(ref); if (defaultTargetPlatform != TargetPlatform.windows) { unawaited(videoPlayer.init()); diff --git a/lib/util/window_helper.dart b/lib/util/window_helper.dart index a7439166d..807665202 100644 --- a/lib/util/window_helper.dart +++ b/lib/util/window_helper.dart @@ -18,9 +18,7 @@ const windowsWindowPlacementPersistenceDelay = Duration(milliseconds: 2500); @visibleForTesting Color fladderStartupBackgroundColor(TargetPlatform platform) => - platform == TargetPlatform.windows - ? windowsStartupBackgroundColor - : Colors.transparent; + platform == TargetPlatform.windows ? windowsStartupBackgroundColor : Colors.transparent; @visibleForTesting bool shouldUseWaitUntilReadyToShow( @@ -33,8 +31,7 @@ bool shouldUseWaitUntilReadyToShow( } @visibleForTesting -bool shouldSetTaskbarVisibilityDuringStartup(TargetPlatform platform) => - platform != TargetPlatform.windows; +bool shouldSetTaskbarVisibilityDuringStartup(TargetPlatform platform) => platform != TargetPlatform.windows; @visibleForTesting bool shouldApplyStoredWindowBounds({ @@ -102,8 +99,7 @@ extension WindowHelperSetup on WindowManager { PackageInfo packageInfo, ) async { final isFullScreen = await windowManager.isFullScreen(); - final isMacDebug = - defaultTargetPlatform == TargetPlatform.macOS && kDebugMode; + final isMacDebug = defaultTargetPlatform == TargetPlatform.macOS && kDebugMode; final shouldResizeAndShow = !isMacDebug || !isFullScreen; final options = WindowOptions( diff --git a/lib/wrappers/media_control_wrapper.dart b/lib/wrappers/media_control_wrapper.dart index 9756c895b..d73e1bda3 100644 --- a/lib/wrappers/media_control_wrapper.dart +++ b/lib/wrappers/media_control_wrapper.dart @@ -69,8 +69,7 @@ class MediaControlsWrapper extends BaseAudioHandler implements VideoPlayerContro List subscriptions = []; ProviderSubscription? _subtitleSettingsSubscription; SMTCWindows? smtc; - final SingleFlightInitializer _smtcInitializer = - SingleFlightInitializer(); + final SingleFlightInitializer _smtcInitializer = SingleFlightInitializer(); bool initializedWrapper = false; bool _isStopped = false; @@ -144,8 +143,7 @@ class MediaControlsWrapper extends BaseAudioHandler implements VideoPlayerContro } subscriptions.clear(); await _subscribePlayer(); - _subtitleSettingsSubscription = - ref.listen(subtitleSettingsProvider, (_, next) { + _subtitleSettingsSubscription = ref.listen(subtitleSettingsProvider, (_, next) { _player?.applySubtitleSettings(next); }); } @@ -262,15 +260,12 @@ class MediaControlsWrapper extends BaseAudioHandler implements VideoPlayerContro subscriptions.add(_player!.stateStream.listen((value) { playbackState.add(playbackState.value.copyWith( bufferedPosition: value.buffer, - processingState: value.buffering - ? AudioProcessingState.buffering - : AudioProcessingState.ready, + processingState: value.buffering ? AudioProcessingState.buffering : AudioProcessingState.ready, updatePosition: value.position, playing: value.playing, )); smtc?.setPosition(value.position); - smtc?.setPlaybackStatus( - value.playing ? PlaybackStatus.playing : PlaybackStatus.paused); + smtc?.setPlaybackStatus(value.playing ? PlaybackStatus.playing : PlaybackStatus.paused); if (value.completed && !_audioQueueTransitioning) { _onAudioTrackCompleted(); } @@ -569,8 +564,7 @@ class MediaControlsWrapper extends BaseAudioHandler implements VideoPlayerContro final playbackModel = ref.read(playBackModel); if (playbackModel == null) return false; if (_mpvPlaylistItems.isEmpty) return false; - if (_mpvPlaylistCurrentIndex < 0 || - _mpvPlaylistCurrentIndex >= _mpvPlaylistItems.length) { + if (_mpvPlaylistCurrentIndex < 0 || _mpvPlaylistCurrentIndex >= _mpvPlaylistItems.length) { return false; } return _mpvPlaylistItems[_mpvPlaylistCurrentIndex].id == playbackModel.item.id;