From 912c15e06302e39624ef5dd3afe52987c8619821 Mon Sep 17 00:00:00 2001 From: Fabian Freund Date: Wed, 10 Jun 2026 09:22:58 +0200 Subject: [PATCH] make refresh rate selectable --- .../lib/domain/services/display_mode.dart | 70 ++++++++++++++++ .../lib/domain/services/display_mode.g.dart | 77 +++++++++++++++++ .../domain/providers/desktop_mode.g.dart | 2 +- .../screens/general_settings.dart | 84 ++++++++++++++++++- .../user/data/models/general_settings.dart | 16 ++++ .../user/data/models/general_settings.g.dart | 25 ++++++ .../domain/repositories/general_settings.dart | 4 + .../repositories/general_settings.g.dart | 2 +- apps/weblibre/lib/main.dart | 4 + apps/weblibre/pubspec.yaml | 1 + 10 files changed, 281 insertions(+), 4 deletions(-) create mode 100644 apps/weblibre/lib/domain/services/display_mode.dart create mode 100644 apps/weblibre/lib/domain/services/display_mode.g.dart diff --git a/apps/weblibre/lib/domain/services/display_mode.dart b/apps/weblibre/lib/domain/services/display_mode.dart new file mode 100644 index 00000000..ae709ae0 --- /dev/null +++ b/apps/weblibre/lib/domain/services/display_mode.dart @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter_displaymode/flutter_displaymode.dart'; +import 'package:riverpod/riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:weblibre/core/logger.dart'; +import 'package:weblibre/features/user/data/models/general_settings.dart'; +import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; + +part 'display_mode.g.dart'; + +Future _applyRefreshRateMode(RefreshRateMode mode) async { + // `flutter_displaymode` only supports Android; the calls throw on other + // platforms, so skip them entirely. + if (!Platform.isAndroid) { + return; + } + + try { + switch (mode) { + case RefreshRateMode.system: + // No dedicated "auto" helper; selecting the auto mode hands control + // back to the OS-managed default. + await FlutterDisplayMode.setPreferredMode(DisplayMode.auto); + case RefreshRateMode.high: + await FlutterDisplayMode.setHighRefreshRate(); + case RefreshRateMode.low: + await FlutterDisplayMode.setLowRefreshRate(); + } + } catch (e, s) { + logger.w('Failed to apply refresh rate mode', error: e, stackTrace: s); + } +} + +/// Always-mounted side-effect provider that pushes the configured +/// [RefreshRateMode] to the OS whenever it changes. Flutter does not request a +/// high refresh rate by default, so without this the app stays at 60Hz on many +/// devices even when the panel supports more. +/// +/// Watched from the root widget so it stays alive for the whole app lifetime. +/// The body re-runs on every change of the setting (including the initial +/// build), so the mode is applied at startup as well as on later edits. +@Riverpod(keepAlive: true) +void displayModeApplier(Ref ref) { + final mode = ref.watch( + generalSettingsWithDefaultsProvider.select((s) => s.refreshRateMode), + ); + + unawaited(_applyRefreshRateMode(mode)); +} diff --git a/apps/weblibre/lib/domain/services/display_mode.g.dart b/apps/weblibre/lib/domain/services/display_mode.g.dart new file mode 100644 index 00000000..51a2fc5b --- /dev/null +++ b/apps/weblibre/lib/domain/services/display_mode.g.dart @@ -0,0 +1,77 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'display_mode.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning +/// Always-mounted side-effect provider that pushes the configured +/// [RefreshRateMode] to the OS whenever it changes. Flutter does not request a +/// high refresh rate by default, so without this the app stays at 60Hz on many +/// devices even when the panel supports more. +/// +/// Watched from the root widget so it stays alive for the whole app lifetime. +/// The body re-runs on every change of the setting (including the initial +/// build), so the mode is applied at startup as well as on later edits. + +@ProviderFor(displayModeApplier) +final displayModeApplierProvider = DisplayModeApplierProvider._(); + +/// Always-mounted side-effect provider that pushes the configured +/// [RefreshRateMode] to the OS whenever it changes. Flutter does not request a +/// high refresh rate by default, so without this the app stays at 60Hz on many +/// devices even when the panel supports more. +/// +/// Watched from the root widget so it stays alive for the whole app lifetime. +/// The body re-runs on every change of the setting (including the initial +/// build), so the mode is applied at startup as well as on later edits. + +final class DisplayModeApplierProvider + extends $FunctionalProvider + with $Provider { + /// Always-mounted side-effect provider that pushes the configured + /// [RefreshRateMode] to the OS whenever it changes. Flutter does not request a + /// high refresh rate by default, so without this the app stays at 60Hz on many + /// devices even when the panel supports more. + /// + /// Watched from the root widget so it stays alive for the whole app lifetime. + /// The body re-runs on every change of the setting (including the initial + /// build), so the mode is applied at startup as well as on later edits. + DisplayModeApplierProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'displayModeApplierProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$displayModeApplierHash(); + + @$internal + @override + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + void create(Ref ref) { + return displayModeApplier(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(void value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$displayModeApplierHash() => + r'10e3dab5052bed606d3fd02a08103d2ac3f99f13'; diff --git a/apps/weblibre/lib/features/geckoview/domain/providers/desktop_mode.g.dart b/apps/weblibre/lib/features/geckoview/domain/providers/desktop_mode.g.dart index ed86e350..b75870ef 100644 --- a/apps/weblibre/lib/features/geckoview/domain/providers/desktop_mode.g.dart +++ b/apps/weblibre/lib/features/geckoview/domain/providers/desktop_mode.g.dart @@ -57,7 +57,7 @@ final class DesktopModeProvider extends $NotifierProvider { } } -String _$desktopModeHash() => r'e755b0a36f7079006047896cb8f2a7be484dea90'; +String _$desktopModeHash() => r'af80dc2845488b6a039281d1e7c3d9ab79a38a62'; final class DesktopModeFamily extends $Family with $ClassFamilyOverride { diff --git a/apps/weblibre/lib/features/settings/presentation/screens/general_settings.dart b/apps/weblibre/lib/features/settings/presentation/screens/general_settings.dart index 9faebeea..7fc6de4e 100644 --- a/apps/weblibre/lib/features/settings/presentation/screens/general_settings.dart +++ b/apps/weblibre/lib/features/settings/presentation/screens/general_settings.dart @@ -53,7 +53,8 @@ const List generalSettingsSections = [ ), SettingsEntryDefinition( title: 'Pure Black (OLED)', - subtitle: 'Use true-black surfaces in dark mode to save power on OLED ' + subtitle: + 'Use true-black surfaces in dark mode to save power on OLED ' 'screens', keywords: ['oled', 'amoled', 'high contrast', 'black', 'dark'], child: _PureBlackTile(), @@ -64,6 +65,24 @@ const List generalSettingsSections = [ keywords: ['ui scale', 'zoom'], child: _UiZoomSection(), ), + SettingsEntryDefinition( + title: 'Refresh Rate', + subtitle: 'Request a high or low display refresh rate (Android)', + keywords: [ + 'fps', + 'hz', + 'hertz', + 'frame rate', + 'framerate', + '60hz', + '90hz', + '120hz', + 'smooth', + 'high refresh', + 'display mode', + ], + child: _RefreshRateSection(), + ), SettingsEntryDefinition( title: 'Disable Animations', subtitle: 'Reduce motion and turn off app animations', @@ -78,7 +97,8 @@ const List generalSettingsSections = [ ), SettingsEntryDefinition( title: 'Show Close Button', - subtitle: 'Add a button to dismiss the search / new-tab page without ' + subtitle: + 'Add a button to dismiss the search / new-tab page without ' 'a back gesture', keywords: [ 'back', @@ -421,6 +441,66 @@ class _ThemeSection extends HookConsumerWidget { } } +class _RefreshRateSection extends HookConsumerWidget { + const _RefreshRateSection(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final refreshRateMode = ref.watch( + generalSettingsWithDefaultsProvider.select((s) => s.refreshRateMode), + ); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const ListTile( + title: Text('Refresh Rate'), + subtitle: Text( + 'Choose "High" for the smoothest scrolling and animations on ' + '90/120Hz screens, or "Low" to save battery.', + ), + leading: Icon(Icons.speed), + contentPadding: EdgeInsets.zero, + ), + Center( + child: SegmentedButton( + segments: const [ + ButtonSegment( + value: RefreshRateMode.system, + icon: Icon(Icons.smartphone), + label: Text('System'), + ), + ButtonSegment( + value: RefreshRateMode.high, + icon: Icon(Icons.bolt), + label: Text('High'), + ), + ButtonSegment( + value: RefreshRateMode.low, + icon: Icon(Icons.battery_saver), + label: Text('Low'), + ), + ], + selected: {refreshRateMode}, + onSelectionChanged: (value) async { + await ref + .read(saveGeneralSettingsControllerProvider.notifier) + .save( + (currentSettings) => + currentSettings.copyWith.refreshRateMode(value.first), + ); + }, + ), + ), + ], + ), + ); + } +} + class _ExternalDownloadManagerTile extends HookConsumerWidget { const _ExternalDownloadManagerTile(); diff --git a/apps/weblibre/lib/features/user/data/models/general_settings.dart b/apps/weblibre/lib/features/user/data/models/general_settings.dart index 6f364ddc..8b51afef 100644 --- a/apps/weblibre/lib/features/user/data/models/general_settings.dart +++ b/apps/weblibre/lib/features/user/data/models/general_settings.dart @@ -47,6 +47,15 @@ const defaultQuickTabSwitcherHierarchyGlyphs = 2; const minQuickTabSwitcherHierarchyGlyphs = 0; const maxQuickTabSwitcherHierarchyGlyphs = 4; +/// Controls the Android display refresh rate the app requests at startup. +/// +/// Flutter does not request a high refresh rate by default, so on many devices +/// (Samsung, OnePlus, Xiaomi, …) the app is left at 60Hz even on a 90/120Hz +/// panel. [high] asks the OS for the fastest available mode, [low] for the +/// slowest (battery saving), and [system] leaves the OS-managed default in +/// place. Android-only; ignored on other platforms. +enum RefreshRateMode { system, high, low } + enum TabBarSwipeAction { switchLastOpened, navigateOrderedTabs } enum QuickTabSwitcherMode { lastUsedTabs, containerTabs } @@ -79,6 +88,9 @@ class GeneralSettings with FastEquatable { final ThemeMode themeMode; final double uiScaleFactor; final bool disableAnimations; + + /// Android display refresh rate requested at startup. See [RefreshRateMode]. + final RefreshRateMode refreshRateMode; final bool showModalBarrier; final bool enableReadability; final bool enforceReadability; @@ -168,6 +180,7 @@ class GeneralSettings with FastEquatable { required this.themeMode, required this.uiScaleFactor, required this.disableAnimations, + required this.refreshRateMode, required this.showModalBarrier, required this.enableReadability, required this.enforceReadability, @@ -232,6 +245,7 @@ class GeneralSettings with FastEquatable { ThemeMode? themeMode, double? uiScaleFactor, bool? disableAnimations, + RefreshRateMode? refreshRateMode, bool? showModalBarrier, bool? enableReadability, bool? enforceReadability, @@ -293,6 +307,7 @@ class GeneralSettings with FastEquatable { }) : themeMode = themeMode ?? ThemeMode.dark, uiScaleFactor = uiScaleFactor ?? defaultUiScaleFactor, disableAnimations = disableAnimations ?? false, + refreshRateMode = refreshRateMode ?? RefreshRateMode.high, showModalBarrier = showModalBarrier ?? true, enableReadability = enableReadability ?? true, enforceReadability = enforceReadability ?? false, @@ -419,6 +434,7 @@ class GeneralSettings with FastEquatable { themeMode, uiScaleFactor, disableAnimations, + refreshRateMode, showModalBarrier, enableReadability, enforceReadability, diff --git a/apps/weblibre/lib/features/user/data/models/general_settings.g.dart b/apps/weblibre/lib/features/user/data/models/general_settings.g.dart index 9f2229b5..2e91c385 100644 --- a/apps/weblibre/lib/features/user/data/models/general_settings.g.dart +++ b/apps/weblibre/lib/features/user/data/models/general_settings.g.dart @@ -13,6 +13,8 @@ abstract class _$GeneralSettingsCWProxy { GeneralSettings disableAnimations(bool disableAnimations); + GeneralSettings refreshRateMode(RefreshRateMode refreshRateMode); + GeneralSettings showModalBarrier(bool showModalBarrier); GeneralSettings enableReadability(bool enableReadability); @@ -162,6 +164,7 @@ abstract class _$GeneralSettingsCWProxy { ThemeMode themeMode, double uiScaleFactor, bool disableAnimations, + RefreshRateMode refreshRateMode, bool showModalBarrier, bool enableReadability, bool enforceReadability, @@ -241,6 +244,10 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy { GeneralSettings disableAnimations(bool disableAnimations) => call(disableAnimations: disableAnimations); + @override + GeneralSettings refreshRateMode(RefreshRateMode refreshRateMode) => + call(refreshRateMode: refreshRateMode); + @override GeneralSettings showModalBarrier(bool showModalBarrier) => call(showModalBarrier: showModalBarrier); @@ -500,6 +507,7 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy { Object? themeMode = const $CopyWithPlaceholder(), Object? uiScaleFactor = const $CopyWithPlaceholder(), Object? disableAnimations = const $CopyWithPlaceholder(), + Object? refreshRateMode = const $CopyWithPlaceholder(), Object? showModalBarrier = const $CopyWithPlaceholder(), Object? enableReadability = const $CopyWithPlaceholder(), Object? enforceReadability = const $CopyWithPlaceholder(), @@ -576,6 +584,12 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy { ? _value.disableAnimations // ignore: cast_nullable_to_non_nullable : disableAnimations as bool, + refreshRateMode: + refreshRateMode == const $CopyWithPlaceholder() || + refreshRateMode == null + ? _value.refreshRateMode + // ignore: cast_nullable_to_non_nullable + : refreshRateMode as RefreshRateMode, showModalBarrier: showModalBarrier == const $CopyWithPlaceholder() || showModalBarrier == null @@ -940,6 +954,10 @@ GeneralSettings _$GeneralSettingsFromJson( themeMode: $enumDecodeNullable(_$ThemeModeEnumMap, json['themeMode']), uiScaleFactor: (json['uiScaleFactor'] as num?)?.toDouble(), disableAnimations: json['disableAnimations'] as bool?, + refreshRateMode: $enumDecodeNullable( + _$RefreshRateModeEnumMap, + json['refreshRateMode'], + ), showModalBarrier: json['showModalBarrier'] as bool?, enableReadability: json['enableReadability'] as bool?, enforceReadability: json['enforceReadability'] as bool?, @@ -1059,6 +1077,7 @@ Map _$GeneralSettingsToJson( 'themeMode': _$ThemeModeEnumMap[instance.themeMode]!, 'uiScaleFactor': instance.uiScaleFactor, 'disableAnimations': instance.disableAnimations, + 'refreshRateMode': _$RefreshRateModeEnumMap[instance.refreshRateMode]!, 'showModalBarrier': instance.showModalBarrier, 'enableReadability': instance.enableReadability, 'enforceReadability': instance.enforceReadability, @@ -1138,6 +1157,12 @@ const _$ThemeModeEnumMap = { ThemeMode.dark: 'dark', }; +const _$RefreshRateModeEnumMap = { + RefreshRateMode.system: 'system', + RefreshRateMode.high: 'high', + RefreshRateMode.low: 'low', +}; + const _$DeleteBrowsingDataTypeEnumMap = { DeleteBrowsingDataType.tabs: 'tabs', DeleteBrowsingDataType.history: 'history', diff --git a/apps/weblibre/lib/features/user/domain/repositories/general_settings.dart b/apps/weblibre/lib/features/user/domain/repositories/general_settings.dart index ab278bae..876407d7 100644 --- a/apps/weblibre/lib/features/user/domain/repositories/general_settings.dart +++ b/apps/weblibre/lib/features/user/domain/repositories/general_settings.dart @@ -56,6 +56,10 @@ class GeneralSettingsRepository extends _$GeneralSettingsRepository { DriftSqlType.bool, db.typeMapping, ), + 'refreshRateMode': settings['refreshRateMode']?.readAs( + DriftSqlType.string, + db.typeMapping, + ), 'showModalBarrier': settings['showModalBarrier']?.readAs( DriftSqlType.bool, db.typeMapping, diff --git a/apps/weblibre/lib/features/user/domain/repositories/general_settings.g.dart b/apps/weblibre/lib/features/user/domain/repositories/general_settings.g.dart index 007e0758..05c4edb2 100644 --- a/apps/weblibre/lib/features/user/domain/repositories/general_settings.g.dart +++ b/apps/weblibre/lib/features/user/domain/repositories/general_settings.g.dart @@ -35,7 +35,7 @@ final class GeneralSettingsRepositoryProvider } String _$generalSettingsRepositoryHash() => - r'0f3991899956a779f18615fa7c8c172d9086af1c'; + r'eb369cd699e336cd5ca51709f116883ae8c6dbc4'; abstract class _$GeneralSettingsRepository extends $StreamNotifier { diff --git a/apps/weblibre/lib/main.dart b/apps/weblibre/lib/main.dart index 03116493..67c3d45c 100644 --- a/apps/weblibre/lib/main.dart +++ b/apps/weblibre/lib/main.dart @@ -44,6 +44,7 @@ import 'package:weblibre/core/providers/app_state.dart'; import 'package:weblibre/core/providers/defaults.dart'; import 'package:weblibre/core/providers/router.dart'; import 'package:weblibre/domain/services/app_initialization.dart'; +import 'package:weblibre/domain/services/display_mode.dart'; import 'package:weblibre/features/account/domain/services/account_callback_handler.dart'; import 'package:weblibre/features/geckoview/features/browser/domain/services/engine_settings_replication.dart'; import 'package:weblibre/features/geckoview/features/open_link_tools/domain/services/url_cleaner_catalog_service.dart'; @@ -151,6 +152,9 @@ class _MainWidget extends HookConsumerWidget { // messages reach the ring buffer before the browser view (or logs // screen) mounts and would otherwise drop them. ref.watch(singboxProxyLogsProvider.select((_) => null)); + // Apply the configured display refresh rate from app start and keep it in + // sync with the setting (Flutter defaults to 60Hz otherwise). + ref.watch(displayModeApplierProvider); final rootKey = ref.watch(appStateKeyProvider); diff --git a/apps/weblibre/pubspec.yaml b/apps/weblibre/pubspec.yaml index e7c987d8..bec5a3ea 100644 --- a/apps/weblibre/pubspec.yaml +++ b/apps/weblibre/pubspec.yaml @@ -29,6 +29,7 @@ dependencies: flutter: sdk: flutter flutter_auto_size_text: ^5.0.0 + flutter_displaymode: ^0.7.0 flutter_hooks: ^0.21.3+1 flutter_markdown: ^0.7.7+1 flutter_material_design_icons: ^3.1.0+7447