From 13a7a660859be3e5c54a68dead949c062167cbdd Mon Sep 17 00:00:00 2001 From: ultraelectronica Date: Sat, 17 Oct 2026 11:06:54 +0800 Subject: [PATCH 1/3] Add app-wide display mode controller for refresh rate Replace the per-screen refresh-rate wrapper with a single controller mounted above the app. It applies the stored preference after loading, reacts to preference changes, and re-applies on resume so changes made in Settings take effect immediately. --- .../common/display_mode_controller.dart | 93 ++++++ .../widgets/display_mode_controller_test.dart | 289 ++++++++++++++++++ 2 files changed, 382 insertions(+) create mode 100644 lib/widgets/common/display_mode_controller.dart create mode 100644 test/widgets/display_mode_controller_test.dart diff --git a/lib/widgets/common/display_mode_controller.dart b/lib/widgets/common/display_mode_controller.dart new file mode 100644 index 00000000..5942b602 --- /dev/null +++ b/lib/widgets/common/display_mode_controller.dart @@ -0,0 +1,93 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:flick/core/utils/dev_log.dart'; +import 'package:flick/providers/app_preferences_provider.dart'; +import 'package:flick/services/display_mode_service.dart'; + +/// Applies the refresh-rate preference for the whole app. +/// +/// The preference used to be applied by a wrapper on every screen, which meant +/// a change made in Settings had no effect until a wrapped screen mounted and +/// that many screens kept poking the display driver. Applying it here also +/// keeps the mode in sync after the stored preference finishes loading and +/// when the app returns to the foreground. +class DisplayModeController extends ConsumerStatefulWidget { + final Widget child; + + const DisplayModeController({super.key, required this.child}); + + @override + ConsumerState createState() => + _DisplayModeControllerState(); +} + +class _DisplayModeControllerState extends ConsumerState + with WidgetsBindingObserver { + final DisplayModeService _displayModeService = DisplayModeService(); + bool _preferencesLoaded = false; + + bool get _isForeground { + final state = WidgetsBinding.instance.lifecycleState; + return state == null || state == AppLifecycleState.resumed; + } + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + _displayModeService.setForeground(_isForeground); + ref.listenManual( + appPreferencesProvider.select( + (preferences) => preferences.refreshRateMode, + ), + (previous, next) { + if (!_preferencesLoaded || !_isForeground || previous == next) return; + _applyRefreshRate(next, 'preference'); + }, + ); + _restoreRefreshRate(); + } + + Future _restoreRefreshRate() async { + try { + // A change-only listener misses a stored value that loaded before this + // controller mounted, including when a cached engine recreates its UI. + await ref.read(appPreferencesProvider.notifier).loaded; + if (!mounted) return; + _preferencesLoaded = true; + if (!_isForeground) return; + await _applyRefreshRate( + ref.read(appPreferencesProvider).refreshRateMode, + 'startup', + ); + } catch (e) { + devLog('DisplayMode[controller]: failed to restore refresh rate: $e'); + } + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + _displayModeService.setForeground(false); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + _displayModeService.setForeground(state == AppLifecycleState.resumed); + if (state != AppLifecycleState.resumed || !_preferencesLoaded) return; + _applyRefreshRate( + ref.read(appPreferencesProvider).refreshRateMode, + 'resume', + ); + } + + Future _applyRefreshRate(String mode, String reason) async { + devLog('DisplayMode[controller]: apply refreshRateMode=$mode ($reason)'); + await _displayModeService.applyRefreshRateMode(mode, reason: reason); + } + + @override + Widget build(BuildContext context) => widget.child; +} diff --git a/test/widgets/display_mode_controller_test.dart b/test/widgets/display_mode_controller_test.dart new file mode 100644 index 00000000..9e1ba608 --- /dev/null +++ b/test/widgets/display_mode_controller_test.dart @@ -0,0 +1,289 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:flick/providers/app_preferences_provider.dart'; +import 'package:flick/services/app_preferences_service.dart'; +import 'package:flick/services/display_mode_service.dart'; +import 'package:flick/widgets/common/display_mode_controller.dart'; + +class _LoadingPreferencesService extends AppPreferencesService { + final completion = Completer(); + + @override + Future getPreferences() => completion.future; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const channel = MethodChannel('flutter_display_mode'); + final calls = []; + late int activeId; + late int preferredId; + + void pauseApp(WidgetTester tester) { + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.hidden); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused); + } + + void resumeApp(WidgetTester tester) { + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.hidden); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + } + + setUp(() { + SharedPreferences.setMockInitialValues({}); + calls.clear(); + final service = DisplayModeService(); + service.debugReset(); + service.verificationDelay = Duration.zero; + activeId = 1; + preferredId = 0; + const autoMode = {'id': 0, 'width': 0, 'height': 0, 'refreshRate': 0.0}; + const mode60 = { + 'id': 1, + 'width': 1080, + 'height': 2400, + 'refreshRate': 60.0, + }; + const mode120 = { + 'id': 2, + 'width': 1080, + 'height': 2400, + 'refreshRate': 120.0, + }; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + switch (call.method) { + case 'getSupportedModes': + return const [mode60, mode120]; + case 'getActiveMode': + return activeId == 2 ? mode120 : mode60; + case 'getPreferredMode': + if (preferredId == 0) return autoMode; + return preferredId == 2 ? mode120 : mode60; + case 'setPreferredMode': + final id = (call.arguments as Map)['mode'] as int; + calls.add(id); + preferredId = id; + activeId = id == 0 ? 1 : id; + return null; + default: + return null; + } + }); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + DisplayModeService().debugReset(); + }); + + testWidgets('applies a refresh-rate change without waiting for a screen', ( + tester, + ) async { + final container = ProviderContainer(); + addTearDown(container.dispose); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const DisplayModeController(child: SizedBox()), + ), + ); + await tester.pumpAndSettle(); + + expect(calls, [ + 0, + ], reason: 'a fresh install clears forced modes and follows the system'); + + await container + .read(appPreferencesProvider.notifier) + .setRefreshRateMode(DisplayModeService.standard); + await tester.pumpAndSettle(); + + expect(calls, [0, 1]); + }); + + testWidgets('applies a stored forced mode after preferences load', ( + tester, + ) async { + SharedPreferences.setMockInitialValues({ + 'onboarding_completed': true, + 'app_refresh_rate_mode': 'high', + }); + final container = ProviderContainer(); + addTearDown(container.dispose); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const DisplayModeController(child: SizedBox()), + ), + ); + await tester.pumpAndSettle(); + + expect(calls, [2]); + }); + + for (final mode in {'high': 2, 'standard': 1, 'adaptive': 0}.entries) { + testWidgets('restores preloaded ${mode.key} on controller mount', ( + tester, + ) async { + SharedPreferences.setMockInitialValues({ + 'onboarding_completed': true, + 'app_refresh_rate_mode': mode.key, + }); + final container = ProviderContainer(); + addTearDown(container.dispose); + container.read(appPreferencesProvider); + await tester.runAsync(() => pumpEventQueue()); + expect(container.read(appPreferencesProvider).refreshRateMode, mode.key); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const DisplayModeController(child: SizedBox()), + ), + ); + await tester.pumpAndSettle(); + + expect(calls, [mode.value]); + }); + } + + testWidgets('waits for saved preferences before applying any mode', ( + tester, + ) async { + final service = _LoadingPreferencesService(); + final container = ProviderContainer( + overrides: [appPreferencesServiceProvider.overrideWithValue(service)], + ); + addTearDown(container.dispose); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const DisplayModeController(child: SizedBox()), + ), + ); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + await tester.pump(); + expect(calls, isEmpty); + + service.completion.complete( + const AppPreferences(refreshRateMode: DisplayModeService.high), + ); + await tester.pumpAndSettle(); + + expect(calls, [2]); + }); + + testWidgets( + 'reopening a window restores the saved rate without data changes', + (tester) async { + SharedPreferences.setMockInitialValues({ + 'onboarding_completed': true, + 'app_refresh_rate_mode': 'high', + 'app_haptics_enabled': false, + }); + final container = ProviderContainer(); + addTearDown(container.dispose); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const DisplayModeController(child: SizedBox()), + ), + ); + await tester.pumpAndSettle(); + expect(calls, [2]); + + pauseApp(tester); + // Android creates a new window, but the cached Dart engine and its last + // requested mode survive. + activeId = 1; + preferredId = 0; + resumeApp(tester); + await tester.pumpAndSettle(); + + expect(calls, [2, 2]); + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getString('app_refresh_rate_mode'), 'high'); + expect(prefs.getBool('app_haptics_enabled'), isFalse); + expect(prefs.getBool('onboarding_completed'), isTrue); + }, + ); + + testWidgets('closing before verification does not suppress the saved mode', ( + tester, + ) async { + SharedPreferences.setMockInitialValues({'app_refresh_rate_mode': 'high'}); + final container = ProviderContainer(); + addTearDown(container.dispose); + final service = DisplayModeService(); + service.verificationDelay = const Duration(milliseconds: 1500); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const DisplayModeController(child: SizedBox()), + ), + ); + await tester.pump(); + expect(calls, [2]); + + try { + pauseApp(tester); + activeId = 1; + preferredId = 0; + await tester.pump(const Duration(seconds: 2)); + expect(calls, [2], reason: 'a background 60 Hz reading is not rejection'); + } finally { + service.verificationDelay = Duration.zero; + resumeApp(tester); + await tester.pumpAndSettle(); + } + + expect(calls, [2, 2]); + }); + + testWidgets('preference changes in the background apply on resume', ( + tester, + ) async { + SharedPreferences.setMockInitialValues({'app_refresh_rate_mode': 'high'}); + final container = ProviderContainer(); + addTearDown(container.dispose); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const DisplayModeController(child: SizedBox()), + ), + ); + await tester.pumpAndSettle(); + expect(calls, [2]); + + try { + pauseApp(tester); + await container + .read(appPreferencesProvider.notifier) + .setRefreshRateMode(DisplayModeService.standard); + await tester.pump(); + expect(calls, [2]); + } finally { + resumeApp(tester); + await tester.pumpAndSettle(); + } + + expect(calls, [2, 1]); + }); +} From 07acaac9e373bda969be23bbe10f6e0953749d76 Mon Sep 17 00:00:00 2001 From: ultraelectronica Date: Sat, 17 Oct 2026 11:07:00 +0800 Subject: [PATCH 2/3] Refresh-rate mode selection and reapplication fixes - Standard mode now picks the advertised mode nearest 60 Hz instead of the panel's lowest rate. - Apply mode changes immediately app-wide, restore the saved mode after window recreation, and avoid false rejections while backgrounded. --- docs/RELEASE_0.22.2-beta.4.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/RELEASE_0.22.2-beta.4.md b/docs/RELEASE_0.22.2-beta.4.md index 22792a06..170d222e 100644 --- a/docs/RELEASE_0.22.2-beta.4.md +++ b/docs/RELEASE_0.22.2-beta.4.md @@ -1,12 +1,15 @@ # Flick 0.22.2-beta.4 -Display refresh-rate reliability: new installs follow the system rate by default, and forced modes now detect when the system rejects them. +Display refresh-rate reliability: new installs follow the system rate by default, the 60 Hz option now targets a real ~60 Hz mode, and forced modes detect when the system rejects them. ## Display Modes - **New installs follow the system refresh rate by default** — a fresh installation starts on the Adaptive refresh-rate mode instead of forcing the highest rate. Existing installations keep the mode they already use. +- **60 Hz now means an actual 60 Hz mode** — the Standard option picks the advertised mode closest to 60 Hz at the current resolution instead of the panel's lowest rate (10 Hz on some displays); if no ~60 Hz mode exists, Flick follows the system. +- **Refresh-rate changes apply immediately** — the selected mode is applied app-wide when it changes in Settings instead of waiting for the next screen to mount. +- **Saved refresh rate is restored on reopening**: Flick waits for saved preferences and reapplies the selected mode if Android recreated its window. A lower refresh rate while the app is in the background no longer triggers a false rejection. - **Forced refresh rates are verified after they are applied** — if the system ignores or downgrades the request (seen on some Fairphone 6 firmware, [#210](https://github.com/moss-apps/Flick/issues/210)), Flick returns to the system default, logs the mismatch, and stops re-forcing for the session. -- Refresh-rate requests are skipped when the target mode was already requested, instead of being re-sent from every screen mount or resume. +- Re-applying a mode already preferred by the current window is skipped. ## Builds & Flavors From bb70ace9d949cad78c16c385a1aff1efd6e73749 Mon Sep 17 00:00:00 2001 From: ultraelectronica Date: Sat, 17 Oct 2026 11:07:10 +0800 Subject: [PATCH 3/3] Centralize display mode application app-wide Replace the per-screen DisplayModeWrapper with a single DisplayModeController wrapping the app, and make the display mode service restore and re-apply the saved refresh rate on startup and window recreation. Screen-agnostic reapplication removes redundant per-mount, resume, and background-change requests. Also expose app preferences load completion so display mode changes wait for persisted settings, and document the refresh-rate fixes in the changelog. --- CHANGELOG.md | 5 +- lib/app/app.dart | 13 +- .../albums/screens/albums_screen.dart | 49 +- .../artists/screens/artist_detail_screen.dart | 413 ++++++----- .../artists/screens/artists_screen.dart | 49 +- .../favorites/screens/favorites_screen.dart | 55 +- .../folders/screens/folders_screen.dart | 57 +- lib/features/menu/screens/menu_screen.dart | 655 +++++++++--------- .../menu/screens/smart_mix_detail_screen.dart | 151 ++-- .../player/screens/full_player_screen.dart | 159 +++-- .../recap/screens/listening_recap_screen.dart | 45 +- .../recap/screens/replay_studio_screen.dart | 13 +- .../screens/recently_added_screen.dart | 37 +- .../screens/recently_played_screen.dart | 37 +- .../settings/screens/logs_screen.dart | 25 +- .../screens/uac2_preferences_screen.dart | 161 +++-- .../screens/uac2_settings_screen.dart | 135 ++-- lib/features/songs/screens/songs_screen.dart | 353 +++++----- .../whats_new/data/changelog_data.dart | 5 +- lib/providers/app_preferences_provider.dart | 12 +- lib/services/display_mode_service.dart | 141 +++- lib/widgets/common/display_mode_wrapper.dart | 60 -- .../app_preferences_provider_test.dart | 65 +- test/services/display_mode_service_test.dart | 317 +++++++-- 24 files changed, 1614 insertions(+), 1398 deletions(-) delete mode 100644 lib/widgets/common/display_mode_wrapper.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 89a1a410..51efafba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,11 @@ ### Display Modes - **New installs follow the system refresh rate by default**: a fresh installation now starts on the Adaptive refresh-rate mode instead of forcing the highest rate. Existing installations keep the mode they already use, and Settings → Interface → Refresh Rate still offers Adaptive, 60 Hz, and maximum. +- **60 Hz now means an actual 60 Hz mode**: the Standard option picks the advertised mode closest to 60 Hz at the current resolution, instead of the panel's lowest rate (10 Hz on displays with low-power modes). If no ~60 Hz mode exists, Flick follows the system instead. +- **Refresh-rate changes apply immediately**: the selected mode is applied app-wide when it changes in Settings, instead of waiting for the next screen to mount. +- **Saved refresh rate is restored on reopening**: Flick waits for saved preferences and checks the current Android window before skipping a repeated request. Background refresh-rate changes no longer trigger a false rejection or session fallback. - **Forced refresh rates are verified after they are applied**: if the system ignores or downgrades the request (seen on some Fairphone 6 firmware), Flick returns to the system default, logs the mismatch in the developer log, and stops re-forcing for the session. -- Refresh-rate requests are no longer re-sent on every screen mount and resume; redundant applies are skipped. +- Re-applying a mode already preferred by the current window is skipped. ## 0.22.1-beta.3 (2026-09-30) diff --git a/lib/app/app.dart b/lib/app/app.dart index f2cc9368..c7813145 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -39,6 +39,7 @@ import 'package:flick/providers/mini_player_config_provider.dart'; import 'package:flick/widgets/common/floating_mini_player.dart'; import 'package:flick/widgets/common/floating_scan_progress.dart'; import 'package:flick/widgets/common/connection_notice_host.dart'; +import 'package:flick/widgets/common/display_mode_controller.dart'; import 'package:flick/widgets/uac2/usb_bit_perfect_prompt.dart'; import 'package:flick/models/song.dart'; import 'package:flick/services/library_scanner_service.dart'; @@ -1082,11 +1083,13 @@ class _RootRouter extends ConsumerWidget { ? const MainShell() : const OnboardingScreen(); - return MediaQuery( - data: MediaQuery.of( - context, - ).copyWith(disableAnimations: !animationsEnabled), - child: child, + return DisplayModeController( + child: MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(disableAnimations: !animationsEnabled), + child: child, + ), ); } } diff --git a/lib/features/albums/screens/albums_screen.dart b/lib/features/albums/screens/albums_screen.dart index 4aeb56e2..20789e1d 100644 --- a/lib/features/albums/screens/albums_screen.dart +++ b/lib/features/albums/screens/albums_screen.dart @@ -19,7 +19,6 @@ import 'package:flick/services/player_service.dart'; import 'package:flick/services/motion_art/animated_artwork_service.dart'; import 'package:flick/widgets/common/cached_image_widget.dart'; import 'package:flick/widgets/common/surface_icon_button.dart'; -import 'package:flick/widgets/common/display_mode_wrapper.dart'; import 'package:flick/widgets/common/glass_bottom_sheet.dart'; import 'package:flick/l10n/l10n.dart'; @@ -195,32 +194,30 @@ class _AlbumsScreenState extends ConsumerState { if (next.hasValue) _loadAlbums(); }); - return DisplayModeWrapper( - child: Scaffold( - backgroundColor: Colors.transparent, - body: Stack( - children: [ - Positioned.fill( - child: AmbientBackground(song: currentSong), - ), - SafeArea( - bottom: false, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildHeader(context), - Expanded( - child: _isLoading - ? _buildLoadingState() - : _sortedAlbums.isEmpty - ? _buildEmptyState() - : _buildAlbumsGrid(), - ), - ], - ), + return Scaffold( + backgroundColor: Colors.transparent, + body: Stack( + children: [ + Positioned.fill( + child: AmbientBackground(song: currentSong), + ), + SafeArea( + bottom: false, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildHeader(context), + Expanded( + child: _isLoading + ? _buildLoadingState() + : _sortedAlbums.isEmpty + ? _buildEmptyState() + : _buildAlbumsGrid(), + ), + ], ), - ], - ), + ), + ], ), ); } diff --git a/lib/features/artists/screens/artist_detail_screen.dart b/lib/features/artists/screens/artist_detail_screen.dart index 4b205a14..0ab03a23 100644 --- a/lib/features/artists/screens/artist_detail_screen.dart +++ b/lib/features/artists/screens/artist_detail_screen.dart @@ -37,7 +37,6 @@ import 'package:flick/features/player/widgets/add_to_playlist_sheet.dart'; import 'package:flick/features/player/widgets/sleep_timer_bottom_sheet.dart'; import 'package:flick/features/songs/widgets/song_actions_button.dart'; import 'package:flick/providers/favorites_provider.dart'; -import 'package:flick/widgets/common/display_mode_wrapper.dart'; import 'package:flick/providers/app_preferences_provider.dart'; import 'package:flick/l10n/l10n.dart'; @@ -583,239 +582,237 @@ class _ArtistDetailScreenState extends ConsumerState Widget build(BuildContext context) { final bgColor = _tintBackground(_backgroundBlend); - return DisplayModeWrapper( - child: TweenAnimationBuilder( - tween: ColorTween(begin: AppColors.background, end: bgColor), - duration: AppConstants.animationSlow, - curve: Curves.easeOut, - builder: (context, animatedBg, _) { - final resolvedBg = animatedBg ?? AppColors.background; - final prefs = ref.watch(appPreferencesProvider); - final appleMusic = ref - .watch(appleMusicArtistProvider(widget.artistName)) - .value; - final appleBio = appleMusic?.info?.biography?.trim(); - final similarArtists = - appleMusic?.info?.similarArtists ?? - const []; - final topSongs = - appleMusic?.topSongs ?? const []; - return Stack( - children: [ - Scaffold( + return TweenAnimationBuilder( + tween: ColorTween(begin: AppColors.background, end: bgColor), + duration: AppConstants.animationSlow, + curve: Curves.easeOut, + builder: (context, animatedBg, _) { + final resolvedBg = animatedBg ?? AppColors.background; + final prefs = ref.watch(appPreferencesProvider); + final appleMusic = ref + .watch(appleMusicArtistProvider(widget.artistName)) + .value; + final appleBio = appleMusic?.info?.biography?.trim(); + final similarArtists = + appleMusic?.info?.similarArtists ?? + const []; + final topSongs = + appleMusic?.topSongs ?? const []; + return Stack( + children: [ + Scaffold( + backgroundColor: resolvedBg, + body: AdaptiveColorProvider( backgroundColor: resolvedBg, - body: AdaptiveColorProvider( - backgroundColor: resolvedBg, - albumDominantColor: _artistColor, - child: NotificationListener( - onNotification: onScrollNotification, - child: CustomScrollView( - controller: _scrollController, - slivers: [ - DetailHeader( - expanded: prefs.detailHeaderArtExpanded, - centeredTitle: prefs.detailHeaderCenteredTitle, - artBuilder: _buildArtLayer, - title: widget.artistName, - subtitle: - l10n.albumsSongs(_albumGroups.length, widget.songs.length), - meta: _metaLine, - lossless: _hasLossless, - fadeTo: resolvedBg, - onBack: () => Navigator.of(context).pop(), + albumDominantColor: _artistColor, + child: NotificationListener( + onNotification: onScrollNotification, + child: CustomScrollView( + controller: _scrollController, + slivers: [ + DetailHeader( + expanded: prefs.detailHeaderArtExpanded, + centeredTitle: prefs.detailHeaderCenteredTitle, + artBuilder: _buildArtLayer, + title: widget.artistName, + subtitle: + l10n.albumsSongs(_albumGroups.length, widget.songs.length), + meta: _metaLine, + lossless: _hasLossless, + fadeTo: resolvedBg, + onBack: () => Navigator.of(context).pop(), + ), + const SliverToBoxAdapter( + child: SizedBox(height: AppConstants.spacingMd), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppConstants.spacingLg, + vertical: AppConstants.spacingXxs, + ), + child: DetailActionCapsule( + isFavorite: _isAllFavorites, + onToggleFavorite: _toggleFavoriteAll, + onShuffle: _shuffleAll, + onPlay: _playAll, + onQueue: _queueAll, + onMore: _showMore, + primaryColor: _artistColor, + ), + ), + ), + SliverToBoxAdapter( + child: + DetailDescription(descriptionKey: _descriptionKey), ), const SliverToBoxAdapter( child: SizedBox(height: AppConstants.spacingMd), ), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: AppConstants.spacingLg, - vertical: AppConstants.spacingXxs, - ), - child: DetailActionCapsule( - isFavorite: _isAllFavorites, - onToggleFavorite: _toggleFavoriteAll, - onShuffle: _shuffleAll, - onPlay: _playAll, - onQueue: _queueAll, - onMore: _showMore, - primaryColor: _artistColor, - ), - ), - ), + if (appleBio != null && appleBio.isNotEmpty) + _buildSectionTitle(context, l10n.about), + if (appleBio != null && appleBio.isNotEmpty) SliverToBoxAdapter( - child: - DetailDescription(descriptionKey: _descriptionKey), - ), - const SliverToBoxAdapter( - child: SizedBox(height: AppConstants.spacingMd), - ), - if (appleBio != null && appleBio.isNotEmpty) - _buildSectionTitle(context, l10n.about), - if (appleBio != null && appleBio.isNotEmpty) - SliverToBoxAdapter( - child: FetchedDescription( - text: appleBio, - sheetTitle: l10n.about, - ), + child: FetchedDescription( + text: appleBio, + sheetTitle: l10n.about, ), - const SliverToBoxAdapter( - child: SizedBox(height: AppConstants.spacingLg), ), - if (_artistAlbums.length > 1) - _buildSectionTitle(context, l10n.albums), - if (_artistAlbums.length > 1) - SliverToBoxAdapter( - child: SizedBox( - height: 200, - child: ListView.separated( - padding: const EdgeInsets.symmetric( - horizontal: AppConstants.spacingLg, - ), - scrollDirection: Axis.horizontal, - itemCount: _artistAlbums.length, - separatorBuilder: (_, __) => const SizedBox( - width: AppConstants.spacingMd, - ), - itemBuilder: (context, index) { - final album = _artistAlbums[index]; - return _AlbumCard( - albumName: album.albumName, - albumArtist: album.albumArtist, - songCount: album.songs.length, - albumArt: _getArt(album.songs), - albumArtSourcePath: _getSourcePath( - album.songs, - ), - onTap: () => _openAlbum(album), - ); - }, + const SliverToBoxAdapter( + child: SizedBox(height: AppConstants.spacingLg), + ), + if (_artistAlbums.length > 1) + _buildSectionTitle(context, l10n.albums), + if (_artistAlbums.length > 1) + SliverToBoxAdapter( + child: SizedBox( + height: 200, + child: ListView.separated( + padding: const EdgeInsets.symmetric( + horizontal: AppConstants.spacingLg, ), - ), - ), - if (_artistAlbums.length > 1) - const SliverToBoxAdapter( - child: SizedBox(height: AppConstants.spacingLg), - ), - if (!_isLoadingExtras && _mostPlayedSongs.isNotEmpty) - _buildSectionTitle(context, l10n.mostPlayed), - if (!_isLoadingExtras && _mostPlayedSongs.isNotEmpty) - SliverToBoxAdapter( - child: SizedBox( - height: 72, - child: ListView.separated( - padding: const EdgeInsets.symmetric( - horizontal: AppConstants.spacingLg, - ), - scrollDirection: Axis.horizontal, - itemCount: _mostPlayedSongs.length, - separatorBuilder: (_, __) => const SizedBox( - width: AppConstants.spacingMd, - ), - itemBuilder: (context, index) { - final song = _mostPlayedSongs[index]; - return _MostPlayedCard( - song: song, - onTap: () => _playSong(song), - ); - }, + scrollDirection: Axis.horizontal, + itemCount: _artistAlbums.length, + separatorBuilder: (_, __) => const SizedBox( + width: AppConstants.spacingMd, ), + itemBuilder: (context, index) { + final album = _artistAlbums[index]; + return _AlbumCard( + albumName: album.albumName, + albumArtist: album.albumArtist, + songCount: album.songs.length, + albumArt: _getArt(album.songs), + albumArtSourcePath: _getSourcePath( + album.songs, + ), + onTap: () => _openAlbum(album), + ); + }, ), ), - if (!_isLoadingExtras && _mostPlayedSongs.isNotEmpty) - const SliverToBoxAdapter( - child: SizedBox(height: AppConstants.spacingLg), - ), - if (similarArtists.isNotEmpty) - _buildSectionTitle(context, l10n.similarArtists), - if (similarArtists.isNotEmpty) - SliverToBoxAdapter( - child: Padding( + ), + if (_artistAlbums.length > 1) + const SliverToBoxAdapter( + child: SizedBox(height: AppConstants.spacingLg), + ), + if (!_isLoadingExtras && _mostPlayedSongs.isNotEmpty) + _buildSectionTitle(context, l10n.mostPlayed), + if (!_isLoadingExtras && _mostPlayedSongs.isNotEmpty) + SliverToBoxAdapter( + child: SizedBox( + height: 72, + child: ListView.separated( padding: const EdgeInsets.symmetric( horizontal: AppConstants.spacingLg, ), - child: Wrap( - spacing: AppConstants.spacingXs, - runSpacing: AppConstants.spacingXs, - children: [ - for (final artist in similarArtists) - _SimilarArtistChip( - name: artist.name, - onTap: () => - _openSimilarArtist(artist.name), - ), - ], + scrollDirection: Axis.horizontal, + itemCount: _mostPlayedSongs.length, + separatorBuilder: (_, __) => const SizedBox( + width: AppConstants.spacingMd, ), + itemBuilder: (context, index) { + final song = _mostPlayedSongs[index]; + return _MostPlayedCard( + song: song, + onTap: () => _playSong(song), + ); + }, ), ), - if (similarArtists.isNotEmpty) - const SliverToBoxAdapter( - child: SizedBox(height: AppConstants.spacingLg), + ), + if (!_isLoadingExtras && _mostPlayedSongs.isNotEmpty) + const SliverToBoxAdapter( + child: SizedBox(height: AppConstants.spacingLg), + ), + if (similarArtists.isNotEmpty) + _buildSectionTitle(context, l10n.similarArtists), + if (similarArtists.isNotEmpty) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppConstants.spacingLg, + ), + child: Wrap( + spacing: AppConstants.spacingXs, + runSpacing: AppConstants.spacingXs, + children: [ + for (final artist in similarArtists) + _SimilarArtistChip( + name: artist.name, + onTap: () => + _openSimilarArtist(artist.name), + ), + ], + ), ), - if (topSongs.isNotEmpty) - _buildSectionTitle(context, l10n.topSongs), - if (topSongs.isNotEmpty) - SliverToBoxAdapter( - child: SizedBox( - height: 72, - child: ListView.separated( - padding: const EdgeInsets.symmetric( - horizontal: AppConstants.spacingLg, - ), - scrollDirection: Axis.horizontal, - itemCount: topSongs.length, - separatorBuilder: (_, __) => const SizedBox( - width: AppConstants.spacingMd, - ), - itemBuilder: (context, index) { - final song = topSongs[index]; - return _AppleTopSongCard( - rank: index + 1, - song: song, - onTap: () => _openAppleMusic( - _appleMusicSearchUri( - '${song.artistName} ${song.trackName}', - ), - ), - ); - }, + ), + if (similarArtists.isNotEmpty) + const SliverToBoxAdapter( + child: SizedBox(height: AppConstants.spacingLg), + ), + if (topSongs.isNotEmpty) + _buildSectionTitle(context, l10n.topSongs), + if (topSongs.isNotEmpty) + SliverToBoxAdapter( + child: SizedBox( + height: 72, + child: ListView.separated( + padding: const EdgeInsets.symmetric( + horizontal: AppConstants.spacingLg, + ), + scrollDirection: Axis.horizontal, + itemCount: topSongs.length, + separatorBuilder: (_, __) => const SizedBox( + width: AppConstants.spacingMd, ), + itemBuilder: (context, index) { + final song = topSongs[index]; + return _AppleTopSongCard( + rank: index + 1, + song: song, + onTap: () => _openAppleMusic( + _appleMusicSearchUri( + '${song.artistName} ${song.trackName}', + ), + ), + ); + }, ), ), - if (topSongs.isNotEmpty) - const SliverToBoxAdapter( - child: SizedBox(height: AppConstants.spacingLg), - ), - _buildSectionTitle(context, l10n.songs14), - SliverPadding( - padding: EdgeInsets.only( - bottom: AppConstants.navBarHeight + 120, - ), - sliver: SliverList( - delegate: SliverChildBuilderDelegate(( - context, - index, - ) { - final albumEntry = _albumGroups[index]; - return _AlbumSection( - albumName: albumEntry.key, - songs: albumEntry.value, - onSongTap: _playSong, - ); - }, childCount: _albumGroups.length), - ), ), - ], - ), + if (topSongs.isNotEmpty) + const SliverToBoxAdapter( + child: SizedBox(height: AppConstants.spacingLg), + ), + _buildSectionTitle(context, l10n.songs14), + SliverPadding( + padding: EdgeInsets.only( + bottom: AppConstants.navBarHeight + 120, + ), + sliver: SliverList( + delegate: SliverChildBuilderDelegate(( + context, + index, + ) { + final albumEntry = _albumGroups[index]; + return _AlbumSection( + albumName: albumEntry.key, + songs: albumEntry.value, + onSongTap: _playSong, + ); + }, childCount: _albumGroups.length), + ), + ), + ], ), ), ), - _buildOverlayTopBar(context, resolvedBg), - ], - ); - }, - ), + ), + _buildOverlayTopBar(context, resolvedBg), + ], + ); + }, ); } diff --git a/lib/features/artists/screens/artists_screen.dart b/lib/features/artists/screens/artists_screen.dart index 33ea65c4..ae4cc486 100644 --- a/lib/features/artists/screens/artists_screen.dart +++ b/lib/features/artists/screens/artists_screen.dart @@ -16,7 +16,6 @@ import 'package:flick/providers/providers.dart'; import 'package:flick/services/player_service.dart'; import 'package:flick/widgets/common/cached_image_widget.dart'; import 'package:flick/widgets/common/surface_icon_button.dart'; -import 'package:flick/widgets/common/display_mode_wrapper.dart'; import 'package:flick/widgets/common/glass_search_bar.dart'; import 'package:flick/widgets/common/glass_bottom_sheet.dart'; import 'package:flick/l10n/l10n.dart'; @@ -196,32 +195,30 @@ class _ArtistsScreenState extends ConsumerState { if (next.hasValue) _loadArtists(); }); - return DisplayModeWrapper( - child: Scaffold( - backgroundColor: Colors.transparent, - body: Stack( - children: [ - Positioned.fill( - child: AmbientBackground(song: currentSong), - ), - SafeArea( - bottom: false, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildHeader(context), - Expanded( - child: _isLoading - ? _buildLoadingState() - : _artists.isEmpty - ? _buildEmptyState() - : _buildArtistsList(), - ), - ], - ), + return Scaffold( + backgroundColor: Colors.transparent, + body: Stack( + children: [ + Positioned.fill( + child: AmbientBackground(song: currentSong), + ), + SafeArea( + bottom: false, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildHeader(context), + Expanded( + child: _isLoading + ? _buildLoadingState() + : _artists.isEmpty + ? _buildEmptyState() + : _buildArtistsList(), + ), + ], ), - ], - ), + ), + ], ), ); } diff --git a/lib/features/favorites/screens/favorites_screen.dart b/lib/features/favorites/screens/favorites_screen.dart index 2486c2d2..0df7f4ed 100644 --- a/lib/features/favorites/screens/favorites_screen.dart +++ b/lib/features/favorites/screens/favorites_screen.dart @@ -11,7 +11,6 @@ import 'package:flick/models/song.dart'; import 'package:flick/services/player_service.dart'; import 'package:flick/services/favorites_service.dart'; import 'package:flick/widgets/common/cached_image_widget.dart'; -import 'package:flick/widgets/common/display_mode_wrapper.dart'; import 'package:flick/widgets/common/flick_artwork_placeholder.dart'; import 'package:flick/widgets/common/surface_icon_button.dart'; import 'package:flick/providers/providers.dart'; @@ -195,35 +194,33 @@ class _FavoritesScreenState extends ConsumerState { ref.watch(appPreferencesProvider).favoriteRemovalMode; final currentSong = ref.watch(currentSongProvider); - return DisplayModeWrapper( - child: Scaffold( - backgroundColor: Colors.transparent, - body: Stack( - children: [ - Positioned.fill( - child: AmbientBackground(song: currentSong), - ), - SafeArea( - bottom: false, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (_selectionMode) - _buildSelectionHeader(context) - else - _buildHeader(context), - Expanded( - child: _isLoading - ? _buildLoadingState() - : _favorites.isEmpty - ? _buildEmptyState() - : _buildFavoritesList(removalMode), - ), - ], - ), + return Scaffold( + backgroundColor: Colors.transparent, + body: Stack( + children: [ + Positioned.fill( + child: AmbientBackground(song: currentSong), + ), + SafeArea( + bottom: false, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (_selectionMode) + _buildSelectionHeader(context) + else + _buildHeader(context), + Expanded( + child: _isLoading + ? _buildLoadingState() + : _favorites.isEmpty + ? _buildEmptyState() + : _buildFavoritesList(removalMode), + ), + ], ), - ], - ), + ), + ], ), ); } diff --git a/lib/features/folders/screens/folders_screen.dart b/lib/features/folders/screens/folders_screen.dart index 6734f81e..f15e1f34 100644 --- a/lib/features/folders/screens/folders_screen.dart +++ b/lib/features/folders/screens/folders_screen.dart @@ -18,7 +18,6 @@ import 'package:flick/features/player/widgets/ambient_background.dart'; import 'package:flick/widgets/common/blurred_song_background.dart'; import 'package:flick/widgets/common/surface_icon_button.dart'; import 'package:flick/widgets/common/cached_image_widget.dart'; -import 'package:flick/widgets/common/display_mode_wrapper.dart'; import 'package:flick/widgets/common/flick_artwork_placeholder.dart'; import 'package:flick/widgets/common/glass_bottom_sheet.dart'; import 'package:flick/l10n/l10n.dart'; @@ -263,36 +262,34 @@ class _FoldersScreenState extends ConsumerState { Widget build(BuildContext context) { final currentSong = ref.watch(currentSongProvider); - return DisplayModeWrapper( - child: Scaffold( - backgroundColor: Colors.transparent, - body: Stack( - children: [ - Positioned.fill(child: AmbientBackground(song: currentSong)), - SafeArea( - bottom: false, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildHeader(context), - Expanded( - child: _isLoading - ? const Center( - child: CircularProgressIndicator( - color: AppColors.textSecondary, - ), - ) - : _folders.isEmpty - ? _buildEmptyState() - : _viewMode == FolderViewMode.tree - ? _buildFoldersTree() - : _buildRootFoldersGrid(), - ), - ], - ), + return Scaffold( + backgroundColor: Colors.transparent, + body: Stack( + children: [ + Positioned.fill(child: AmbientBackground(song: currentSong)), + SafeArea( + bottom: false, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildHeader(context), + Expanded( + child: _isLoading + ? const Center( + child: CircularProgressIndicator( + color: AppColors.textSecondary, + ), + ) + : _folders.isEmpty + ? _buildEmptyState() + : _viewMode == FolderViewMode.tree + ? _buildFoldersTree() + : _buildRootFoldersGrid(), + ), + ], ), - ], - ), + ), + ], ), ); } diff --git a/lib/features/menu/screens/menu_screen.dart b/lib/features/menu/screens/menu_screen.dart index 8faf2c6d..59516366 100644 --- a/lib/features/menu/screens/menu_screen.dart +++ b/lib/features/menu/screens/menu_screen.dart @@ -35,7 +35,6 @@ import 'package:flick/services/player_service.dart'; import 'package:flick/services/uac2_preferences_service.dart'; import 'package:flick/widgets/common/cached_image_widget.dart'; import 'package:flick/widgets/common/flick_artwork_placeholder.dart'; -import 'package:flick/widgets/common/display_mode_wrapper.dart'; import 'package:flick/widgets/common/engine_restart_notice.dart'; import 'package:flick/widgets/common/glass_bottom_sheet.dart'; import 'package:flick/widgets/uac2/uac2_volume_control.dart'; @@ -664,392 +663,390 @@ class _MenuScreenState extends ConsumerState playlistsAsync.isLoading || _isHistoryLoading); - return DisplayModeWrapper( - child: Scaffold( - backgroundColor: Colors.transparent, - body: Stack( - children: [ - RepaintBoundary( - child: SafeArea( - bottom: false, - child: RefreshIndicator( - onRefresh: _refreshHome, - color: AppColors.accent, - child: CustomScrollView( - physics: const AlwaysScrollableScrollPhysics( - parent: BouncingScrollPhysics(), + return Scaffold( + backgroundColor: Colors.transparent, + body: Stack( + children: [ + RepaintBoundary( + child: SafeArea( + bottom: false, + child: RefreshIndicator( + onRefresh: _refreshHome, + color: AppColors.accent, + child: CustomScrollView( + physics: const AlwaysScrollableScrollPhysics( + parent: BouncingScrollPhysics(), + ), + slivers: [ + SliverToBoxAdapter( + child: _buildHeader( + context, + songCount: allSongs.length, + favoriteCount: favoriteSongs.length, + playlistCount: playlists.length, + ), ), - slivers: [ + if (appPreferences.showBrowseMore) SliverToBoxAdapter( - child: _buildHeader( - context, - songCount: allSongs.length, - favoriteCount: favoriteSongs.length, - playlistCount: playlists.length, + child: Padding( + padding: const EdgeInsets.only( + bottom: AppConstants.spacingMd, + ), + child: _buildBrowseShortcuts(context), ), ), - if (appPreferences.showBrowseMore) - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.only( - bottom: AppConstants.spacingMd, - ), - child: _buildBrowseShortcuts(context), - ), + SliverToBoxAdapter( + child: AnimatedSize( + duration: AppConstants.animationNormal, + curve: Curves.easeInOut, + alignment: Alignment.topCenter, + child: + appPreferences.showEngineSelector && + enginePreferenceAsync.hasValue + ? Padding( + padding: const EdgeInsets.fromLTRB( + AppConstants.spacingLg, + 0, + AppConstants.spacingLg, + AppConstants.spacingMd, + ), + child: _buildEngineSelector( + context, + enginePreferenceAsync.value!, + dominantColor: _heroDominantColor, + ), + ) + : const SizedBox.shrink(), ), + ), + if (appPreferences.showUsbVolumeOnMenu) SliverToBoxAdapter( - child: AnimatedSize( - duration: AppConstants.animationNormal, - curve: Curves.easeInOut, - alignment: Alignment.topCenter, - child: - appPreferences.showEngineSelector && - enginePreferenceAsync.hasValue - ? Padding( - padding: const EdgeInsets.fromLTRB( - AppConstants.spacingLg, - 0, - AppConstants.spacingLg, - AppConstants.spacingMd, - ), - child: _buildEngineSelector( - context, - enginePreferenceAsync.value!, - dominantColor: _heroDominantColor, - ), - ) - : const SizedBox.shrink(), - ), - ), - if (appPreferences.showUsbVolumeOnMenu) - SliverToBoxAdapter( - child: AnimatedSize( - duration: AppConstants.animationNormal, - curve: Curves.easeInOut, - alignment: Alignment.topCenter, - child: Padding( - padding: const EdgeInsets.fromLTRB( - AppConstants.spacingLg, - 0, - AppConstants.spacingLg, - AppConstants.spacingMd, - ), - child: Uac2VolumeControl( - useGradientBackground: true, - gradientColors: - _heroGradientColors(_heroDominantColor), - ), + child: AnimatedSize( + duration: AppConstants.animationNormal, + curve: Curves.easeInOut, + alignment: Alignment.topCenter, + child: Padding( + padding: const EdgeInsets.fromLTRB( + AppConstants.spacingLg, + 0, + AppConstants.spacingLg, + AppConstants.spacingMd, + ), + child: Uac2VolumeControl( + useGradientBackground: true, + gradientColors: + _heroGradientColors(_heroDominantColor), ), ), ), - if (_pendingEngineRestart && - appPreferences.showEngineSelector) - SliverToBoxAdapter( - child: AnimatedSize( - duration: AppConstants.animationNormal, - curve: Curves.easeInOut, - alignment: Alignment.topCenter, - child: Padding( - padding: const EdgeInsets.fromLTRB( - AppConstants.spacingLg, - 0, - AppConstants.spacingLg, - AppConstants.spacingMd, - ), - child: const EngineRestartNotice(), + ), + if (_pendingEngineRestart && + appPreferences.showEngineSelector) + SliverToBoxAdapter( + child: AnimatedSize( + duration: AppConstants.animationNormal, + curve: Curves.easeInOut, + alignment: Alignment.topCenter, + child: Padding( + padding: const EdgeInsets.fromLTRB( + AppConstants.spacingLg, + 0, + AppConstants.spacingLg, + AppConstants.spacingMd, ), + child: const EngineRestartNotice(), ), ), - if (updateState.updateAvailable && _showUpdateNotice) - SliverToBoxAdapter( - child: AnimatedSize( - duration: AppConstants.animationNormal, - curve: Curves.easeInOut, - alignment: Alignment.topCenter, - child: FadeTransition( - opacity: _updateNoticeFade, - child: SlideTransition( - position: _updateNoticeSlide, - child: Padding( - padding: const EdgeInsets.fromLTRB( - AppConstants.spacingLg, - 0, - AppConstants.spacingLg, - AppConstants.spacingMd, - ), - child: _buildUpdateNotice(context), + ), + if (updateState.updateAvailable && _showUpdateNotice) + SliverToBoxAdapter( + child: AnimatedSize( + duration: AppConstants.animationNormal, + curve: Curves.easeInOut, + alignment: Alignment.topCenter, + child: FadeTransition( + opacity: _updateNoticeFade, + child: SlideTransition( + position: _updateNoticeSlide, + child: Padding( + padding: const EdgeInsets.fromLTRB( + AppConstants.spacingLg, + 0, + AppConstants.spacingLg, + AppConstants.spacingMd, ), + child: _buildUpdateNotice(context), ), ), ), ), - if (isInitialLoading) - const SliverFillRemaining( - hasScrollBody: false, - child: _MenuLoadingState(), - ) - else ...[ - SliverToBoxAdapter( - child: AnimatedSize( - duration: const Duration(milliseconds: 400), - curve: Curves.easeOutCubic, - child: - !appPreferences.welcomeCardDismissed - ? FadeTransition( - opacity: _welcomeCardFade, - child: SlideTransition( - position: _welcomeCardSlide, - child: Padding( - padding: const EdgeInsets.fromLTRB( - AppConstants.spacingLg, - 0, - AppConstants.spacingLg, - AppConstants.spacingMd, - ), - child: _buildWelcomeCard(context, ref), + ), + if (isInitialLoading) + const SliverFillRemaining( + hasScrollBody: false, + child: _MenuLoadingState(), + ) + else ...[ + SliverToBoxAdapter( + child: AnimatedSize( + duration: const Duration(milliseconds: 400), + curve: Curves.easeOutCubic, + child: + !appPreferences.welcomeCardDismissed + ? FadeTransition( + opacity: _welcomeCardFade, + child: SlideTransition( + position: _welcomeCardSlide, + child: Padding( + padding: const EdgeInsets.fromLTRB( + AppConstants.spacingLg, + 0, + AppConstants.spacingLg, + AppConstants.spacingMd, ), + child: _buildWelcomeCard(context, ref), ), - ) - : const SizedBox.shrink(), + ), + ) + : const SizedBox.shrink(), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB( + AppConstants.spacingLg, + 0, + AppConstants.spacingLg, + AppConstants.spacingMd, + ), + child: _buildHeroCard( + context, + currentSong: currentSong, + favoriteSongs: favoriteSongs, + recentEntries: _recentEntries, + homeData: homeData, ), ), + ), + if (appPreferences.showQuickAccess) SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB( AppConstants.spacingLg, 0, AppConstants.spacingLg, - AppConstants.spacingMd, + AppConstants.spacingLg, ), - child: _buildHeroCard( + child: _buildQuickAccessGrid( context, - currentSong: currentSong, - favoriteSongs: favoriteSongs, - recentEntries: _recentEntries, homeData: homeData, + favoritesCount: favoriteSongs.length, + playlistCount: playlists.length, ), ), ), - if (appPreferences.showQuickAccess) - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB( - AppConstants.spacingLg, - 0, - AppConstants.spacingLg, - AppConstants.spacingLg, - ), - child: _buildQuickAccessGrid( - context, - homeData: homeData, - favoritesCount: favoriteSongs.length, - playlistCount: playlists.length, + if (appPreferences.showSmartMixes && + homeData.smartMixes.isNotEmpty) + SliverToBoxAdapter( + child: _buildSection( + context, + title: l10n.madeForYou, + subtitle: + l10n.generatedFromYourListeningHabitsFavorites, + child: SizedBox( + height: 222, + child: ListView.separated( + padding: const EdgeInsets.symmetric( + horizontal: AppConstants.spacingLg, + ), + scrollDirection: Axis.horizontal, + itemCount: homeData.smartMixes.length, + separatorBuilder: (_, _) => const SizedBox( + width: AppConstants.spacingMd, + ), + itemBuilder: (context, index) { + final mix = homeData.smartMixes[index]; + return _SmartMixCard( + mix: mix, + onTap: mix.songs.isEmpty + ? null + : () { + _navigateTo( + context, + SmartMixDetailScreen( + title: mix.title, + description: mix.description, + icon: mix.icon, + brandColors: mix.colors, + songs: mix.songs, + ), + ); + }, + ); + }, ), ), ), - if (appPreferences.showSmartMixes && - homeData.smartMixes.isNotEmpty) - SliverToBoxAdapter( - child: _buildSection( - context, - title: l10n.madeForYou, - subtitle: - l10n.generatedFromYourListeningHabitsFavorites, - child: SizedBox( - height: 222, - child: ListView.separated( - padding: const EdgeInsets.symmetric( - horizontal: AppConstants.spacingLg, - ), - scrollDirection: Axis.horizontal, - itemCount: homeData.smartMixes.length, - separatorBuilder: (_, _) => const SizedBox( - width: AppConstants.spacingMd, - ), - itemBuilder: (context, index) { - final mix = homeData.smartMixes[index]; - return _SmartMixCard( - mix: mix, - onTap: mix.songs.isEmpty - ? null - : () { - _navigateTo( - context, - SmartMixDetailScreen( - title: mix.title, - description: mix.description, - icon: mix.icon, - brandColors: mix.colors, - songs: mix.songs, - ), - ); - }, - ); - }, + ), + if (appPreferences.showRecentArtists && + homeData.recentArtists.isNotEmpty) + SliverToBoxAdapter( + child: _buildSection( + context, + title: l10n.artistsInRotation, + subtitle: + l10n.theNamesShowingUpAgainAnd, + child: SizedBox( + height: 172, + child: ListView.separated( + padding: const EdgeInsets.symmetric( + horizontal: AppConstants.spacingLg, ), + scrollDirection: Axis.horizontal, + itemCount: homeData.recentArtists.length, + separatorBuilder: (_, _) => const SizedBox( + width: AppConstants.spacingMd, + ), + itemBuilder: (context, index) { + final artist = homeData.recentArtists[index]; + return _ArtistShelfCard( + artist: artist, + onTap: () { + _navigateTo( + context, + ArtistDetailScreen( + artistName: artist.name, + songs: artist.songs, + artistArt: artist.artPath, + artistArtSourcePath: artist + .songs + .firstOrNull + ?.filePath, + playerService: _playerService, + ), + ); + }, + ); + }, ), ), ), - if (appPreferences.showRecentArtists && - homeData.recentArtists.isNotEmpty) - SliverToBoxAdapter( - child: _buildSection( - context, - title: l10n.artistsInRotation, - subtitle: - l10n.theNamesShowingUpAgainAnd, - child: SizedBox( - height: 172, - child: ListView.separated( - padding: const EdgeInsets.symmetric( - horizontal: AppConstants.spacingLg, - ), - scrollDirection: Axis.horizontal, - itemCount: homeData.recentArtists.length, - separatorBuilder: (_, _) => const SizedBox( - width: AppConstants.spacingMd, - ), - itemBuilder: (context, index) { - final artist = homeData.recentArtists[index]; - return _ArtistShelfCard( - artist: artist, - onTap: () { - _navigateTo( - context, - ArtistDetailScreen( - artistName: artist.name, - songs: artist.songs, - artistArt: artist.artPath, - artistArtSourcePath: artist - .songs - .firstOrNull - ?.filePath, - playerService: _playerService, - ), - ); - }, - ); - }, - ), + ), + if (appPreferences.showRecentTracks && + homeData.recentTracks.isNotEmpty) + SliverToBoxAdapter( + child: _buildSection( + context, + title: l10n.recentlyPlayed, + subtitle: + l10n.pickUpExactlyWhereYourLast, + trailing: TextButton( + onPressed: () => _navigateTo( + context, + const RecentlyPlayedScreen(), ), + child: Text(l10n.seeAll), ), - ), - if (appPreferences.showRecentTracks && - homeData.recentTracks.isNotEmpty) - SliverToBoxAdapter( - child: _buildSection( - context, - title: l10n.recentlyPlayed, - subtitle: - l10n.pickUpExactlyWhereYourLast, - trailing: TextButton( - onPressed: () => _navigateTo( - context, - const RecentlyPlayedScreen(), + child: SizedBox( + height: 212, + child: ListView.separated( + padding: const EdgeInsets.symmetric( + horizontal: AppConstants.spacingLg, ), - child: Text(l10n.seeAll), - ), - child: SizedBox( - height: 212, - child: ListView.separated( - padding: const EdgeInsets.symmetric( - horizontal: AppConstants.spacingLg, - ), - scrollDirection: Axis.horizontal, - itemCount: homeData.recentTracks.length, - separatorBuilder: (_, _) => const SizedBox( - width: AppConstants.spacingMd, - ), - itemBuilder: (context, index) { - final song = homeData.recentTracks[index]; - return _RecentTrackCard( - song: song, - onTap: () => _playSongs( - context, - homeData.recentTracks, - initialSong: song, - heroSeed: 'menu_recent', - ), - ); - }, + scrollDirection: Axis.horizontal, + itemCount: homeData.recentTracks.length, + separatorBuilder: (_, _) => const SizedBox( + width: AppConstants.spacingMd, ), + itemBuilder: (context, index) { + final song = homeData.recentTracks[index]; + return _RecentTrackCard( + song: song, + onTap: () => _playSongs( + context, + homeData.recentTracks, + initialSong: song, + heroSeed: 'menu_recent', + ), + ); + }, ), ), ), - if (appPreferences.showPlaylistPreviews) - SliverToBoxAdapter( - child: _buildSection( - context, - title: l10n.yourPlaylists, - subtitle: playlists.isEmpty - ? l10n.createYourFirstPlaylistOrJump - : l10n.savedPlaylistsAndQuickJumpsInto, - trailing: TextButton( - onPressed: () => - _navigateTo(context, const PlaylistsScreen()), - child: Text( - playlists.isEmpty ? l10n.create : l10n.seeAll, - ), + ), + if (appPreferences.showPlaylistPreviews) + SliverToBoxAdapter( + child: _buildSection( + context, + title: l10n.yourPlaylists, + subtitle: playlists.isEmpty + ? l10n.createYourFirstPlaylistOrJump + : l10n.savedPlaylistsAndQuickJumpsInto, + trailing: TextButton( + onPressed: () => + _navigateTo(context, const PlaylistsScreen()), + child: Text( + playlists.isEmpty ? l10n.create : l10n.seeAll, ), - child: playlists.isEmpty - ? Padding( + ), + child: playlists.isEmpty + ? Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppConstants.spacingLg, + ), + child: _EmptyShelfCard( + title: l10n.noPlaylistsYet, + subtitle: + l10n.createAPlaylistImportAnM3u, + icon: LucideIcons.listMusic, + onTap: () => _navigateTo( + context, + const PlaylistsScreen(), + ), + ), + ) + : SizedBox( + height: 280, + child: ListView.separated( padding: const EdgeInsets.symmetric( horizontal: AppConstants.spacingLg, ), - child: _EmptyShelfCard( - title: l10n.noPlaylistsYet, - subtitle: - l10n.createAPlaylistImportAnM3u, - icon: LucideIcons.listMusic, - onTap: () => _navigateTo( - context, - const PlaylistsScreen(), - ), - ), - ) - : SizedBox( - height: 280, - child: ListView.separated( - padding: const EdgeInsets.symmetric( - horizontal: AppConstants.spacingLg, - ), - scrollDirection: Axis.horizontal, - itemCount: - homeData.playlistPreviews.length, - separatorBuilder: (_, _) => - const SizedBox( - width: AppConstants.spacingMd, - ), - itemBuilder: (context, index) { - final preview = - homeData.playlistPreviews[index]; - return _PlaylistPreviewCard( - preview: preview, - onTap: () => _navigateTo( - context, - PlaylistDetailScreen( - playlist: preview.playlist, - ), + scrollDirection: Axis.horizontal, + itemCount: + homeData.playlistPreviews.length, + separatorBuilder: (_, _) => + const SizedBox( + width: AppConstants.spacingMd, + ), + itemBuilder: (context, index) { + final preview = + homeData.playlistPreviews[index]; + return _PlaylistPreviewCard( + preview: preview, + onTap: () => _navigateTo( + context, + PlaylistDetailScreen( + playlist: preview.playlist, ), - ); - }, - ), + ), + ); + }, ), - ), - ), - const SliverToBoxAdapter( - child: SizedBox( - height: AppConstants.navBarHeight + 136, + ), ), ), - ], + const SliverToBoxAdapter( + child: SizedBox( + height: AppConstants.navBarHeight + 136, + ), + ), ], - ), + ], ), ), - ), - ], - ), + ), + ), + ], ), ); } diff --git a/lib/features/menu/screens/smart_mix_detail_screen.dart b/lib/features/menu/screens/smart_mix_detail_screen.dart index 3b977124..823b9337 100644 --- a/lib/features/menu/screens/smart_mix_detail_screen.dart +++ b/lib/features/menu/screens/smart_mix_detail_screen.dart @@ -23,7 +23,6 @@ import 'package:flick/features/player/widgets/add_to_playlist_sheet.dart'; import 'package:flick/features/player/widgets/sleep_timer_bottom_sheet.dart'; import 'package:flick/features/songs/widgets/song_actions_button.dart'; import 'package:flick/providers/favorites_provider.dart'; -import 'package:flick/widgets/common/display_mode_wrapper.dart'; import 'package:flick/l10n/l10n.dart'; /// Detail screen for generated smart mixes (On Repeat, Heavy Rotation, etc.), @@ -288,90 +287,88 @@ class _SmartMixDetailScreenState extends ConsumerState Widget build(BuildContext context) { final bgColor = _tintBackground(_backgroundBlend); - return DisplayModeWrapper( - child: TweenAnimationBuilder( - tween: ColorTween(begin: AppColors.background, end: bgColor), - duration: AppConstants.animationSlow, - curve: Curves.easeOut, - builder: (context, animatedBg, _) { - final resolvedBg = animatedBg ?? AppColors.background; - final prefs = ref.watch(appPreferencesProvider); - return Stack( - children: [ - Scaffold( + return TweenAnimationBuilder( + tween: ColorTween(begin: AppColors.background, end: bgColor), + duration: AppConstants.animationSlow, + curve: Curves.easeOut, + builder: (context, animatedBg, _) { + final resolvedBg = animatedBg ?? AppColors.background; + final prefs = ref.watch(appPreferencesProvider); + return Stack( + children: [ + Scaffold( + backgroundColor: resolvedBg, + body: AdaptiveColorProvider( backgroundColor: resolvedBg, - body: AdaptiveColorProvider( - backgroundColor: resolvedBg, - albumDominantColor: _mixColor, - child: NotificationListener( - onNotification: onScrollNotification, - child: CustomScrollView( - controller: _scrollController, - slivers: [ - DetailHeader( - expanded: prefs.detailHeaderArtExpanded, - centeredTitle: prefs.detailHeaderCenteredTitle, - artBuilder: _buildArtLayer, - title: widget.title, - subtitle: widget.description, - subtitleMaxLines: 2, - meta: _metaLine, - lossless: _hasLossless, - fadeTo: resolvedBg, - onBack: () => Navigator.of(context).pop(), - ), - const SliverToBoxAdapter( - child: SizedBox(height: AppConstants.spacingMd), - ), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: AppConstants.spacingLg, - vertical: AppConstants.spacingXxs, - ), - child: DetailActionCapsule( - isFavorite: _isAllFavorites, - onToggleFavorite: _toggleFavoriteAll, - onShuffle: _shuffleAll, - onPlay: _playAll, - onQueue: _queueAll, - onMore: _showMore, - primaryColor: _mixColor, - ), + albumDominantColor: _mixColor, + child: NotificationListener( + onNotification: onScrollNotification, + child: CustomScrollView( + controller: _scrollController, + slivers: [ + DetailHeader( + expanded: prefs.detailHeaderArtExpanded, + centeredTitle: prefs.detailHeaderCenteredTitle, + artBuilder: _buildArtLayer, + title: widget.title, + subtitle: widget.description, + subtitleMaxLines: 2, + meta: _metaLine, + lossless: _hasLossless, + fadeTo: resolvedBg, + onBack: () => Navigator.of(context).pop(), + ), + const SliverToBoxAdapter( + child: SizedBox(height: AppConstants.spacingMd), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppConstants.spacingLg, + vertical: AppConstants.spacingXxs, + ), + child: DetailActionCapsule( + isFavorite: _isAllFavorites, + onToggleFavorite: _toggleFavoriteAll, + onShuffle: _shuffleAll, + onPlay: _playAll, + onQueue: _queueAll, + onMore: _showMore, + primaryColor: _mixColor, ), ), - const SliverToBoxAdapter( - child: SizedBox(height: AppConstants.spacingLg), + ), + const SliverToBoxAdapter( + child: SizedBox(height: AppConstants.spacingLg), + ), + _buildSectionTitle(context, l10n.songs14), + SliverPadding( + padding: EdgeInsets.only( + bottom: AppConstants.navBarHeight + 80, ), - _buildSectionTitle(context, l10n.songs14), - SliverPadding( - padding: EdgeInsets.only( - bottom: AppConstants.navBarHeight + 80, - ), - sliver: SliverList( - delegate: SliverChildBuilderDelegate(( - context, - index, - ) { - final song = widget.songs[index]; - return _SongTile( - song: song, - index: index, - onTap: () => _playSong(song), - ); - }, childCount: widget.songs.length), - ), + sliver: SliverList( + delegate: SliverChildBuilderDelegate(( + context, + index, + ) { + final song = widget.songs[index]; + return _SongTile( + song: song, + index: index, + onTap: () => _playSong(song), + ); + }, childCount: widget.songs.length), ), - ], - ), + ), + ], ), ), ), - _buildOverlayTopBar(context, resolvedBg), - ], - ); - }, - ), + ), + _buildOverlayTopBar(context, resolvedBg), + ], + ); + }, ); } diff --git a/lib/features/player/screens/full_player_screen.dart b/lib/features/player/screens/full_player_screen.dart index 18e6c904..fceb748d 100644 --- a/lib/features/player/screens/full_player_screen.dart +++ b/lib/features/player/screens/full_player_screen.dart @@ -30,7 +30,6 @@ import 'package:flick/services/lyrics_service.dart'; import 'package:flick/services/player_screen_mode_preference_service.dart'; import 'package:flick/widgets/common/animated_album_art.dart'; import 'package:flick/widgets/common/cached_image_widget.dart'; -import 'package:flick/widgets/common/display_mode_wrapper.dart'; import 'package:flick/widgets/common/flick_artwork_placeholder.dart'; import 'package:flick/widgets/uac2/iso_volume_popup.dart'; import 'package:flick/widgets/uac2/uac2_error_notification.dart'; @@ -870,92 +869,90 @@ class _FullPlayerScreenState extends ConsumerState if (didPop) return; _close(context); }, - child: DisplayModeWrapper( - child: Scaffold( - backgroundColor: AppColors.background, - body: ValueListenableBuilder( - valueListenable: _playerService.currentSongNotifier, - builder: (context, song, _) { - if (song == null) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (context.mounted) Navigator.of(context).pop(); - }); - return const SizedBox.shrink(); - } + child: Scaffold( + backgroundColor: AppColors.background, + body: ValueListenableBuilder( + valueListenable: _playerService.currentSongNotifier, + builder: (context, song, _) { + if (song == null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (context.mounted) Navigator.of(context).pop(); + }); + return const SizedBox.shrink(); + } - final isShortHeight = MediaQuery.sizeOf(context).height < 620.0; - final showImmersiveFullView = - _isImmersiveFullView && - _playerScreenMode == PlayerScreenMode.immersive && - !isShortHeight; - final showVisualizerOnly = - _isVisualizationMode && - appPrefs.visualizerEnabled && - showImmersiveFullView; + final isShortHeight = MediaQuery.sizeOf(context).height < 620.0; + final showImmersiveFullView = + _isImmersiveFullView && + _playerScreenMode == PlayerScreenMode.immersive && + !isShortHeight; + final showVisualizerOnly = + _isVisualizationMode && + appPrefs.visualizerEnabled && + showImmersiveFullView; - // Outer vertical-dismiss gesture (kept). Horizontal is now inside carousel. - return GestureDetector( - behavior: HitTestBehavior.translucent, - onTap: _handleImmersiveSceneTap, - onVerticalDragStart: (details) { - if (_isVinylRotationActive || _isCarouselDragging) return; - final screenHeight = MediaQuery.sizeOf(context).height; - if (details.globalPosition.dy >= - screenHeight - _backGestureEdgeWidth) { - _dragOffset = 0.0; - return; - } - _dragController.stop(); - }, - onVerticalDragUpdate: (details) { - if (_isVinylRotationActive || _isCarouselDragging) return; - if (details.delta.dy > 0) { - final now = DateTime.now(); - if (now.difference(_lastDragUpdate).inMilliseconds < 16) { - return; - } - _lastDragUpdate = now; - _dragOffset = (_dragOffset + details.delta.dy).clamp( - 0.0, - 1000.0, - ); - _dragController.value = _dragOffset; - } - }, - onVerticalDragEnd: (details) { - if (_isVinylRotationActive || _isCarouselDragging) return; - if (_dragOffset > 100 || details.primaryVelocity! > 500) { - _close(context); + // Outer vertical-dismiss gesture (kept). Horizontal is now inside carousel. + return GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: _handleImmersiveSceneTap, + onVerticalDragStart: (details) { + if (_isVinylRotationActive || _isCarouselDragging) return; + final screenHeight = MediaQuery.sizeOf(context).height; + if (details.globalPosition.dy >= + screenHeight - _backGestureEdgeWidth) { + _dragOffset = 0.0; + return; + } + _dragController.stop(); + }, + onVerticalDragUpdate: (details) { + if (_isVinylRotationActive || _isCarouselDragging) return; + if (details.delta.dy > 0) { + final now = DateTime.now(); + if (now.difference(_lastDragUpdate).inMilliseconds < 16) { return; } - _dragOffset = 0.0; - _dragController.animateTo(0.0); + _lastDragUpdate = now; + _dragOffset = (_dragOffset + details.delta.dy).clamp( + 0.0, + 1000.0, + ); + _dragController.value = _dragOffset; + } + }, + onVerticalDragEnd: (details) { + if (_isVinylRotationActive || _isCarouselDragging) return; + if (_dragOffset > 100 || details.primaryVelocity! > 500) { + _close(context); + return; + } + _dragOffset = 0.0; + _dragController.animateTo(0.0); + }, + child: AnimatedBuilder( + animation: _dragController, + builder: (context, child) { + final offset = _dragController.value * 0.5; + return Transform.translate( + offset: Offset(0, offset), + child: child!, + ); }, - child: AnimatedBuilder( - animation: _dragController, - builder: (context, child) { - final offset = _dragController.value * 0.5; - return Transform.translate( - offset: Offset(0, offset), - child: child!, - ); - }, - child: _buildPlayerBody( - context, - song, - appPrefs, - colorMode, - albumColor, - visStyle, - visFreq, - visMove, - showImmersiveFullView, - showVisualizerOnly, - ), + child: _buildPlayerBody( + context, + song, + appPrefs, + colorMode, + albumColor, + visStyle, + visFreq, + visMove, + showImmersiveFullView, + showVisualizerOnly, ), - ); - }, - ), + ), + ); + }, ), ), ); diff --git a/lib/features/recap/screens/listening_recap_screen.dart b/lib/features/recap/screens/listening_recap_screen.dart index ab280b94..e459f052 100644 --- a/lib/features/recap/screens/listening_recap_screen.dart +++ b/lib/features/recap/screens/listening_recap_screen.dart @@ -5,7 +5,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../data/repositories/recently_played_repository.dart'; import '../../../providers/app_preferences_provider.dart'; -import '../../../widgets/common/display_mode_wrapper.dart'; import '../../../widgets/common/flick_artwork_placeholder.dart'; import '../widgets/replay_card_options.dart'; import '../widgets/replay_poster.dart'; @@ -163,29 +162,27 @@ class _ListeningRecapScreenState extends ConsumerState final animations = ref.watch( appPreferencesProvider.select((p) => p.animationsEnabled), ); - return DisplayModeWrapper( - child: MediaQuery( - data: MediaQuery.of(context).copyWith( - disableAnimations: - MediaQuery.disableAnimationsOf(context) || !animations, - ), - child: TickerMode( - enabled: _active && !_editing, - child: Scaffold( - backgroundColor: Colors.transparent, - body: SafeArea( - bottom: false, - child: Column( - children: [ - _header(), - ReplayPeriodPicker( - selected: _period, - onSelected: _selectPeriod, - ), - const SizedBox(height: 4), - Expanded(child: Builder(builder: _body)), - ], - ), + return MediaQuery( + data: MediaQuery.of(context).copyWith( + disableAnimations: + MediaQuery.disableAnimationsOf(context) || !animations, + ), + child: TickerMode( + enabled: _active && !_editing, + child: Scaffold( + backgroundColor: Colors.transparent, + body: SafeArea( + bottom: false, + child: Column( + children: [ + _header(), + ReplayPeriodPicker( + selected: _period, + onSelected: _selectPeriod, + ), + const SizedBox(height: 4), + Expanded(child: Builder(builder: _body)), + ], ), ), ), diff --git a/lib/features/recap/screens/replay_studio_screen.dart b/lib/features/recap/screens/replay_studio_screen.dart index 1d20d809..2416cff4 100644 --- a/lib/features/recap/screens/replay_studio_screen.dart +++ b/lib/features/recap/screens/replay_studio_screen.dart @@ -16,7 +16,6 @@ import '../../../providers/app_preferences_provider.dart'; import '../../../services/color_extraction_service.dart'; import '../../../services/csv_export_service.dart'; import '../../../services/gallery_save_service.dart'; -import '../../../widgets/common/display_mode_wrapper.dart'; import '../widgets/replay_card_options.dart'; import '../widgets/replay_poster.dart'; import '../widgets/replay_style.dart'; @@ -276,14 +275,12 @@ class _ReplayStudioScreenState extends ConsumerState { final animations = ref.watch( appPreferencesProvider.select((p) => p.animationsEnabled), ); - return DisplayModeWrapper( - child: MediaQuery( - data: MediaQuery.of(context).copyWith( - disableAnimations: - MediaQuery.disableAnimationsOf(context) || !animations, - ), - child: Builder(builder: _studio), + return MediaQuery( + data: MediaQuery.of(context).copyWith( + disableAnimations: + MediaQuery.disableAnimationsOf(context) || !animations, ), + child: Builder(builder: _studio), ); } diff --git a/lib/features/recently_added/screens/recently_added_screen.dart b/lib/features/recently_added/screens/recently_added_screen.dart index 8330daea..94e8655a 100644 --- a/lib/features/recently_added/screens/recently_added_screen.dart +++ b/lib/features/recently_added/screens/recently_added_screen.dart @@ -12,7 +12,6 @@ import 'package:flick/models/song.dart'; import 'package:flick/services/player_service.dart'; import 'package:flick/data/repositories/song_repository.dart'; import 'package:flick/widgets/common/cached_image_widget.dart'; -import 'package:flick/widgets/common/display_mode_wrapper.dart'; import 'package:flick/widgets/common/flick_artwork_placeholder.dart'; import 'package:flick/widgets/common/surface_icon_button.dart'; import 'package:flick/l10n/l10n.dart'; @@ -194,25 +193,23 @@ class _RecentlyAddedScreenState extends State { @override Widget build(BuildContext context) { - return DisplayModeWrapper( - child: Scaffold( - backgroundColor: Colors.transparent, - body: BlurredSongBackground( - child: SafeArea( - bottom: false, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildHeader(context), - Expanded( - child: _isLoading - ? _buildLoadingState() - : _groupedSongs.isEmpty - ? _buildEmptyState() - : _buildList(), - ), - ], - ), + return Scaffold( + backgroundColor: Colors.transparent, + body: BlurredSongBackground( + child: SafeArea( + bottom: false, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildHeader(context), + Expanded( + child: _isLoading + ? _buildLoadingState() + : _groupedSongs.isEmpty + ? _buildEmptyState() + : _buildList(), + ), + ], ), ), ), diff --git a/lib/features/recently_played/screens/recently_played_screen.dart b/lib/features/recently_played/screens/recently_played_screen.dart index 625a72e5..24334102 100644 --- a/lib/features/recently_played/screens/recently_played_screen.dart +++ b/lib/features/recently_played/screens/recently_played_screen.dart @@ -13,7 +13,6 @@ import 'package:flick/models/song.dart'; import 'package:flick/services/player_service.dart'; import 'package:flick/data/repositories/recently_played_repository.dart'; import 'package:flick/widgets/common/cached_image_widget.dart'; -import 'package:flick/widgets/common/display_mode_wrapper.dart'; import 'package:flick/widgets/common/flick_artwork_placeholder.dart'; import 'package:flick/widgets/common/flick_dialog.dart'; import 'package:flick/widgets/common/surface_icon_button.dart'; @@ -214,25 +213,23 @@ class _RecentlyPlayedScreenState extends State { @override Widget build(BuildContext context) { - return DisplayModeWrapper( - child: Scaffold( - backgroundColor: Colors.transparent, - body: BlurredSongBackground( - child: SafeArea( - bottom: false, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildHeader(context), - Expanded( - child: _isLoading - ? _buildLoadingState() - : _groupedHistory.isEmpty - ? _buildEmptyState() - : _buildHistoryList(), - ), - ], - ), + return Scaffold( + backgroundColor: Colors.transparent, + body: BlurredSongBackground( + child: SafeArea( + bottom: false, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildHeader(context), + Expanded( + child: _isLoading + ? _buildLoadingState() + : _groupedHistory.isEmpty + ? _buildEmptyState() + : _buildHistoryList(), + ), + ], ), ), ), diff --git a/lib/features/settings/screens/logs_screen.dart b/lib/features/settings/screens/logs_screen.dart index ff9fb78f..95ac4518 100644 --- a/lib/features/settings/screens/logs_screen.dart +++ b/lib/features/settings/screens/logs_screen.dart @@ -18,7 +18,6 @@ import 'package:flick/core/theme/app_theme.dart'; import 'package:flick/core/theme/adaptive_color_provider.dart'; import 'package:flick/core/utils/app_haptics.dart'; import 'package:flick/core/utils/app_log.dart'; -import 'package:flick/widgets/common/display_mode_wrapper.dart'; import 'package:flick/l10n/l10n.dart'; class LogsScreen extends ConsumerStatefulWidget { @@ -242,19 +241,17 @@ class _LogsScreenState extends ConsumerState { @override Widget build(BuildContext context) { - return DisplayModeWrapper( - child: Scaffold( - backgroundColor: AppColors.background, - body: SafeArea( - bottom: false, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildHeader(context), - _buildControls(context), - Expanded(child: _buildList(context)), - ], - ), + return Scaffold( + backgroundColor: AppColors.background, + body: SafeArea( + bottom: false, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildHeader(context), + _buildControls(context), + Expanded(child: _buildList(context)), + ], ), ), ); diff --git a/lib/features/settings/screens/uac2_preferences_screen.dart b/lib/features/settings/screens/uac2_preferences_screen.dart index 571e134e..5e791804 100644 --- a/lib/features/settings/screens/uac2_preferences_screen.dart +++ b/lib/features/settings/screens/uac2_preferences_screen.dart @@ -17,7 +17,6 @@ import 'package:flick/services/uac2_service.dart'; import 'package:flick/services/player_service.dart'; import 'package:flick/src/rust/api/audio_api.dart' as rust_audio; import 'package:flick/src/rust/audio/engine.dart' show AudioApiPreference; -import 'package:flick/widgets/common/display_mode_wrapper.dart'; import 'package:flick/widgets/common/engine_restart_notice.dart'; import 'package:flick/widgets/uac2/uac2_volume_control.dart'; import 'package:flick/features/settings/screens/logs_screen.dart'; @@ -54,94 +53,92 @@ class _Uac2PreferencesScreenState extends ConsumerState { final dsdOutputModeAsync = ref.watch(dsdOutputModeProvider); final currentSong = ref.watch(currentSongProvider); - return DisplayModeWrapper( - child: Scaffold( - backgroundColor: Colors.transparent, - body: Stack( - children: [ - Container( - decoration: const BoxDecoration( - gradient: AppColors.backgroundGradient, - ), - ), - Positioned.fill( - child: AmbientBackground(song: currentSong), + return Scaffold( + backgroundColor: Colors.transparent, + body: Stack( + children: [ + Container( + decoration: const BoxDecoration( + gradient: AppColors.backgroundGradient, ), - SafeArea( - bottom: false, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildHeader(context), - const SizedBox(height: AppConstants.spacingMd), - Expanded( - child: SingleChildScrollView( - padding: const EdgeInsets.symmetric( - horizontal: AppConstants.spacingMd, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (_pendingEngineRestart) ...[ - const EngineRestartNotice(), - const SizedBox(height: AppConstants.spacingLg), - ], - _buildSectionHeader(context, l10n.audioFormat), - _buildFormatPreferences( - context, - preferencesService, - formatPrefAsync, - preferredFormatAsync, - audioFormatAsync, - ), + ), + Positioned.fill( + child: AmbientBackground(song: currentSong), + ), + SafeArea( + bottom: false, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildHeader(context), + const SizedBox(height: AppConstants.spacingMd), + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric( + horizontal: AppConstants.spacingMd, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (_pendingEngineRestart) ...[ + const EngineRestartNotice(), const SizedBox(height: AppConstants.spacingLg), - _buildSectionHeader(context, l10n.experimental), - _buildExperimentalWarning(context), - _buildDsdOptions( - context, - preferencesService, - dsdOutputModeAsync, - ), - const SizedBox(height: AppConstants.spacingSm), - _build432HzTuningTile( - context, - preferencesService, - tuning432HzAsync, - ), + ], + _buildSectionHeader(context, l10n.audioFormat), + _buildFormatPreferences( + context, + preferencesService, + formatPrefAsync, + preferredFormatAsync, + audioFormatAsync, + ), + const SizedBox(height: AppConstants.spacingLg), + _buildSectionHeader(context, l10n.experimental), + _buildExperimentalWarning(context), + _buildDsdOptions( + context, + preferencesService, + dsdOutputModeAsync, + ), + const SizedBox(height: AppConstants.spacingSm), + _build432HzTuningTile( + context, + preferencesService, + tuning432HzAsync, + ), + const SizedBox(height: AppConstants.spacingLg), + _buildSectionHeader(context, l10n.advanced), + _buildAdvancedOptions( + context, + preferencesService, + audioEngineAsync, + androidAudioApiAsync, + developerModeAsync, + bitPerfectAsync, + dapBitPerfectAsync, + killIsochronousUsbOnQuitAsync, + autoEngageUsbDacAsync, + declinedUsbDevicesAsync, + diagnostics, + ), + if (audioEngineAsync.when( + data: (e) => e == AudioEnginePreference.isochronousUsb, + loading: () => false, + error: (_, _) => false, + )) ...[ const SizedBox(height: AppConstants.spacingLg), - _buildSectionHeader(context, l10n.advanced), - _buildAdvancedOptions( - context, - preferencesService, - audioEngineAsync, - androidAudioApiAsync, - developerModeAsync, - bitPerfectAsync, - dapBitPerfectAsync, - killIsochronousUsbOnQuitAsync, - autoEngageUsbDacAsync, - declinedUsbDevicesAsync, - diagnostics, - ), - if (audioEngineAsync.when( - data: (e) => e == AudioEnginePreference.isochronousUsb, - loading: () => false, - error: (_, _) => false, - )) ...[ - const SizedBox(height: AppConstants.spacingLg), - _buildSectionHeader(context, l10n.volume), - const Uac2VolumeControl(), - ], - const SizedBox(height: AppConstants.navBarHeight + 120), + _buildSectionHeader(context, l10n.volume), + const Uac2VolumeControl(), ], - ), + const SizedBox(height: AppConstants.navBarHeight + 120), + ], ), ), - ], - ), + ), + ], ), - ], - ), + ), + ], ), ); } diff --git a/lib/features/settings/screens/uac2_settings_screen.dart b/lib/features/settings/screens/uac2_settings_screen.dart index 9a398a81..80976766 100644 --- a/lib/features/settings/screens/uac2_settings_screen.dart +++ b/lib/features/settings/screens/uac2_settings_screen.dart @@ -8,7 +8,6 @@ import 'package:flick/core/utils/responsive.dart'; import 'package:flick/models/audio_output_diagnostics.dart'; import 'package:flick/providers/providers.dart'; import 'package:flick/services/uac2_service.dart'; -import 'package:flick/widgets/common/display_mode_wrapper.dart'; import 'package:flick/features/settings/screens/uac2_preferences_screen.dart'; import 'package:flick/widgets/uac2/uac2_volume_control.dart'; import 'package:flick/widgets/uac2/uac2_hotplug_monitor.dart'; @@ -33,81 +32,79 @@ class _Uac2SettingsScreenState extends ConsumerState { final currentSong = ref.watch(currentSongProvider); final appPreferences = ref.watch(appPreferencesProvider); - return DisplayModeWrapper( - child: Scaffold( - backgroundColor: Colors.transparent, - body: Stack( - children: [ - Container( - decoration: const BoxDecoration( - gradient: AppColors.backgroundGradient, - ), - ), - Positioned.fill( - child: AmbientBackground(song: currentSong), + return Scaffold( + backgroundColor: Colors.transparent, + body: Stack( + children: [ + Container( + decoration: const BoxDecoration( + gradient: AppColors.backgroundGradient, ), - SafeArea( - bottom: false, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildHeader(context), - const SizedBox(height: AppConstants.spacingMd), - Expanded( - child: SingleChildScrollView( - padding: const EdgeInsets.symmetric( - horizontal: AppConstants.spacingMd, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (!isAvailable) _buildUnavailableCard(context), - if (isAvailable) ...[ - const Uac2HotplugMonitor(), - _buildSectionHeader(context, l10n.usbAudioDevices), - devicesAsync.when( - data: (devices) => _buildDevicesList( - context, - devices, - selectedDevice, - deviceStatus, - ), - loading: () => _buildLoadingCard(context), - error: (error, _) => _buildErrorCard(context, error), + ), + Positioned.fill( + child: AmbientBackground(song: currentSong), + ), + SafeArea( + bottom: false, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildHeader(context), + const SizedBox(height: AppConstants.spacingMd), + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric( + horizontal: AppConstants.spacingMd, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (!isAvailable) _buildUnavailableCard(context), + if (isAvailable) ...[ + const Uac2HotplugMonitor(), + _buildSectionHeader(context, l10n.usbAudioDevices), + devicesAsync.when( + data: (devices) => _buildDevicesList( + context, + devices, + selectedDevice, + deviceStatus, ), - if (selectedDevice != null) ...[ - const SizedBox(height: AppConstants.spacingLg), - _buildSectionHeader(context, l10n.deviceInformation), - _buildDeviceInfoCard(context, selectedDevice), - const SizedBox(height: AppConstants.spacingLg), - _buildSectionHeader(context, l10n.capabilities), - _buildCapabilitiesCard(context, selectedDevice), - ], - if (deviceStatus != null) ...[ - const SizedBox(height: AppConstants.spacingLg), - _buildSectionHeader(context, l10n.status), - _buildStatusCard(context, deviceStatus), - ], - if (deviceStatus != null && - deviceStatus.state != Uac2State.idle && - deviceStatus.hasVolumeControl && - appPreferences.showUsbVolumeOnSettings) ...[ - const SizedBox(height: AppConstants.spacingLg), - _buildSectionHeader(context, l10n.volumeControl), - const Uac2VolumeControl(), - ], - + loading: () => _buildLoadingCard(context), + error: (error, _) => _buildErrorCard(context, error), + ), + if (selectedDevice != null) ...[ + const SizedBox(height: AppConstants.spacingLg), + _buildSectionHeader(context, l10n.deviceInformation), + _buildDeviceInfoCard(context, selectedDevice), + const SizedBox(height: AppConstants.spacingLg), + _buildSectionHeader(context, l10n.capabilities), + _buildCapabilitiesCard(context, selectedDevice), + ], + if (deviceStatus != null) ...[ + const SizedBox(height: AppConstants.spacingLg), + _buildSectionHeader(context, l10n.status), + _buildStatusCard(context, deviceStatus), + ], + if (deviceStatus != null && + deviceStatus.state != Uac2State.idle && + deviceStatus.hasVolumeControl && + appPreferences.showUsbVolumeOnSettings) ...[ + const SizedBox(height: AppConstants.spacingLg), + _buildSectionHeader(context, l10n.volumeControl), + const Uac2VolumeControl(), ], - const SizedBox(height: AppConstants.navBarHeight + 120), + ], - ), + const SizedBox(height: AppConstants.navBarHeight + 120), + ], ), ), - ], - ), + ), + ], ), - ], - ), + ), + ], ), ); } diff --git a/lib/features/songs/screens/songs_screen.dart b/lib/features/songs/screens/songs_screen.dart index 312cf0f5..4ecfa5d8 100644 --- a/lib/features/songs/screens/songs_screen.dart +++ b/lib/features/songs/screens/songs_screen.dart @@ -31,7 +31,6 @@ import 'package:flick/services/music_folder_service.dart'; import 'package:flick/services/player_service.dart'; import 'package:flick/models/nav_bar_config.dart'; import 'package:flick/widgets/common/glass_search_bar.dart'; -import 'package:flick/widgets/common/display_mode_wrapper.dart'; import 'package:flick/widgets/common/cached_image_widget.dart'; import 'package:flick/widgets/common/flick_artwork_placeholder.dart'; import 'package:flick/widgets/common/glass_bottom_sheet.dart'; @@ -146,208 +145,206 @@ class _SongsScreenState extends ConsumerState final shouldReserveOrbitBottomSpace = viewMode != SongViewMode.list && navBarVisible && !isAlbumMode; - return DisplayModeWrapper( - child: Stack( - clipBehavior: Clip.none, - children: [ - SafeArea( - bottom: false, - child: Column( - children: [ - // Header with sort option - AnimatedSwitcher( - duration: AppConstants.animationNormal, - switchInCurve: Curves.easeOutCubic, - switchOutCurve: Curves.easeInCubic, - transitionBuilder: (child, animation) => - FadeTransition(opacity: animation, child: child), - child: _selectionMode - ? KeyedSubtree( - key: const ValueKey('selection_header'), - child: _buildSelectionHeader(), - ) - : KeyedSubtree( - key: const ValueKey('normal_header'), - child: _buildHeader(songsAsync), - ), - ), + return Stack( + clipBehavior: Clip.none, + children: [ + SafeArea( + bottom: false, + child: Column( + children: [ + // Header with sort option + AnimatedSwitcher( + duration: AppConstants.animationNormal, + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeInCubic, + transitionBuilder: (child, animation) => + FadeTransition(opacity: animation, child: child), + child: _selectionMode + ? KeyedSubtree( + key: const ValueKey('selection_header'), + child: _buildSelectionHeader(), + ) + : KeyedSubtree( + key: const ValueKey('normal_header'), + child: _buildHeader(songsAsync), + ), + ), - // Search Bar (hidden when Search tab is in the nav bar) - if (!searchInNavBar) ...[ - Padding( - padding: const EdgeInsets.symmetric( - horizontal: AppConstants.spacingLg, - ), - child: TutorialTargetAnchor( - target: TutorialTarget.songsSearchBar, - child: Container( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - AppColors.surfaceLight.withValues(alpha: 0.75), - AppColors.surface.withValues(alpha: 0.85), - ], - ), - borderRadius: BorderRadius.circular( - AppConstants.radiusXl, - ), - border: Border.all( - color: AppColors.glassBorder, - width: 1, - ), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.2), - blurRadius: 16, - spreadRadius: 1, - offset: const Offset(0, 4), - ), + // Search Bar (hidden when Search tab is in the nav bar) + if (!searchInNavBar) ...[ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppConstants.spacingLg, + ), + child: TutorialTargetAnchor( + target: TutorialTarget.songsSearchBar, + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + AppColors.surfaceLight.withValues(alpha: 0.75), + AppColors.surface.withValues(alpha: 0.85), ], ), - child: GlassSearchBar( - controller: _searchController, - hintText: l10n.searchSongsArtists, - showBackground: false, - onChanged: (value) { - setState(() { - _searchQuery = value.toLowerCase(); - _selectedIndex = 0; - _lastSyncedSong = null; - }); - }, + borderRadius: BorderRadius.circular( + AppConstants.radiusXl, ), + border: Border.all( + color: AppColors.glassBorder, + width: 1, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.2), + blurRadius: 16, + spreadRadius: 1, + offset: const Offset(0, 4), + ), + ], + ), + child: GlassSearchBar( + controller: _searchController, + hintText: l10n.searchSongsArtists, + showBackground: false, + onChanged: (value) { + setState(() { + _searchQuery = value.toLowerCase(); + _selectedIndex = 0; + _lastSyncedSong = null; + }); + }, ), ), ), - const SizedBox(height: AppConstants.spacingMd), - ], + ), + const SizedBox(height: AppConstants.spacingMd), + ], - // Content based on async state - Expanded( - child: songsAsync.when( - loading: () => _buildLoadingState(), - error: (error, stack) => _buildErrorState(error), - data: (songsState) { - final isAlbumMode = - songsState.sortOption == SongSortOption.album; - final allSongs = songsState.sortedSongs; - var songs = allSongs; - if (_cachedSongs != songsState.songs) { - _cachedSongs = songsState.songs; - _lastSyncedSong = null; + // Content based on async state + Expanded( + child: songsAsync.when( + loading: () => _buildLoadingState(), + error: (error, stack) => _buildErrorState(error), + data: (songsState) { + final isAlbumMode = + songsState.sortOption == SongSortOption.album; + final allSongs = songsState.sortedSongs; + var songs = allSongs; + if (_cachedSongs != songsState.songs) { + _cachedSongs = songsState.songs; + _lastSyncedSong = null; + } + + if (_searchQuery.isNotEmpty) { + songs = songs.where((song) { + return song.title.toLowerCase().contains( + _searchQuery, + ) || + song.artist.toLowerCase().contains(_searchQuery); + }).toList(); + } + _cachedDisplaySongs = songs; + + if (!isAlbumMode) { + _wasInAlbumMode = false; + final sig = + '${songs.length}:${songs.isNotEmpty ? songs.first.id : ''}'; + if (sig != _listPaginationSignature) { + _listPaginationSignature = sig; + _resetListPagination(); } + } - if (_searchQuery.isNotEmpty) { - songs = songs.where((song) { - return song.title.toLowerCase().contains( - _searchQuery, - ) || - song.artist.toLowerCase().contains(_searchQuery); - }).toList(); - } - _cachedDisplaySongs = songs; - - if (!isAlbumMode) { - _wasInAlbumMode = false; - final sig = - '${songs.length}:${songs.isNotEmpty ? songs.first.id : ''}'; - if (sig != _listPaginationSignature) { - _listPaginationSignature = sig; - _resetListPagination(); - } - } + if (songs.isEmpty && _searchQuery.isEmpty) { + return _buildEmptyState(); + } - if (songs.isEmpty && _searchQuery.isEmpty) { - return _buildEmptyState(); - } + if (songs.isEmpty && _searchQuery.isNotEmpty) { + return _buildNoSearchResultsState(); + } - if (songs.isEmpty && _searchQuery.isNotEmpty) { - return _buildNoSearchResultsState(); + if (isAlbumMode) { + if (!_wasInAlbumMode) { + _wasInAlbumMode = true; + _visibleAlbumCount = _getAlbumPageSize(); + _albumPaginationSignature = ''; } - - if (isAlbumMode) { - if (!_wasInAlbumMode) { - _wasInAlbumMode = true; - _visibleAlbumCount = _getAlbumPageSize(); - _albumPaginationSignature = ''; - } - final albumGroups = songsState.albumGroups; - if (_searchQuery.isNotEmpty) { - final filteredGroups = []; - for (final group in albumGroups) { - final filtered = group.songs.where((song) { - return song.title.toLowerCase().contains( - _searchQuery, - ) || - song.artist.toLowerCase().contains( - _searchQuery, - ); - }).toList(); - if (filtered.isNotEmpty) { - filteredGroups.add( - AlbumGroup( - key: group.key, - albumName: group.albumName, - albumArtist: group.albumArtist, - songs: filtered, - ), - ); - } - } - if (filteredGroups.isEmpty) { - return _buildNoSearchResultsState(); + final albumGroups = songsState.albumGroups; + if (_searchQuery.isNotEmpty) { + final filteredGroups = []; + for (final group in albumGroups) { + final filtered = group.songs.where((song) { + return song.title.toLowerCase().contains( + _searchQuery, + ) || + song.artist.toLowerCase().contains( + _searchQuery, + ); + }).toList(); + if (filtered.isNotEmpty) { + filteredGroups.add( + AlbumGroup( + key: group.key, + albumName: group.albumName, + albumArtist: group.albumArtist, + songs: filtered, + ), + ); } - return _buildAlbumGridView(filteredGroups); } - if (albumGroups.isEmpty) { - return _buildEmptyState(); + if (filteredGroups.isEmpty) { + return _buildNoSearchResultsState(); } - return _buildAlbumGridView(albumGroups); + return _buildAlbumGridView(filteredGroups); } + if (albumGroups.isEmpty) { + return _buildEmptyState(); + } + return _buildAlbumGridView(albumGroups); + } - _alignCurrentSongAfterSongsLoad(songs); + _alignCurrentSongAfterSongsLoad(songs); - if (_selectedIndex >= songs.length) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - setState(() => _selectedIndex = 0); - } - }); - } + if (_selectedIndex >= songs.length) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + setState(() => _selectedIndex = 0); + } + }); + } - _syncSelectedTokenForIndex(songs, _selectedIndex); + _syncSelectedTokenForIndex(songs, _selectedIndex); - final tokenToIndexMap = _buildFastIndexMap(songs); - final appPrefs = ref.watch(appPreferencesProvider); + final tokenToIndexMap = _buildFastIndexMap(songs); + final appPrefs = ref.watch(appPreferencesProvider); - return _buildSongsView( - songs, - viewMode, - tokenToIndexMap, - navBarVisible, - swipeActionsEnabled, - appPrefs.fastIndexEnabled, - ); - }, - ), + return _buildSongsView( + songs, + viewMode, + tokenToIndexMap, + navBarVisible, + swipeActionsEnabled, + appPrefs.fastIndexEnabled, + ); + }, ), + ), - // Reserve space only when needed for orbit view. - // List view handles its own bottom padding. - AnimatedContainer( - duration: AppConstants.animationNormal, - curve: Curves.easeOutCubic, - height: shouldReserveOrbitBottomSpace - ? AppConstants.navBarHeight + AppConstants.spacingLg - : 0, - ), - ], - ), + // Reserve space only when needed for orbit view. + // List view handles its own bottom padding. + AnimatedContainer( + duration: AppConstants.animationNormal, + curve: Curves.easeOutCubic, + height: shouldReserveOrbitBottomSpace + ? AppConstants.navBarHeight + AppConstants.spacingLg + : 0, + ), + ], ), - ], - ), + ), + ], ); } diff --git a/lib/features/whats_new/data/changelog_data.dart b/lib/features/whats_new/data/changelog_data.dart index 25f9df02..024799bc 100644 --- a/lib/features/whats_new/data/changelog_data.dart +++ b/lib/features/whats_new/data/changelog_data.dart @@ -51,8 +51,11 @@ const List kChangelogEntries = [ title: 'Display Modes', bullets: [ '**New installs follow the system refresh rate by default** — a fresh installation starts on the Adaptive refresh-rate mode instead of forcing the highest rate. Existing installations keep the mode they already use; Settings → Interface → Refresh Rate still offers Adaptive, 60 Hz, and maximum.', + '**60 Hz now means an actual 60 Hz mode** — the Standard option picks the advertised mode closest to 60 Hz at the current resolution instead of the lowest rate (10 Hz on displays with low-power modes). If no ~60 Hz mode exists, Flick follows the system instead.', + '**Refresh-rate changes apply immediately** — the selected mode is applied app-wide when it changes in Settings, instead of waiting for the next screen to mount.', + '**Saved refresh rate is restored on reopening**: Flick waits for saved preferences and reapplies the selected mode if Android recreated its window. Background refresh-rate changes no longer trigger a false rejection.', '**Forced refresh rates are verified after they are applied** — if the system ignores or downgrades the request (seen on some Fairphone 6 firmware), Flick returns to the system default and stops re-forcing for the session.', - 'Refresh-rate requests are no longer re-sent on every screen mount and resume; redundant applies are skipped.', + 'Re-applying a mode already preferred by the current window is skipped.', ], ), ], diff --git a/lib/providers/app_preferences_provider.dart b/lib/providers/app_preferences_provider.dart index 76119904..baea1dd0 100644 --- a/lib/providers/app_preferences_provider.dart +++ b/lib/providers/app_preferences_provider.dart @@ -7,17 +7,17 @@ final appPreferencesServiceProvider = Provider((ref) { }); class AppPreferencesNotifier extends Notifier { - bool _initialized = false; + Future? _loadFuture; @override AppPreferences build() { - if (!_initialized) { - _initialized = true; - Future.microtask(_loadPreferences); - } + _loadFuture ??= Future.microtask(_loadPreferences); return const AppPreferences(); } + /// Completes once persisted preferences have replaced the startup defaults. + Future get loaded => _loadFuture!; + Future _loadPreferences() async { final preferences = await ref .read(appPreferencesServiceProvider) @@ -720,6 +720,8 @@ class AppPreferencesNotifier extends Notifier { } Future setRefreshRateMode(String value) async { + await loaded; + if (!ref.mounted) return; if (state.refreshRateMode == value) return; state = state.copyWith(refreshRateMode: value); await ref.read(appPreferencesServiceProvider).setRefreshRateMode(value); diff --git a/lib/services/display_mode_service.dart b/lib/services/display_mode_service.dart index 9d220e13..af68f775 100644 --- a/lib/services/display_mode_service.dart +++ b/lib/services/display_mode_service.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/foundation.dart'; import 'package:flutter_displaymode/flutter_displaymode.dart'; import 'package:flick/core/utils/dev_log.dart'; @@ -6,15 +8,20 @@ import 'package:flick/core/utils/dev_log.dart'; /// /// The preference values mirror the `refreshRateMode` app preference: /// * `adaptive` — leave the mode to the system (no preferred mode set); -/// * `standard` — request the lowest refresh rate at the current resolution; +/// * `standard` — request the advertised mode closest to 60 Hz at the +/// current resolution; /// * `high` — request the highest refresh rate at the current resolution. /// +/// `standard` deliberately avoids the lowest advertised rate: panels with +/// 10/20/30 Hz low-power modes would otherwise cap at 10 Hz. When no mode +/// within 5 Hz of 60 Hz exists, the system keeps control instead. +/// /// Forced modes are verified shortly after they are requested. Some firmware /// acknowledges the request and then drops the panel into a low-power state /// (reported on the Fairphone 6, see moss-apps/Flick#210); when the active /// mode does not match the request the service falls back to `auto` and stops -/// forcing for the rest of the session, so later screen mounts and resumes do -/// not keep poking the display driver. +/// forcing for the rest of the session, so later screens and resumes do not +/// keep poking the display driver. class DisplayModeService { /// Preference value: follow the system refresh rate. static const String adaptive = 'adaptive'; @@ -44,8 +51,8 @@ class DisplayModeService { @visibleForTesting Duration verificationDelay = const Duration(milliseconds: 1500); - /// Last mode id handed to the platform; lets mounts and resumes skip - /// redundant requests. + /// Last requested mode. The current window's preference must still match + /// before skipping: Android can recreate a window without restarting Dart. int? _lastRequestedModeId; /// True once a forced mode was rejected. Automatic applies then leave the @@ -54,6 +61,24 @@ class DisplayModeService { /// Invalidates a pending verification when a newer request takes over. int _verificationToken = 0; + Timer? _verificationTimer; + bool _isForeground = true; + + /// Background/closing windows can run at a lower rate without rejecting the + /// preference. Only verify requests while the app is in the foreground. + void setForeground(bool isForeground) { + _isForeground = isForeground; + if (!isForeground) _cancelVerification(); + } + + void _cancelVerification() { + _verificationTimer?.cancel(); + _verificationTimer = null; + _verificationToken++; + } + + bool _isCurrentRequest(int token) => + token == _verificationToken && _isForeground && isSupported; static String _describe(DisplayMode? mode) { if (mode == null) return 'unknown'; @@ -97,17 +122,21 @@ class DisplayModeService { /// Applies the refresh-rate [mode] preference. /// /// [reason] is used for logging and to distinguish automatic applies - /// ('mount', 'resume') from an explicit user selection ('preference'). + /// ('startup', 'resume') from an explicit user selection ('preference'). Future applyRefreshRateMode( String mode, { String reason = 'apply', }) async { - if (!isSupported) return; + if (!isSupported || !_isForeground) return; + + _cancelVerification(); + final token = _verificationToken; final requested = await _resolveTargetMode(mode); - if (requested == null) return; + if (requested == null || !_isCurrentRequest(token)) return; final isForced = requested.id != DisplayMode.auto.id; + if (reason == 'preference') _forceSuppressed = false; if (isForced && _forceSuppressed && reason != 'preference') { devLog( 'DisplayMode[apply]: forced modes suppressed for this session, ' @@ -116,47 +145,55 @@ class DisplayModeService { return; } - if (_lastRequestedModeId == requested.id) { + final alreadyPreferred = await _hasCurrentRequest(requested); + if (!_isCurrentRequest(token)) return; + if (alreadyPreferred) { devLog( - 'DisplayMode[apply]: ${_describe(requested)} already requested, ' + 'DisplayMode[apply]: ${_describe(requested)} already preferred by ' + 'the current window, ' 'skipping ($reason)', ); + if (isForced) _scheduleVerification(requested, token); return; } - _verificationToken++; try { await FlutterDisplayMode.setPreferredMode(requested); } catch (e) { devLog('DisplayMode[apply]: failed to set ${_describe(requested)}: $e'); return; } + if (!_isCurrentRequest(token)) return; _lastRequestedModeId = requested.id; await _logState('apply $mode ($reason)'); - if (isForced) { - _scheduleVerification(requested); + if (isForced && _isCurrentRequest(token)) { + _scheduleVerification(requested, token); } } - /// Clears any forced display mode so the system decides again. - Future resetToAuto({String reason = 'resetAuto'}) async { - if (!isSupported) return; - _verificationToken++; - if (_lastRequestedModeId == DisplayMode.auto.id) { - devLog('DisplayMode[resetAuto]: already auto ($reason)'); - return; - } + Future _hasCurrentRequest(DisplayMode requested) async { + if (_lastRequestedModeId != requested.id) return false; try { - await FlutterDisplayMode.setPreferredMode(DisplayMode.auto); - _lastRequestedModeId = DisplayMode.auto.id; - await _logState('resetAuto ($reason)'); + _preferredMode = await FlutterDisplayMode.preferred; + return _preferredMode?.id == requested.id; } catch (e) { - devLog('Failed to reset display mode: $e'); + devLog('DisplayMode[apply]: failed to check the current window: $e'); + return false; } } - /// Picks the platform mode matching [mode] at the current resolution, - /// mirroring the plugin's high/low refresh-rate helpers. + /// Clears any forced display mode so the system decides again. + Future resetToAuto({String reason = 'resetAuto'}) => + applyRefreshRateMode(adaptive, reason: reason); + + /// The refresh rate that the `standard` preference aims for. + static const double _standardRefreshRate = 60; + + /// How far a mode may be from [_standardRefreshRate] and still count as + /// "60 Hz" (covers panels that report 59.94 Hz). + static const double _standardTolerance = 5; + + /// Picks the platform mode matching [mode] at the current resolution. Future _resolveTargetMode(String mode) async { if (mode == adaptive) return DisplayMode.auto; @@ -172,16 +209,17 @@ class DisplayModeService { try { final modes = await FlutterDisplayMode.supported; final active = await FlutterDisplayMode.active; + if (effectiveMode == standard) { + return _closestToStandard(active, modes); + } + var target = active; for (final candidate in modes) { if (candidate.width != active.width || candidate.height != active.height) { continue; } - final better = effectiveMode == standard - ? candidate.refreshRate < target.refreshRate - : candidate.refreshRate > target.refreshRate; - if (better) { + if (candidate.refreshRate > target.refreshRate) { target = candidate; } } @@ -192,10 +230,38 @@ class DisplayModeService { } } - void _scheduleVerification(DisplayMode requested) { - final token = ++_verificationToken; - Future.delayed(verificationDelay, () async { - if (token != _verificationToken || !isSupported) return; + /// Returns the mode closest to 60 Hz at [active]'s resolution, or `auto` + /// when the panel has no such mode. + DisplayMode _closestToStandard(DisplayMode active, List modes) { + DisplayMode? best; + var bestDistance = double.infinity; + for (final candidate in modes) { + if (candidate.width != active.width || + candidate.height != active.height) { + continue; + } + final distance = (candidate.refreshRate - _standardRefreshRate).abs(); + if (distance <= _standardTolerance && distance < bestDistance) { + best = candidate; + bestDistance = distance; + } + } + if (best == null) { + devLog( + 'DisplayMode[resolve]: no mode within ' + '${_standardTolerance.toStringAsFixed(0)} Hz of ' + '${_standardRefreshRate.toStringAsFixed(0)} Hz at ' + '${active.width}x${active.height}; following the system', + ); + return DisplayMode.auto; + } + return best; + } + + void _scheduleVerification(DisplayMode requested, int token) { + _verificationTimer = Timer(verificationDelay, () async { + _verificationTimer = null; + if (!_isCurrentRequest(token)) return; final DisplayMode active; try { active = await FlutterDisplayMode.active; @@ -203,7 +269,7 @@ class DisplayModeService { devLog('DisplayMode[verify]: failed to read the active mode: $e'); return; } - if (token != _verificationToken) return; + if (!_isCurrentRequest(token)) return; _currentMode = active; if (active.id == requested.id) { devLog('DisplayMode[verify]: ${_describe(requested)} is active'); @@ -222,12 +288,13 @@ class DisplayModeService { /// Clears session state; call between tests. @visibleForTesting void debugReset() { + _cancelVerification(); _availableModes = []; _currentMode = null; _preferredMode = null; _lastRequestedModeId = null; _forceSuppressed = false; - _verificationToken = 0; + _isForeground = true; verificationDelay = const Duration(milliseconds: 1500); } diff --git a/lib/widgets/common/display_mode_wrapper.dart b/lib/widgets/common/display_mode_wrapper.dart deleted file mode 100644 index 7181834b..00000000 --- a/lib/widgets/common/display_mode_wrapper.dart +++ /dev/null @@ -1,60 +0,0 @@ -import 'package:flutter/widgets.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flick/core/utils/dev_log.dart'; -import 'package:flick/providers/app_preferences_provider.dart'; -import 'package:flick/services/display_mode_service.dart'; - -class DisplayModeWrapper extends ConsumerStatefulWidget { - final Widget child; - final bool enableOnMount; - - const DisplayModeWrapper({ - super.key, - required this.child, - this.enableOnMount = true, - }); - - @override - ConsumerState createState() => - _DisplayModeWrapperState(); -} - -class _DisplayModeWrapperState extends ConsumerState - with WidgetsBindingObserver { - final DisplayModeService _displayModeService = DisplayModeService(); - - @override - void initState() { - super.initState(); - WidgetsBinding.instance.addObserver(this); - ref.listenManual(appPreferencesProvider.select((p) => p.refreshRateMode), - (_, __) => _applyRefreshRate('preference')); - if (widget.enableOnMount) { - _applyRefreshRate('mount'); - } - } - - @override - void dispose() { - WidgetsBinding.instance.removeObserver(this); - super.dispose(); - } - - @override - void didChangeAppLifecycleState(AppLifecycleState state) { - if (state == AppLifecycleState.resumed) { - _applyRefreshRate('resume'); - } - } - - Future _applyRefreshRate(String reason) async { - final mode = ref.read(appPreferencesProvider).refreshRateMode; - devLog('DisplayMode[wrapper]: apply refreshRateMode=$mode ($reason)'); - await _displayModeService.applyRefreshRateMode(mode, reason: reason); - } - - @override - Widget build(BuildContext context) { - return widget.child; - } -} diff --git a/test/providers/app_preferences_provider_test.dart b/test/providers/app_preferences_provider_test.dart index f5d33c38..346270d0 100644 --- a/test/providers/app_preferences_provider_test.dart +++ b/test/providers/app_preferences_provider_test.dart @@ -150,12 +150,61 @@ void main() { expect(prefs.getString('app_refresh_rate_mode'), 'adaptive'); }); - test('refresh rate keeps the previous default on existing installations', () async { - SharedPreferences.setMockInitialValues({'onboarding_completed': true}); - final container = ProviderContainer(); - addTearDown(container.dispose); - container.read(appPreferencesProvider); - await pumpEventQueue(); - expect(container.read(appPreferencesProvider).refreshRateMode, 'high'); - }); + test( + 'refresh rate keeps the previous default on existing installations', + () async { + SharedPreferences.setMockInitialValues({'onboarding_completed': true}); + final container = ProviderContainer(); + addTearDown(container.dispose); + container.read(appPreferencesProvider); + await pumpEventQueue(); + expect(container.read(appPreferencesProvider).refreshRateMode, 'high'); + }, + ); + + test( + 'refresh rate keeps an explicitly chosen mode on existing installations', + () async { + SharedPreferences.setMockInitialValues({ + 'onboarding_completed': true, + 'app_refresh_rate_mode': 'standard', + }); + final container = ProviderContainer(); + addTearDown(container.dispose); + container.read(appPreferencesProvider); + await pumpEventQueue(); + expect( + container.read(appPreferencesProvider).refreshRateMode, + 'standard', + ); + }, + ); + + for (final mode in ['high', 'standard', 'adaptive']) { + test('refresh rate $mode survives reopening the preferences', () async { + SharedPreferences.setMockInitialValues({ + 'onboarding_completed': true, + 'app_refresh_rate_mode': 'standard', + 'app_haptics_enabled': false, + }); + final container = ProviderContainer(); + container.read(appPreferencesProvider); + // This setter must wait for the saved values, rather than comparing + // against or overwriting the temporary startup defaults. + await container + .read(appPreferencesProvider.notifier) + .setRefreshRateMode(mode); + container.dispose(); + + final restored = ProviderContainer(); + addTearDown(restored.dispose); + await restored.read(appPreferencesProvider.notifier).loaded; + expect(restored.read(appPreferencesProvider).refreshRateMode, mode); + + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getString('app_refresh_rate_mode'), mode); + expect(prefs.getBool('app_haptics_enabled'), isFalse); + expect(prefs.getBool('onboarding_completed'), isTrue); + }); + } } diff --git a/test/services/display_mode_service_test.dart b/test/services/display_mode_service_test.dart index 77f58773..f4e6468e 100644 --- a/test/services/display_mode_service_test.dart +++ b/test/services/display_mode_service_test.dart @@ -1,26 +1,29 @@ +import 'dart:async'; + import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flick/services/display_mode_service.dart'; -/// One resolution, two refresh rates — a typical 60/120 Hz phone panel. +/// One resolution across all fakes; refresh rates vary to model panels with +/// low-power modes alongside the usual 60/120 Hz modes (see issue #210). const int _width = 1080; const int _height = 2400; -const Map _mode60 = { - 'id': 1, +Map _mode(int id, double refreshRate) => { + 'id': id, 'width': _width, 'height': _height, - 'refreshRate': 60.0, + 'refreshRate': refreshRate, }; -const Map _mode120 = { - 'id': 2, - 'width': _width, - 'height': _height, - 'refreshRate': 120.0, -}; +final List> _lowPowerModes = [ + _mode(1, 10), + _mode(2, 30), + _mode(3, 60), + _mode(4, 120), +]; const Map _autoMode = { 'id': 0, @@ -32,44 +35,49 @@ const Map _autoMode = { /// Fake of the `flutter_display_mode` platform channel. /// /// With [rejectForcedModes] a non-zero mode request is acknowledged but the -/// active mode never changes, mirroring firmware that ignores the request -/// (reported on the Fairphone 6, see moss-apps/Flick#210). +/// active mode never changes. With [settleForcedToId] the request is accepted +/// but the panel settles on another mode, mirroring firmware that drops into a +/// low-power state after a forced request. class _FakeDisplay { + _FakeDisplay({List>? modes, this.initialActiveId = 1}) + : modes = modes ?? [_mode(1, 60), _mode(2, 120)]; + + final List> modes; + final int initialActiveId; final List setPreferredCalls = []; + bool rejectForcedModes = false; + int? settleForcedToId; - int _activeId = 1; + late int _activeId = initialActiveId; int _preferredId = 0; - Map get _active => _activeId == 2 ? _mode120 : _mode60; + void recreateWindow() { + _activeId = initialActiveId; + _preferredId = 0; + } - Map get _preferred { - switch (_preferredId) { - case 1: - return _mode60; - case 2: - return _mode120; - default: - return _autoMode; - } + Map _modeById(int id) { + if (id == 0) return _autoMode; + return modes.firstWhere((mode) => mode['id'] == id); } Future handle(MethodCall call) async { switch (call.method) { case 'getSupportedModes': - return const [_mode60, _mode120]; + return modes; case 'getActiveMode': - return _active; + return _modeById(_activeId); case 'getPreferredMode': - return _preferred; + return _modeById(_preferredId); case 'setPreferredMode': final id = (call.arguments as Map)['mode'] as int; setPreferredCalls.add(id); _preferredId = id; if (id == 0) { - _activeId = 1; + _activeId = initialActiveId; } else if (!rejectForcedModes) { - _activeId = id; + _activeId = settleForcedToId ?? id; } return null; default: @@ -88,7 +96,7 @@ void main() { debugDefaultTargetPlatformOverride = TargetPlatform.android; fake = _FakeDisplay(); TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, fake.handle); + .setMockMethodCallHandler(channel, (call) => fake.handle(call)); final service = DisplayModeService(); service.debugReset(); service.verificationDelay = Duration.zero; @@ -101,35 +109,123 @@ void main() { DisplayModeService().debugReset(); }); - test('high requests the highest refresh rate at the current resolution', () async { + test( + 'high requests the highest refresh rate at the current resolution', + () async { + final service = DisplayModeService(); + await service.applyRefreshRateMode( + DisplayModeService.high, + reason: 'preference', + ); + expect(fake.setPreferredCalls, [2]); + + await pumpEventQueue(); + expect(fake.setPreferredCalls, [ + 2, + ], reason: 'an accepted mode is not requested again'); + + await service.applyRefreshRateMode( + DisplayModeService.high, + reason: 'resume', + ); + expect(fake.setPreferredCalls, [2], reason: 'a repeat resume is skipped'); + }, + ); + + test( + 'restores the forced mode when a new window loses the old request', + () async { + final service = DisplayModeService(); + await service.applyRefreshRateMode( + DisplayModeService.high, + reason: 'preference', + ); + await pumpEventQueue(); + + fake.recreateWindow(); + await service.applyRefreshRateMode( + DisplayModeService.high, + reason: 'resume', + ); + await pumpEventQueue(); + + expect(fake.setPreferredCalls, [2, 2]); + }, + ); + + test( + 'backgrounding cancels verification without suppressing a later restore', + () async { + fake.rejectForcedModes = true; + final service = DisplayModeService(); + await service.applyRefreshRateMode( + DisplayModeService.high, + reason: 'preference', + ); + service.setForeground(false); + await pumpEventQueue(); + expect(fake.setPreferredCalls, [2]); + + await service.applyRefreshRateMode( + DisplayModeService.standard, + reason: 'resume', + ); + expect(fake.setPreferredCalls, [2]); + + fake.rejectForcedModes = false; + fake.recreateWindow(); + service.setForeground(true); + await service.applyRefreshRateMode( + DisplayModeService.high, + reason: 'resume', + ); + await pumpEventQueue(); + expect(fake.setPreferredCalls, [2, 2]); + }, + ); + + test('standard targets 60 Hz instead of the lowest low-power mode', () async { + fake = _FakeDisplay(modes: _lowPowerModes, initialActiveId: 2); final service = DisplayModeService(); await service.applyRefreshRateMode( - DisplayModeService.high, + DisplayModeService.standard, reason: 'preference', ); - expect(fake.setPreferredCalls, [2]); + expect(fake.setPreferredCalls, [3]); await pumpEventQueue(); - expect( - fake.setPreferredCalls, - [2], - reason: 'an accepted mode is not requested again', - ); + expect(fake.setPreferredCalls, [3]); + }); + test('standard accepts a 59.94 Hz mode as 60 Hz', () async { + fake = _FakeDisplay( + modes: [_mode(1, 30), _mode(2, 59.94), _mode(3, 120)], + initialActiveId: 1, + ); + final service = DisplayModeService(); await service.applyRefreshRateMode( - DisplayModeService.high, - reason: 'mount', + DisplayModeService.standard, + reason: 'preference', ); - expect(fake.setPreferredCalls, [2], reason: 'a repeat mount is skipped'); + + expect(fake.setPreferredCalls, [2]); + await pumpEventQueue(); }); - test('standard requests the lowest refresh rate at the current resolution', () async { + test('standard follows the system when no 60 Hz mode exists', () async { + fake = _FakeDisplay( + modes: [_mode(1, 10), _mode(2, 30), _mode(3, 120)], + initialActiveId: 2, + ); final service = DisplayModeService(); await service.applyRefreshRateMode( DisplayModeService.standard, reason: 'preference', ); - expect(fake.setPreferredCalls, [1]); + + expect(fake.setPreferredCalls, [ + 0, + ], reason: 'the lowest mode is never used as a 60 Hz substitute'); }); test('adaptive clears the forced mode instead of requesting one', () async { @@ -142,46 +238,139 @@ void main() { await service.applyRefreshRateMode( DisplayModeService.adaptive, - reason: 'mount', + reason: 'resume', ); expect(fake.setPreferredCalls, [0], reason: 'already auto is skipped'); }); - test('a rejected forced mode falls back to auto and stays suppressed', () async { - fake.rejectForcedModes = true; + test( + 'a rejected forced mode falls back to auto and stays suppressed', + () async { + fake.rejectForcedModes = true; + final service = DisplayModeService(); + await service.applyRefreshRateMode( + DisplayModeService.high, + reason: 'preference', + ); + expect(fake.setPreferredCalls, [2]); + + await pumpEventQueue(); + expect(fake.setPreferredCalls, [ + 2, + 0, + ], reason: 'the rejected request is followed by a reset to auto'); + + await service.applyRefreshRateMode( + DisplayModeService.high, + reason: 'resume', + ); + await pumpEventQueue(); + expect(fake.setPreferredCalls, [ + 2, + 0, + ], reason: 'automatic applies stop forcing after a rejection'); + }, + ); + + test('a forced mode that settles on a low-power rate is reverted', () async { + fake = _FakeDisplay(modes: _lowPowerModes, initialActiveId: 3); + fake.settleForcedToId = 2; final service = DisplayModeService(); await service.applyRefreshRateMode( DisplayModeService.high, reason: 'preference', ); - expect(fake.setPreferredCalls, [2]); + expect(fake.setPreferredCalls, [4]); await pumpEventQueue(); - expect( - fake.setPreferredCalls, - [2, 0], - reason: 'the rejected request is followed by a reset to auto', - ); + expect(fake.setPreferredCalls, [ + 4, + 0, + ], reason: 'firmware that drops to 30 Hz is reverted to auto'); await service.applyRefreshRateMode( DisplayModeService.high, - reason: 'mount', + reason: 'resume', + ); + expect(fake.setPreferredCalls, [4, 0]); + }); + + test( + 'an explicit preference change still attempts forcing when suppressed', + () async { + fake.rejectForcedModes = true; + final service = DisplayModeService(); + await service.applyRefreshRateMode( + DisplayModeService.high, + reason: 'preference', + ); + await pumpEventQueue(); + expect(fake.setPreferredCalls, [2, 0]); + + await service.applyRefreshRateMode( + DisplayModeService.high, + reason: 'preference', + ); + expect(fake.setPreferredCalls, [2, 0, 2]); + await pumpEventQueue(); + }, + ); + + test('rapid changes do not trigger a stale fallback', () async { + fake = _FakeDisplay(modes: _lowPowerModes, initialActiveId: 3); + final service = DisplayModeService(); + await service.applyRefreshRateMode( + DisplayModeService.standard, + reason: 'preference', ); await service.applyRefreshRateMode( DisplayModeService.high, - reason: 'resume', + reason: 'preference', ); + expect(fake.setPreferredCalls, [3, 4]); + await pumpEventQueue(); - expect( - fake.setPreferredCalls, - [2, 0], - reason: 'automatic applies stop forcing after a rejection', + expect(fake.setPreferredCalls, [ + 3, + 4, + ], reason: 'the superseded verification must not revert'); + }); + + test('a superseded mode lookup cannot overwrite a newer selection', () async { + final lookup = Completer(); + var delayFirstLookup = true; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method == 'getSupportedModes' && delayFirstLookup) { + delayFirstLookup = false; + await lookup.future; + } + return fake.handle(call); + }); + + final service = DisplayModeService(); + final outdated = service.applyRefreshRateMode( + DisplayModeService.high, + reason: 'preference', ); + await pumpEventQueue(); + try { + await service.applyRefreshRateMode( + DisplayModeService.standard, + reason: 'preference', + ); + } finally { + lookup.complete(); + await outdated; + } + await pumpEventQueue(); + + expect(fake.setPreferredCalls, [1]); }); - test('an explicit preference change still attempts forcing when suppressed', () async { - fake.rejectForcedModes = true; + test('a successful explicit retry can be restored on later reopening', () async { final service = DisplayModeService(); + fake.rejectForcedModes = true; await service.applyRefreshRateMode( DisplayModeService.high, reason: 'preference', @@ -189,12 +378,20 @@ void main() { await pumpEventQueue(); expect(fake.setPreferredCalls, [2, 0]); + fake.rejectForcedModes = false; await service.applyRefreshRateMode( DisplayModeService.high, reason: 'preference', ); - expect(fake.setPreferredCalls, [2, 0, 2]); await pumpEventQueue(); + fake.recreateWindow(); + await service.applyRefreshRateMode( + DisplayModeService.high, + reason: 'resume', + ); + await pumpEventQueue(); + + expect(fake.setPreferredCalls, [2, 0, 2, 2]); }); test('an unknown mode falls back to the high behaviour', () async {