refactor(settings): share preference binding lifecycle

This commit is contained in:
edde746
2026-07-12 08:42:25 +02:00
parent c81a1f9a97
commit 352aa04d3e
5 changed files with 228 additions and 90 deletions
+14 -30
View File
@@ -1,15 +1,16 @@
import 'dart:async' show unawaited;
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import '../mixins/disposable_change_notifier_mixin.dart'; import '../mixins/disposable_change_notifier_mixin.dart';
import '../models/shader_preset.dart'; import '../models/shader_preset.dart';
import '../services/settings_binding_owner.dart';
import '../services/settings_service.dart'; import '../services/settings_service.dart';
import '../services/shader_asset_loader.dart'; import '../services/shader_asset_loader.dart';
class ShaderProvider extends ChangeNotifier with DisposableChangeNotifierMixin { class ShaderProvider extends ChangeNotifier with DisposableChangeNotifierMixin {
SettingsService? _settingsService; late final SettingsBindingOwner _settingsBinding;
ValueNotifier<String>? _savedPresetListenable;
ValueNotifier<List<Map<String, dynamic>>>? _customPresetsListenable;
ShaderPreset _savedPreset = ShaderPreset.none; ShaderPreset _savedPreset = ShaderPreset.none;
ShaderPreset _currentPreset = ShaderPreset.none; ShaderPreset _currentPreset = ShaderPreset.none;
@@ -17,30 +18,14 @@ class ShaderProvider extends ChangeNotifier with DisposableChangeNotifierMixin {
bool _initialized = false; bool _initialized = false;
ShaderProvider() { ShaderProvider() {
_initialize(); _settingsBinding = SettingsBindingOwner(
prefs: [SettingsService.globalShaderPreset, SettingsService.customShaderPresets],
onRefresh: _syncFromSettings,
);
unawaited(_settingsBinding.bind());
} }
Future<void> _initialize() async { void _syncFromSettings(SettingsService service) {
final service = await SettingsService.getInstance();
if (_settingsService == service && _savedPresetListenable != null && _customPresetsListenable != null) {
_syncFromSettings();
return;
}
_savedPresetListenable?.removeListener(_onSettingsChanged);
_customPresetsListenable?.removeListener(_onSettingsChanged);
_settingsService = service;
_savedPresetListenable = service.listenable(SettingsService.globalShaderPreset)..addListener(_onSettingsChanged);
_customPresetsListenable = service.listenable(SettingsService.customShaderPresets)..addListener(_onSettingsChanged);
_syncFromSettings();
}
void _onSettingsChanged() => _syncFromSettings();
void _syncFromSettings() {
final service = _settingsService;
if (service == null) return;
final customData = service.read(SettingsService.customShaderPresets); final customData = service.read(SettingsService.customShaderPresets);
final customPresets = customData.map((json) => ShaderPreset.fromJson(json)).toList(); final customPresets = customData.map((json) => ShaderPreset.fromJson(json)).toList();
_customPresets = customPresets; _customPresets = customPresets;
@@ -55,8 +40,7 @@ class ShaderProvider extends ChangeNotifier with DisposableChangeNotifierMixin {
@override @override
void dispose() { void dispose() {
_savedPresetListenable?.removeListener(_onSettingsChanged); _settingsBinding.dispose();
_customPresetsListenable?.removeListener(_onSettingsChanged);
super.dispose(); super.dispose();
} }
@@ -72,12 +56,12 @@ class ShaderProvider extends ChangeNotifier with DisposableChangeNotifierMixin {
} }
Future<void> setPreset(ShaderPreset preset) async { Future<void> setPreset(ShaderPreset preset) async {
final service = _settingsService ?? await SettingsService.getInstance(); final service = _settingsBinding.settings ?? await SettingsService.getInstance();
await service.write(SettingsService.globalShaderPreset, preset.id); await service.write(SettingsService.globalShaderPreset, preset.id);
final changed = _savedPreset.id != preset.id || _currentPreset.id != preset.id; final changed = _savedPreset.id != preset.id || _currentPreset.id != preset.id;
_savedPreset = preset; _savedPreset = preset;
_currentPreset = preset; _currentPreset = preset;
if (changed || _savedPresetListenable == null) { if (changed || _settingsBinding.settings == null) {
safeNotifyListeners(); safeNotifyListeners();
} }
} }
@@ -119,7 +103,7 @@ class ShaderProvider extends ChangeNotifier with DisposableChangeNotifierMixin {
} }
Future<void> _saveCustomPresets() async { Future<void> _saveCustomPresets() async {
final service = _settingsService ?? await SettingsService.getInstance(); final service = _settingsBinding.settings ?? await SettingsService.getInstance();
final data = _customPresets.map((p) => p.toJson()).toList(); final data = _customPresets.map((p) => p.toJson()).toList();
await service.write(SettingsService.customShaderPresets, data); await service.write(SettingsService.customShaderPresets, data);
} }
+13 -28
View File
@@ -1,14 +1,15 @@
import 'dart:async' show unawaited;
import 'dart:io' show Platform; import 'dart:io' show Platform;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import '../mixins/disposable_change_notifier_mixin.dart'; import '../mixins/disposable_change_notifier_mixin.dart';
import '../services/settings_binding_owner.dart';
import '../services/settings_service.dart' as settings; import '../services/settings_service.dart' as settings;
import '../theme/mono_theme.dart'; import '../theme/mono_theme.dart';
class ThemeProvider extends ChangeNotifier with DisposableChangeNotifierMixin { class ThemeProvider extends ChangeNotifier with DisposableChangeNotifierMixin {
settings.SettingsService? _settingsService; late final SettingsBindingOwner _settingsBinding;
ValueNotifier<settings.ThemeMode>? _themeModeListenable;
settings.ThemeMode _themeMode = settings.ThemeMode.system; settings.ThemeMode _themeMode = settings.ThemeMode.system;
late Brightness _systemBrightness; late Brightness _systemBrightness;
@@ -19,7 +20,11 @@ class ThemeProvider extends ChangeNotifier with DisposableChangeNotifierMixin {
// async path below lands a microtask too late for the first build. // async path below lands a microtask too late for the first build.
final loaded = settings.SettingsService.instanceOrNull; final loaded = settings.SettingsService.instanceOrNull;
if (loaded != null) _themeMode = loaded.read(settings.SettingsService.themeMode); if (loaded != null) _themeMode = loaded.read(settings.SettingsService.themeMode);
_initializeSettings(); _settingsBinding = SettingsBindingOwner(
prefs: const [settings.SettingsService.themeMode],
onRefresh: (service) => _syncThemeMode(service.read(settings.SettingsService.themeMode)),
);
unawaited(_settingsBinding.bind());
WidgetsBinding.instance.platformDispatcher.onPlatformBrightnessChanged = _onBrightnessChanged; WidgetsBinding.instance.platformDispatcher.onPlatformBrightnessChanged = _onBrightnessChanged;
} }
@@ -32,33 +37,13 @@ class ThemeProvider extends ChangeNotifier with DisposableChangeNotifierMixin {
@override @override
void dispose() { void dispose() {
_themeModeListenable?.removeListener(_onThemeModeSettingChanged); _settingsBinding.dispose();
if (WidgetsBinding.instance.platformDispatcher.onPlatformBrightnessChanged == _onBrightnessChanged) { if (WidgetsBinding.instance.platformDispatcher.onPlatformBrightnessChanged == _onBrightnessChanged) {
WidgetsBinding.instance.platformDispatcher.onPlatformBrightnessChanged = null; WidgetsBinding.instance.platformDispatcher.onPlatformBrightnessChanged = null;
} }
super.dispose(); super.dispose();
} }
Future<void> _initializeSettings() async {
final service = await settings.SettingsService.getInstance();
if (_settingsService == service && _themeModeListenable != null) {
_syncThemeMode(service.read(settings.SettingsService.themeMode));
return;
}
_themeModeListenable?.removeListener(_onThemeModeSettingChanged);
_settingsService = service;
_themeModeListenable = service.listenable(settings.SettingsService.themeMode)
..addListener(_onThemeModeSettingChanged);
_syncThemeMode(_themeModeListenable!.value);
}
void _onThemeModeSettingChanged() {
final listenable = _themeModeListenable;
if (listenable == null) return;
_syncThemeMode(listenable.value);
}
void _syncThemeMode(settings.ThemeMode mode, {bool forceNotify = false}) { void _syncThemeMode(settings.ThemeMode mode, {bool forceNotify = false}) {
final changed = _themeMode != mode; final changed = _themeMode != mode;
_themeMode = mode; _themeMode = mode;
@@ -106,14 +91,14 @@ class ThemeProvider extends ChangeNotifier with DisposableChangeNotifierMixin {
Future<void> setThemeMode(settings.ThemeMode mode) async { Future<void> setThemeMode(settings.ThemeMode mode) async {
if (_themeMode == mode) return; if (_themeMode == mode) return;
final service = _settingsService ?? await settings.SettingsService.getInstance(); final service = _settingsBinding.settings ?? await settings.SettingsService.getInstance();
await service.write(settings.SettingsService.themeMode, mode); await service.write(settings.SettingsService.themeMode, mode);
if (_themeModeListenable == null) _syncThemeMode(mode); if (_settingsBinding.settings == null) _syncThemeMode(mode);
} }
Future<void> reload() async { Future<void> reload() async {
await _initializeSettings(); await _settingsBinding.bind();
final service = _settingsService; final service = _settingsBinding.settings;
if (service != null) _syncThemeMode(service.read(settings.SettingsService.themeMode), forceNotify: true); if (service != null) _syncThemeMode(service.read(settings.SettingsService.themeMode), forceNotify: true);
} }
+26 -32
View File
@@ -6,6 +6,7 @@ import 'package:flutter/services.dart';
import '../models/hotkey_model.dart'; import '../models/hotkey_model.dart';
import '../i18n/strings.g.dart'; import '../i18n/strings.g.dart';
import '../mpv/mpv.dart'; import '../mpv/mpv.dart';
import 'settings_binding_owner.dart';
import 'settings_service.dart'; import 'settings_service.dart';
import '../utils/platform_detector.dart'; import '../utils/platform_detector.dart';
import '../utils/player_utils.dart'; import '../utils/player_utils.dart';
@@ -14,14 +15,26 @@ class KeyboardShortcutsService extends ChangeNotifier {
static const Set<String> _repeatableVideoActions = {'zoom_in', 'zoom_out'}; static const Set<String> _repeatableVideoActions = {'zoom_in', 'zoom_out'};
static KeyboardShortcutsService? _instance; static KeyboardShortcutsService? _instance;
late SettingsService _settingsService; late final SettingsBindingOwner _settingsBinding;
final List<VoidCallback> _settingsDisposers = [];
Map<String, HotKey> _hotkeys = {}; Map<String, HotKey> _hotkeys = {};
int _seekTimeSmall = 10; // Default, loaded from settings int _seekTimeSmall = 10; // Default, loaded from settings
int _seekTimeLarge = 30; // Default, loaded from settings int _seekTimeLarge = 30; // Default, loaded from settings
int _maxVolume = 100; // Default, loaded from settings (100-300%) int _maxVolume = 100; // Default, loaded from settings (100-300%)
bool _settingsInitialized = false;
KeyboardShortcutsService._(); KeyboardShortcutsService._() {
_settingsBinding = SettingsBindingOwner(
prefs: [
SettingsService.keyboardHotkeys,
SettingsService.seekTimeSmall,
SettingsService.seekTimeLarge,
SettingsService.maxVolume,
],
onRefresh: _syncFromSettings,
);
}
SettingsService get _settingsService => _settingsBinding.settings!;
static Future<KeyboardShortcutsService> getInstance() async { static Future<KeyboardShortcutsService> getInstance() async {
if (_instance == null) { if (_instance == null) {
@@ -37,32 +50,14 @@ class KeyboardShortcutsService extends ChangeNotifier {
} }
Future<void> _init() async { Future<void> _init() async {
_settingsService = await SettingsService.getInstance(); await _settingsBinding.bind();
_bindSettings();
_syncFromSettings(notify: false);
} }
void _bindSettings() { void _syncFromSettings(SettingsService service) {
if (_settingsDisposers.isNotEmpty) return; final hotkeys = service.read(SettingsService.keyboardHotkeys);
void bind<T>(Pref<T> pref) { final seekTimeSmall = service.read(SettingsService.seekTimeSmall);
final notifier = _settingsService.listenable(pref); final seekTimeLarge = service.read(SettingsService.seekTimeLarge);
notifier.addListener(_onSettingsChanged); final maxVolume = service.read(SettingsService.maxVolume);
_settingsDisposers.add(() => notifier.removeListener(_onSettingsChanged));
}
bind(SettingsService.keyboardHotkeys);
bind(SettingsService.seekTimeSmall);
bind(SettingsService.seekTimeLarge);
bind(SettingsService.maxVolume);
}
void _onSettingsChanged() => _syncFromSettings();
void _syncFromSettings({bool notify = true}) {
final hotkeys = _settingsService.read(SettingsService.keyboardHotkeys);
final seekTimeSmall = _settingsService.read(SettingsService.seekTimeSmall);
final seekTimeLarge = _settingsService.read(SettingsService.seekTimeLarge);
final maxVolume = _settingsService.read(SettingsService.maxVolume);
final changed = final changed =
!_hotkeyMapsEqual(_hotkeys, hotkeys) || !_hotkeyMapsEqual(_hotkeys, hotkeys) ||
@@ -75,6 +70,8 @@ class KeyboardShortcutsService extends ChangeNotifier {
_seekTimeLarge = seekTimeLarge; _seekTimeLarge = seekTimeLarge;
_maxVolume = maxVolume; _maxVolume = maxVolume;
final notify = _settingsInitialized;
_settingsInitialized = true;
if (notify && changed) notifyListeners(); if (notify && changed) notifyListeners();
} }
@@ -99,7 +96,7 @@ class KeyboardShortcutsService extends ChangeNotifier {
} }
Future<void> refreshFromStorage() async { Future<void> refreshFromStorage() async {
_syncFromSettings(); _settingsBinding.refresh();
} }
Future<void> resetToDefaults() async { Future<void> resetToDefaults() async {
@@ -109,10 +106,7 @@ class KeyboardShortcutsService extends ChangeNotifier {
@override @override
void dispose() { void dispose() {
for (final dispose in _settingsDisposers) { _settingsBinding.dispose();
dispose();
}
_settingsDisposers.clear();
if (identical(_instance, this)) _instance = null; if (identical(_instance, this)) _instance = null;
super.dispose(); super.dispose();
} }
+87
View File
@@ -0,0 +1,87 @@
import 'package:flutter/foundation.dart';
import 'settings_service.dart';
typedef SettingsServiceAcquirer = Future<SettingsService> Function();
/// Owns a group of preference listeners backed by one [SettingsService].
class SettingsBindingOwner {
factory SettingsBindingOwner({
required Iterable<Pref<Object?>> prefs,
required void Function(SettingsService service) onRefresh,
SettingsServiceAcquirer? acquireSettings,
}) => SettingsBindingOwner._(List.unmodifiable(prefs), onRefresh, acquireSettings);
SettingsBindingOwner._(this._prefs, this._onRefresh, this._acquireSettings);
final List<Pref<Object?>> _prefs;
final void Function(SettingsService service) _onRefresh;
final SettingsServiceAcquirer? _acquireSettings;
final List<Listenable> _listenables = [];
SettingsService? _settings;
Future<void>? _bindingFuture;
int _generation = 0;
bool _disposed = false;
SettingsService? get settings => _settings;
bool get isBound => !_disposed && _settings != null;
Future<void> bind() {
if (_disposed) return Future.value();
final pending = _bindingFuture;
if (pending != null) return pending;
final generation = ++_generation;
late final Future<void> future;
future = _bind(generation).whenComplete(() {
if (identical(_bindingFuture, future)) _bindingFuture = null;
});
_bindingFuture = future;
return future;
}
Future<void> _bind(int generation) async {
final acquirer = _acquireSettings;
final service = await (acquirer?.call() ?? SettingsService.getInstance());
if (_disposed || generation != _generation) return;
if (!identical(_settings, service)) {
_removeListeners();
_settings = service;
for (final pref in _prefs) {
final listenable = service.listenableOf(pref)..addListener(_handlePreferenceChanged);
_listenables.add(listenable);
}
}
_onRefresh(service);
}
void refresh() {
final service = _settings;
if (_disposed || service == null) return;
_onRefresh(service);
}
void _handlePreferenceChanged() {
final service = _settings;
if (_disposed || service == null) return;
_onRefresh(service);
}
void _removeListeners() {
for (final listenable in _listenables) {
listenable.removeListener(_handlePreferenceChanged);
}
_listenables.clear();
}
void dispose() {
if (_disposed) return;
_disposed = true;
_generation++;
_removeListeners();
}
}
@@ -0,0 +1,88 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/services/settings_binding_owner.dart';
import 'package:plezy/services/settings_service.dart';
import '../test_helpers/prefs.dart';
void main() {
setUp(() {
resetSharedPreferencesForTest();
SettingsService.resetForTesting();
});
test('refreshes when a bound preference changes', () async {
final settings = await SettingsService.getInstance();
final values = <int>[];
final binding = SettingsBindingOwner(
prefs: [SettingsService.maxVolume],
onRefresh: (service) => values.add(service.read(SettingsService.maxVolume)),
);
addTearDown(binding.dispose);
await binding.bind();
await settings.write(SettingsService.maxVolume, 175);
expect(values, [100, 175]);
});
test('disposal before initialization ignores the stale completion', () async {
final settings = await SettingsService.getInstance();
final acquired = Completer<SettingsService>();
var refreshes = 0;
final binding = SettingsBindingOwner(
prefs: [SettingsService.maxVolume],
onRefresh: (_) => refreshes++,
acquireSettings: () => acquired.future,
);
final initialized = binding.bind();
binding.dispose();
acquired.complete(settings);
await initialized;
await settings.write(SettingsService.maxVolume, 150);
expect(refreshes, 0);
expect(binding.isBound, isFalse);
});
test('duplicate binding shares initialization and registers one listener', () async {
final settings = await SettingsService.getInstance();
final acquired = Completer<SettingsService>();
var acquisitions = 0;
var refreshes = 0;
final binding = SettingsBindingOwner(
prefs: [SettingsService.maxVolume],
onRefresh: (_) => refreshes++,
acquireSettings: () {
acquisitions++;
return acquired.future;
},
);
addTearDown(binding.dispose);
final first = binding.bind();
final second = binding.bind();
acquired.complete(settings);
await Future.wait([first, second]);
await binding.bind();
await settings.write(SettingsService.maxVolume, 125);
expect(acquisitions, 2);
expect(refreshes, 3);
});
test('does not refresh after disposal', () async {
final settings = await SettingsService.getInstance();
var refreshes = 0;
final binding = SettingsBindingOwner(prefs: [SettingsService.maxVolume], onRefresh: (_) => refreshes++);
await binding.bind();
binding.dispose();
await settings.write(SettingsService.maxVolume, 200);
binding.refresh();
expect(refreshes, 1);
});
}