refactor: deduplicate sheet, key handler, and service patterns

This commit is contained in:
edde746
2026-02-23 10:25:57 +01:00
parent e9307c87f0
commit edbe2a06be
15 changed files with 381 additions and 553 deletions
+56
View File
@@ -83,6 +83,62 @@ KeyEventResult handleBackKeyNavigation<T>(BuildContext context, KeyEvent event,
return handleBackKeyAction(event, () => Navigator.pop(context, result));
}
/// Creates a [FocusOnKeyEventCallback] that dispatches d-pad / arrow keys to
/// the provided directional callbacks.
///
/// Each callback is optional. Directions without a callback are ignored
/// (passed through to the framework). Directions mapped to a callback
/// automatically return [KeyEventResult.handled].
///
/// Only [KeyDownEvent] and [KeyRepeatEvent] are handled (via [isActionable]).
///
/// ```dart
/// Focus(
/// onKeyEvent: dpadKeyHandler(
/// onUp: () => _focusAppBar(),
/// onDown: () => _focusContent(),
/// onLeft: () => _navigateToSidebar(),
/// onSelect: () => _play(),
/// ),
/// child: ...
/// )
/// ```
FocusOnKeyEventCallback dpadKeyHandler({
VoidCallback? onUp,
VoidCallback? onDown,
VoidCallback? onLeft,
VoidCallback? onRight,
VoidCallback? onSelect,
}) {
return (FocusNode _, KeyEvent event) {
if (!event.isActionable) return KeyEventResult.ignored;
final key = event.logicalKey;
if (key.isUpKey && onUp != null) {
onUp();
return KeyEventResult.handled;
}
if (key.isDownKey && onDown != null) {
onDown();
return KeyEventResult.handled;
}
if (key.isLeftKey && onLeft != null) {
onLeft();
return KeyEventResult.handled;
}
if (key.isRightKey && onRight != null) {
onRight();
return KeyEventResult.handled;
}
if (key.isSelectKey && onSelect != null) {
onSelect();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
};
}
/// Navigator observer that automatically suppresses stray back KeyUp events
/// after any route pop caused by a back key press.
///
+9 -25
View File
@@ -1,6 +1,7 @@
import 'dart:async';
import 'package:flutter/material.dart';
import '../utils/deletion_notifier.dart';
import 'event_aware.dart';
/// Mixin for screens that need to react to deletion events.
///
@@ -56,31 +57,14 @@ mixin DeletionAware<T extends StatefulWidget> on State<T> {
@override
void initState() {
super.initState();
_subscribeToDeletions();
}
void _subscribeToDeletions() {
_deletionSubscription = DeletionNotifier().stream.listen((event) {
if (!mounted) return;
final serverId = deletionServerId;
if (serverId != null && event.serverId != serverId) return;
final globalKeys = deletionGlobalKeys;
if (globalKeys != null) {
if (event.affectsAnyGlobalKey(globalKeys)) {
onDeletionEvent(event);
}
return;
}
final ratingKeys = deletionRatingKeys;
// If keys is null, receive all events
// Otherwise, filter to events that affect our keys
if (ratingKeys == null || event.affectsAnyOf(ratingKeys)) {
onDeletionEvent(event);
}
});
_deletionSubscription = subscribeToHierarchicalEvents<DeletionEvent>(
notifier: DeletionNotifier(),
mounted: () => mounted,
serverId: () => deletionServerId,
globalKeys: () => deletionGlobalKeys,
ratingKeys: () => deletionRatingKeys,
onEvent: onDeletionEvent,
);
}
@override
+36
View File
@@ -0,0 +1,36 @@
import 'dart:async';
import '../utils/base_notifier.dart';
import '../utils/hierarchical_event_mixin.dart';
/// Creates a filtered stream subscription for hierarchical events.
///
/// Used internally by [DeletionAware] and [WatchStateAware] to avoid
/// duplicating the subscription and filtering logic.
StreamSubscription<E> subscribeToHierarchicalEvents<E extends HierarchicalEventMixin>({
required BaseNotifier<E> notifier,
required bool Function() mounted,
required String? Function() serverId,
required Set<String>? Function() globalKeys,
required Set<String>? Function() ratingKeys,
required void Function(E event) onEvent,
}) {
return notifier.stream.listen((event) {
if (!mounted()) return;
final sid = serverId();
if (sid != null && event.serverId != sid) return;
final gk = globalKeys();
if (gk != null) {
if (event.affectsAnyGlobalKey(gk)) {
onEvent(event);
}
return;
}
final rk = ratingKeys();
if (rk == null || event.affectsAnyOf(rk)) {
onEvent(event);
}
});
}
+9 -25
View File
@@ -1,6 +1,7 @@
import 'dart:async';
import 'package:flutter/material.dart';
import '../utils/watch_state_notifier.dart';
import 'event_aware.dart';
/// Mixin for screens that need to react to watch state changes.
///
@@ -55,31 +56,14 @@ mixin WatchStateAware<T extends StatefulWidget> on State<T> {
@override
void initState() {
super.initState();
_subscribeToWatchState();
}
void _subscribeToWatchState() {
_watchStateSubscription = WatchStateNotifier().stream.listen((event) {
if (!mounted) return;
final serverId = watchStateServerId;
if (serverId != null && event.serverId != serverId) return;
final globalKeys = watchedGlobalKeys;
if (globalKeys != null) {
if (event.affectsAnyGlobalKey(globalKeys)) {
onWatchStateChanged(event);
}
return;
}
final keys = watchedRatingKeys;
// If keys is null, receive all events
// Otherwise, filter to events that affect our keys
if (keys == null || event.affectsAnyOf(keys)) {
onWatchStateChanged(event);
}
});
_watchStateSubscription = subscribeToHierarchicalEvents<WatchStateEvent>(
notifier: WatchStateNotifier(),
mounted: () => mounted,
serverId: () => watchStateServerId,
globalKeys: () => watchedGlobalKeys,
ratingKeys: () => watchedRatingKeys,
onEvent: onWatchStateChanged,
);
}
@override
+51 -60
View File
@@ -59,77 +59,68 @@ class SettingsProvider extends ChangeNotifier {
bool get showUnwatchedCount => _showUnwatchedCount;
Future<void> setLibraryDensity(LibraryDensity density) async {
/// Helper to update a setting: ensures init, deduplicates, persists, notifies.
Future<void> _updateSetting<T>({
required T current,
required T value,
required void Function(T) setLocal,
required Future<void> Function(T) persist,
}) async {
if (!_isInitialized) await _initializeSettings();
if (_libraryDensity != density) {
_libraryDensity = density;
await _settingsService!.setLibraryDensity(density);
if (current != value) {
setLocal(value);
await persist(value);
notifyListeners();
}
}
Future<void> setViewMode(ViewMode mode) async {
if (!_isInitialized) await _initializeSettings();
if (_viewMode != mode) {
_viewMode = mode;
await _settingsService!.setViewMode(mode);
notifyListeners();
}
}
Future<void> setLibraryDensity(LibraryDensity density) => _updateSetting(
current: _libraryDensity, value: density,
setLocal: (v) => _libraryDensity = v,
persist: _settingsService!.setLibraryDensity,
);
Future<void> setEpisodePosterMode(EpisodePosterMode mode) async {
if (!_isInitialized) await _initializeSettings();
if (_episodePosterMode != mode) {
_episodePosterMode = mode;
await _settingsService!.setEpisodePosterMode(mode);
notifyListeners();
}
}
Future<void> setViewMode(ViewMode mode) => _updateSetting(
current: _viewMode, value: mode,
setLocal: (v) => _viewMode = v,
persist: _settingsService!.setViewMode,
);
Future<void> setShowHeroSection(bool value) async {
if (!_isInitialized) await _initializeSettings();
if (_showHeroSection != value) {
_showHeroSection = value;
await _settingsService!.setShowHeroSection(value);
notifyListeners();
}
}
Future<void> setEpisodePosterMode(EpisodePosterMode mode) => _updateSetting(
current: _episodePosterMode, value: mode,
setLocal: (v) => _episodePosterMode = v,
persist: _settingsService!.setEpisodePosterMode,
);
Future<void> setUseGlobalHubs(bool value) async {
if (!_isInitialized) await _initializeSettings();
if (_useGlobalHubs != value) {
_useGlobalHubs = value;
await _settingsService!.setUseGlobalHubs(value);
notifyListeners();
}
}
Future<void> setShowHeroSection(bool value) => _updateSetting(
current: _showHeroSection, value: value,
setLocal: (v) => _showHeroSection = v,
persist: _settingsService!.setShowHeroSection,
);
Future<void> setShowServerNameOnHubs(bool value) async {
if (!_isInitialized) await _initializeSettings();
if (_showServerNameOnHubs != value) {
_showServerNameOnHubs = value;
await _settingsService!.setShowServerNameOnHubs(value);
notifyListeners();
}
}
Future<void> setUseGlobalHubs(bool value) => _updateSetting(
current: _useGlobalHubs, value: value,
setLocal: (v) => _useGlobalHubs = v,
persist: _settingsService!.setUseGlobalHubs,
);
Future<void> setAlwaysKeepSidebarOpen(bool value) async {
if (!_isInitialized) await _initializeSettings();
if (_alwaysKeepSidebarOpen != value) {
_alwaysKeepSidebarOpen = value;
await _settingsService!.setAlwaysKeepSidebarOpen(value);
notifyListeners();
}
}
Future<void> setShowServerNameOnHubs(bool value) => _updateSetting(
current: _showServerNameOnHubs, value: value,
setLocal: (v) => _showServerNameOnHubs = v,
persist: _settingsService!.setShowServerNameOnHubs,
);
Future<void> setShowUnwatchedCount(bool value) async {
if (!_isInitialized) await _initializeSettings();
if (_showUnwatchedCount != value) {
_showUnwatchedCount = value;
await _settingsService!.setShowUnwatchedCount(value);
notifyListeners();
}
}
Future<void> setAlwaysKeepSidebarOpen(bool value) => _updateSetting(
current: _alwaysKeepSidebarOpen, value: value,
setLocal: (v) => _alwaysKeepSidebarOpen = v,
persist: _settingsService!.setAlwaysKeepSidebarOpen,
);
Future<void> setShowUnwatchedCount(bool value) => _updateSetting(
current: _showUnwatchedCount, value: value,
setLocal: (v) => _showUnwatchedCount = v,
persist: _settingsService!.setShowUnwatchedCount,
);
String get libraryDensityDisplayName {
switch (_libraryDensity) {
+42 -184
View File
@@ -5,7 +5,7 @@ import 'package:flutter/material.dart';
import 'package:plezy/widgets/app_icon.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import '../focus/dpad_navigator.dart';
import '../focus/key_event_utils.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../../services/plex_client.dart';
import '../utils/plex_image_helper.dart';
@@ -306,211 +306,69 @@ class _DiscoverScreenState extends State<DiscoverScreen>
}
/// Handle key events for the hero section
KeyEventResult _handleHeroKeyEvent(FocusNode _, KeyEvent event) {
if (!event.isActionable) {
return KeyEventResult.ignored;
}
final key = event.logicalKey;
// DOWN: Move to first hub
if (key.isDownKey) {
late final _handleHeroKeyEvent = dpadKeyHandler(
onDown: () {
final keys = _allHubKeys;
if (keys.isNotEmpty) {
keys.first.currentState?.requestFocusFromMemory();
}
return KeyEventResult.handled;
}
// UP: Move to app bar (refresh button)
if (key.isUpKey) {
_refreshButtonFocusNode.requestFocus();
return KeyEventResult.handled;
}
// LEFT: Navigate hero carousel to previous, or focus sidebar at index 0
if (key.isLeftKey) {
if (keys.isNotEmpty) keys.first.currentState?.requestFocusFromMemory();
},
onUp: () => _refreshButtonFocusNode.requestFocus(),
onLeft: () {
if (_currentHeroIndex > 0) {
_heroController.previousPage(duration: tokens(context).slow, curve: Curves.easeInOut);
} else {
_navigateToSidebar();
}
return KeyEventResult.handled;
}
// RIGHT: Navigate hero carousel to next
if (key.isRightKey) {
},
onRight: () {
if (_currentHeroIndex < _onDeck.length - 1) {
_heroController.nextPage(duration: tokens(context).slow, curve: Curves.easeInOut);
}
return KeyEventResult.handled;
}
// SELECT: Play current hero item
if (key.isSelectKey) {
},
onSelect: () {
if (_onDeck.isNotEmpty && _currentHeroIndex < _onDeck.length) {
navigateToVideoPlayer(context, metadata: _onDeck[_currentHeroIndex]);
}
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
},
);
/// Handle key events for the refresh button in app bar
KeyEventResult _handleRefreshKeyEvent(FocusNode _, KeyEvent event) {
if (!event.isActionable) {
return KeyEventResult.ignored;
}
final key = event.logicalKey;
// DOWN: Return to hero/content
if (key.isDownKey) {
_focusContentFromAppBar();
return KeyEventResult.handled;
}
// RIGHT: Move to watch together button
if (key.isRightKey) {
_watchTogetherButtonFocusNode.requestFocus();
return KeyEventResult.handled;
}
// LEFT: Navigate to sidebar
if (key.isLeftKey) {
_navigateToSidebar();
return KeyEventResult.handled;
}
// UP: Block at boundary
if (key.isUpKey) {
return KeyEventResult.handled;
}
// SELECT: Trigger refresh
if (key.isSelectKey) {
_loadContent();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
late final _handleRefreshKeyEvent = dpadKeyHandler(
onDown: _focusContentFromAppBar,
onRight: () => _watchTogetherButtonFocusNode.requestFocus(),
onLeft: _navigateToSidebar,
onUp: () {}, // Block at boundary
onSelect: _loadContent,
);
/// Handle key events for the watch together button in app bar
KeyEventResult _handleWatchTogetherKeyEvent(FocusNode _, KeyEvent event) {
if (!event.isActionable) {
return KeyEventResult.ignored;
}
final key = event.logicalKey;
// DOWN: Return to hero/content
if (key.isDownKey) {
_focusContentFromAppBar();
return KeyEventResult.handled;
}
// LEFT: Move to refresh button
if (key.isLeftKey) {
_refreshButtonFocusNode.requestFocus();
return KeyEventResult.handled;
}
// RIGHT: Move to companion remote button
if (key.isRightKey) {
_companionRemoteButtonFocusNode.requestFocus();
return KeyEventResult.handled;
}
// UP: Block at boundary
if (key.isUpKey) {
return KeyEventResult.handled;
}
// SELECT: Navigate to Watch Together screen
if (key.isSelectKey) {
Navigator.push(context, MaterialPageRoute(builder: (_) => const WatchTogetherScreen()));
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
late final _handleWatchTogetherKeyEvent = dpadKeyHandler(
onDown: _focusContentFromAppBar,
onLeft: () => _refreshButtonFocusNode.requestFocus(),
onRight: () => _companionRemoteButtonFocusNode.requestFocus(),
onUp: () {}, // Block at boundary
onSelect: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const WatchTogetherScreen())),
);
/// Handle key events for the companion remote button in app bar
KeyEventResult _handleCompanionRemoteKeyEvent(FocusNode _, KeyEvent event) {
if (!event.isActionable) {
return KeyEventResult.ignored;
}
final key = event.logicalKey;
// DOWN: Return to hero
if (key.isDownKey) {
_heroFocusNode.requestFocus();
return KeyEventResult.handled;
}
// LEFT: Move to watch together button
if (key.isLeftKey) {
_watchTogetherButtonFocusNode.requestFocus();
return KeyEventResult.handled;
}
// RIGHT: Move to user button
if (key.isRightKey) {
_userButtonFocusNode.requestFocus();
return KeyEventResult.handled;
}
// UP: Block at boundary
if (key.isUpKey) {
return KeyEventResult.handled;
}
// SELECT: Show companion remote dialog (host a remote session)
if (key.isSelectKey) {
RemoteSessionDialog.show(context);
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
late final _handleCompanionRemoteKeyEvent = dpadKeyHandler(
onDown: () => _heroFocusNode.requestFocus(),
onLeft: () => _watchTogetherButtonFocusNode.requestFocus(),
onRight: () => _userButtonFocusNode.requestFocus(),
onUp: () {}, // Block at boundary
onSelect: () => RemoteSessionDialog.show(context),
);
/// Handle key events for the user button in app bar
KeyEventResult _handleUserKeyEvent(FocusNode _, KeyEvent event) {
if (!event.isActionable) {
return KeyEventResult.ignored;
}
final key = event.logicalKey;
// DOWN: Return to hero/content
if (key.isDownKey) {
_focusContentFromAppBar();
return KeyEventResult.handled;
}
// LEFT: Move to companion remote button
if (key.isLeftKey) {
_companionRemoteButtonFocusNode.requestFocus();
return KeyEventResult.handled;
}
// RIGHT/UP: Block at boundary
if (key.isRightKey || key.isUpKey) {
return KeyEventResult.handled;
}
// SELECT: Show user menu
if (key.isSelectKey) {
late final _handleUserKeyEvent = dpadKeyHandler(
onDown: _focusContentFromAppBar,
onLeft: () => _companionRemoteButtonFocusNode.requestFocus(),
onRight: () {}, // Block at boundary
onUp: () {}, // Block at boundary
onSelect: () {
final userProvider = context.read<UserProfileProvider>();
_showUserMenu(context, userProvider);
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
},
);
@override
void dispose() {
+34 -91
View File
@@ -351,61 +351,29 @@ class _LibrariesScreenState extends State<LibrariesScreen>
}
/// Handle key events for the edit button in app bar
KeyEventResult _handleEditKeyEvent(FocusNode _, KeyEvent event) {
if (!event.isActionable) return KeyEventResult.ignored;
final key = event.logicalKey;
if (key.isLeftKey) {
// Navigate back to last tab (Playlists)
getTabChipFocusNode(3).requestFocus();
return KeyEventResult.handled;
}
if (key.isRightKey) {
_refreshButtonFocusNode.requestFocus();
return KeyEventResult.handled;
}
if (key.isDownKey) {
_focusCurrentTab();
return KeyEventResult.handled;
}
if (key.isUpKey) {
return KeyEventResult.handled; // Block at boundary
}
if (key.isSelectKey) {
_showLibraryManagementSheet();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
late final _handleEditKeyEvent = dpadKeyHandler(
onLeft: () => getTabChipFocusNode(3).requestFocus(),
onRight: () => _refreshButtonFocusNode.requestFocus(),
onDown: _focusCurrentTab,
onUp: () {}, // Block at boundary
onSelect: _showLibraryManagementSheet,
);
/// Handle key events for the refresh button in app bar
KeyEventResult _handleRefreshKeyEvent(FocusNode _, KeyEvent event) {
if (!event.isActionable) return KeyEventResult.ignored;
final key = event.logicalKey;
if (key.isLeftKey) {
// Navigate to edit button if libraries exist, else to last tab
late final _handleRefreshKeyEvent = dpadKeyHandler(
onLeft: () {
final librariesProvider = context.read<LibrariesProvider>();
if (librariesProvider.libraries.isNotEmpty) {
_editButtonFocusNode.requestFocus();
} else {
getTabChipFocusNode(3).requestFocus();
}
return KeyEventResult.handled;
}
if (key.isRightKey || key.isUpKey) {
return KeyEventResult.handled; // Block at boundary
}
if (key.isDownKey) {
_focusCurrentTab();
return KeyEventResult.handled;
}
if (key.isSelectKey) {
_refreshCurrentTab();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
},
onRight: () {}, // Block at boundary
onUp: () {}, // Block at boundary
onDown: _focusCurrentTab,
onSelect: _refreshCurrentTab,
);
@override
void dispose() {
@@ -1422,52 +1390,27 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> {
Future<void> _showLibraryMenuBottomSheet(BuildContext outerContext, PlexLibrary library) async {
final menuItems = widget.getLibraryMenuItems(library);
final controller = OverlaySheetController.maybeOf(outerContext);
final String? selected;
if (controller != null) {
selected = await controller.push<String>(
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Text(library.title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
final selected = await OverlaySheetController.pushAdaptive<String>(
outerContext,
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Text(library.title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
),
...menuItems.indexed.map(
(entry) => ListTile(
leading: AppIcon(entry.$2.icon, fill: 1),
title: Text(entry.$2.label),
onTap: () => OverlaySheetController.popAdaptive(context, entry.$2.value),
),
...menuItems.indexed.map(
(entry) => ListTile(
leading: AppIcon(entry.$2.icon, fill: 1),
title: Text(entry.$2.label),
onTap: () => controller.pop(entry.$2.value),
),
),
],
),
),
],
),
);
} else {
selected = await showModalBottomSheet<String>(
context: outerContext,
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Text(library.title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
),
...menuItems.indexed.map(
(entry) => ListTile(
leading: AppIcon(entry.$2.icon, fill: 1),
title: Text(entry.$2.label),
onTap: () => Navigator.pop(context, entry.$2.value),
),
),
],
),
),
);
}
),
);
if (selected != null && mounted) {
// Find the selected item to check if confirmation is needed
+12 -33
View File
@@ -18,31 +18,17 @@ void showProgramDetailsSheet(
required String? posterUrl,
required VoidCallback? onTuneChannel,
}) {
final controller = OverlaySheetController.maybeOf(context);
if (controller != null) {
controller.show(
builder: (sheetContext) {
return _ProgramDetailsSheetContent(
program: program,
channel: channel,
posterUrl: posterUrl,
onTuneChannel: onTuneChannel,
);
},
);
} else {
showModalBottomSheet(
context: context,
builder: (sheetContext) {
return _ProgramDetailsSheetContent(
program: program,
channel: channel,
posterUrl: posterUrl,
onTuneChannel: onTuneChannel,
);
},
);
}
OverlaySheetController.showAdaptive(
context,
builder: (sheetContext) {
return _ProgramDetailsSheetContent(
program: program,
channel: channel,
posterUrl: posterUrl,
onTuneChannel: onTuneChannel,
);
},
);
}
class _ProgramDetailsSheetContent extends StatefulWidget {
@@ -107,14 +93,7 @@ class _ProgramDetailsSheetContentState extends State<_ProgramDetailsSheetContent
final buttons = <Widget>[];
int buttonIndex = 0;
void closeSheet() {
final controller = OverlaySheetController.maybeOf(context);
if (controller != null) {
controller.close();
} else {
Navigator.of(context).pop();
}
}
void closeSheet() => OverlaySheetController.closeAdaptive(context);
if (program.isCurrentlyAiring && widget.onTuneChannel != null) {
final idx = buttonIndex;
@@ -1,3 +1,4 @@
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
/// Base class for services that use SharedPreferences singleton pattern.
@@ -33,6 +34,22 @@ abstract class BaseSharedPreferencesService {
return _instances[T] as T;
}
/// Decode a JSON string to a Map with error handling.
///
/// If [legacyStringOk] is true and the value is a plain string (not valid
/// JSON), returns `{'key': jsonString, 'descending': false}` for legacy
/// library sort compatibility.
Map<String, dynamic> decodeJsonStringToMap(String jsonString, {bool legacyStringOk = false}) {
try {
return json.decode(jsonString) as Map<String, dynamic>;
} catch (e) {
if (legacyStringOk) {
return {'key': jsonString, 'descending': false};
}
return {};
}
}
/// Hook for subclass-specific initialization after SharedPreferences is ready.
///
/// Override this method to perform any setup that requires access to
+7 -13
View File
@@ -430,7 +430,7 @@ class SettingsService extends BaseSharedPreferencesService {
final jsonString = prefs.getString(_keyKeyboardShortcuts);
if (jsonString == null) return getDefaultKeyboardShortcuts();
final decoded = _decodeJsonStringToMap(jsonString);
final decoded = decodeJsonStringToMap(jsonString);
if (decoded.isEmpty) return getDefaultKeyboardShortcuts();
final shortcuts = decoded.map((key, value) => MapEntry(key, value.toString()));
@@ -494,6 +494,7 @@ class SettingsService extends BaseSharedPreferencesService {
return result;
} catch (e) {
appLogger.d('Failed to parse keyboard hotkeys', error: e);
return getDefaultKeyboardHotkeys();
}
}
@@ -804,19 +805,10 @@ class SettingsService extends BaseSharedPreferencesService {
final jsonString = prefs.getString(_keyMediaVersionPreferences);
if (jsonString == null) return {};
final decoded = _decodeJsonStringToMap(jsonString);
final decoded = decodeJsonStringToMap(jsonString);
return decoded.map((key, value) => MapEntry(key, value as int));
}
/// Helper to decode JSON string to Map with error handling
Map<String, dynamic> _decodeJsonStringToMap(String jsonString) {
try {
return json.decode(jsonString) as Map<String, dynamic>;
} catch (e) {
return {};
}
}
// App Locale
Future<void> setAppLocale(AppLocale locale) async {
await prefs.setString(_keyAppLocale, locale.languageCode);
@@ -1080,7 +1072,8 @@ class SettingsService extends BaseSharedPreferencesService {
if (jsonString == null) return KnownPlayers.systemDefault;
try {
return ExternalPlayer.fromJsonString(jsonString);
} catch (_) {
} catch (e) {
appLogger.d('Failed to parse external player', error: e);
return KnownPlayers.systemDefault;
}
}
@@ -1096,7 +1089,8 @@ class SettingsService extends BaseSharedPreferencesService {
try {
final List<dynamic> decoded = json.decode(jsonString);
return decoded.map((e) => ExternalPlayer.fromJson(e as Map<String, dynamic>)).toList();
} catch (_) {
} catch (e) {
appLogger.d('Failed to parse custom external players', error: e);
return [];
}
}
+2 -19
View File
@@ -131,7 +131,7 @@ class StorageService extends BaseSharedPreferencesService {
prefs.getString(_keyLibraryFilters);
if (jsonString == null) return {};
final decoded = _decodeJsonStringToMap(jsonString);
final decoded = decodeJsonStringToMap(jsonString);
return decoded.map((key, value) => MapEntry(key, value.toString()));
}
@@ -308,24 +308,7 @@ class StorageService extends BaseSharedPreferencesService {
final jsonString = prefs.getString(key);
if (jsonString == null) return null;
return _decodeJsonStringToMap(jsonString, legacyStringOk: legacyStringOk);
}
/// Helper to decode JSON string to Map with error handling
///
/// [jsonString] - The JSON string to decode
/// [legacyStringOk] - If true, returns {'key': value, 'descending': false}
/// when value is a plain string (for legacy library sort)
Map<String, dynamic> _decodeJsonStringToMap(String jsonString, {bool legacyStringOk = false}) {
try {
return json.decode(jsonString) as Map<String, dynamic>;
} catch (e) {
if (legacyStringOk) {
// Legacy support: if it's just a string, return it as the key
return {'key': jsonString, 'descending': false};
}
return {};
}
return decodeJsonStringToMap(jsonString, legacyStringOk: legacyStringOk);
}
/// Remove all keys matching a prefix
+1 -8
View File
@@ -99,14 +99,7 @@ class BottomSheetHeader extends StatelessWidget {
child: IconButton(
focusNode: closeFocusNode,
icon: AppIcon(Symbols.close_rounded, fill: 1, color: iconColor),
onPressed: onClose ?? () {
final sheetController = OverlaySheetController.maybeOf(context);
if (sheetController != null) {
sheetController.close();
} else {
Navigator.pop(context);
}
},
onPressed: onClose ?? () => OverlaySheetController.closeAdaptive(context),
),
),
],
+37 -89
View File
@@ -309,26 +309,14 @@ class MediaContextMenuState extends State<MediaContextMenu> {
_openedFromKeyboard = false;
if (useBottomSheet) {
// Show overlay sheet if available, otherwise fall back to modal bottom sheet
final overlayController = OverlaySheetController.maybeOf(context);
if (overlayController != null) {
selected = await overlayController.show<String>(
builder: (context) => _FocusableContextMenuSheet(
title: widget.item.title,
actions: menuActions,
focusFirstItem: openedFromKeyboard,
),
);
} else {
selected = await showModalBottomSheet<String>(
context: context,
builder: (context) => _FocusableContextMenuSheet(
title: widget.item.title,
actions: menuActions,
focusFirstItem: openedFromKeyboard,
),
);
}
selected = await OverlaySheetController.showAdaptive<String>(
context,
builder: (context) => _FocusableContextMenuSheet(
title: widget.item.title,
actions: menuActions,
focusFirstItem: openedFromKeyboard,
),
);
} else {
// Show custom focusable popup menu on larger screens
// Use stored tap position or fallback to widget position
@@ -580,19 +568,12 @@ class MediaContextMenuState extends State<MediaContextMenu> {
if (fileInfo != null && context.mounted) {
// Show file info bottom sheet
final overlayController = OverlaySheetController.maybeOf(context);
if (overlayController != null) {
await overlayController.show(
builder: (context) => FileInfoBottomSheet(fileInfo: fileInfo, title: metadata.title),
);
} else {
await showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => FileInfoBottomSheet(fileInfo: fileInfo, title: metadata.title),
);
}
await OverlaySheetController.showAdaptive(
context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => FileInfoBottomSheet(fileInfo: fileInfo, title: metadata.title),
);
} else if (context.mounted) {
showErrorSnackBar(context, t.messages.fileInfoNotAvailable);
}
@@ -634,55 +615,29 @@ class MediaContextMenuState extends State<MediaContextMenu> {
String? selected;
if (useBottomSheet) {
final overlayController = OverlaySheetController.maybeOf(context);
if (overlayController != null) {
selected = await overlayController.push<String>(
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Text(t.common.addTo, style: Theme.of(context).textTheme.titleMedium),
),
...submenuActions.map((action) {
return ListTile(
leading: AppIcon(action.icon, fill: 1),
title: Text(action.label),
onTap: () => overlayController.pop(action.value),
);
}),
const SizedBox(height: 8),
],
),
selected = await OverlaySheetController.pushAdaptive<String>(
context,
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Text(t.common.addTo, style: Theme.of(context).textTheme.titleMedium),
),
...submenuActions.map((action) {
return ListTile(
leading: AppIcon(action.icon, fill: 1),
title: Text(action.label),
onTap: () => OverlaySheetController.popAdaptive(context, action.value),
);
}),
const SizedBox(height: 8),
],
),
);
} else {
selected = await showModalBottomSheet<String>(
context: context,
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Text(t.common.addTo, style: Theme.of(context).textTheme.titleMedium),
),
...submenuActions.map((action) {
return ListTile(
leading: AppIcon(action.icon, fill: 1),
title: Text(action.label),
onTap: () => Navigator.pop(context, action.value),
);
}),
const SizedBox(height: 8),
],
),
),
);
}
),
);
} else {
selected = await showMenu<String>(
context: context,
@@ -1392,14 +1347,7 @@ class _FocusableContextMenuSheetState extends State<_FocusableContextMenuSheet>
focusNode: index == 0 ? _initialFocusNode : null,
leading: AppIcon(action.icon, fill: 1),
title: Text(action.label),
onTap: () {
final controller = OverlaySheetController.maybeOf(context);
if (controller != null) {
controller.close(action.value);
} else {
Navigator.pop(context, action.value);
}
},
onTap: () => OverlaySheetController.closeAdaptive(context, action.value),
hoverColor: action.hoverColor,
);
}),
+67
View File
@@ -87,6 +87,73 @@ class OverlaySheetController {
void refocus() {
_state._refocus();
}
/// Show a sheet using the overlay system if available, otherwise fall back
/// to [showModalBottomSheet]. Returns the result from the sheet.
static Future<T?> showAdaptive<T>(
BuildContext context, {
required WidgetBuilder builder,
BoxConstraints? constraints,
Color? backgroundColor,
bool barrierDismissible = true,
bool isScrollControlled = false,
FocusNode? initialFocusNode,
}) {
final controller = maybeOf(context);
if (controller != null) {
return controller.show<T>(
builder: builder,
constraints: constraints,
backgroundColor: backgroundColor,
barrierDismissible: barrierDismissible,
initialFocusNode: initialFocusNode,
);
}
return showModalBottomSheet<T>(
context: context,
builder: builder,
constraints: constraints,
backgroundColor: backgroundColor ?? Colors.grey[900],
barrierColor: Colors.black54,
isScrollControlled: isScrollControlled,
);
}
/// Push a sub-page using the overlay system if available, otherwise fall
/// back to [showModalBottomSheet]. Returns the result from the page.
static Future<T?> pushAdaptive<T>(
BuildContext context, {
required WidgetBuilder builder,
FocusNode? initialFocusNode,
}) {
final controller = maybeOf(context);
if (controller != null) {
return controller.push<T>(builder: builder, initialFocusNode: initialFocusNode);
}
return showModalBottomSheet<T>(context: context, builder: builder);
}
/// Close the sheet entirely. Uses overlay controller if available,
/// otherwise pops the route.
static void closeAdaptive(BuildContext context, [dynamic result]) {
final controller = maybeOf(context);
if (controller != null) {
controller.close(result);
} else {
Navigator.pop(context, result);
}
}
/// Pop one level (sub-page or close if last page). Uses overlay controller
/// if available, otherwise pops the route.
static void popAdaptive(BuildContext context, [dynamic result]) {
final controller = maybeOf(context);
if (controller != null) {
controller.pop(result);
} else {
Navigator.pop(context, result);
}
}
}
/// Host widget for the overlay-based bottom sheet system.
@@ -46,12 +46,7 @@ class SleepTimerDurationList extends StatelessWidget {
// Pause playback when timer completes
player.pause();
});
final sheetController = OverlaySheetController.maybeOf(context);
if (sheetController != null) {
sheetController.close();
} else {
Navigator.pop(context);
}
OverlaySheetController.closeAdaptive(context);
// Show confirmation snackbar
showSuccessSnackBar(context, t.messages.sleepTimerSet(label: label));