From edbe2a06be7dab31b4a1752600cb53a3fa3b2f26 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Mon, 23 Feb 2026 10:25:44 +0100 Subject: [PATCH] refactor: deduplicate sheet, key handler, and service patterns --- lib/focus/key_event_utils.dart | 56 +++++ lib/mixins/deletion_aware.dart | 34 +-- lib/mixins/event_aware.dart | 36 +++ lib/mixins/watch_state_aware.dart | 34 +-- lib/providers/settings_provider.dart | 111 ++++----- lib/screens/discover_screen.dart | 226 ++++-------------- lib/screens/libraries/libraries_screen.dart | 125 +++------- lib/screens/livetv/program_details_sheet.dart | 45 +--- .../base_shared_preferences_service.dart | 17 ++ lib/services/settings_service.dart | 20 +- lib/services/storage_service.dart | 21 +- lib/widgets/bottom_sheet_header.dart | 9 +- lib/widgets/media_context_menu.dart | 126 +++------- lib/widgets/overlay_sheet.dart | 67 ++++++ .../widgets/sleep_timer_duration_list.dart | 7 +- 15 files changed, 381 insertions(+), 553 deletions(-) create mode 100644 lib/mixins/event_aware.dart diff --git a/lib/focus/key_event_utils.dart b/lib/focus/key_event_utils.dart index f2370b35..e156388c 100644 --- a/lib/focus/key_event_utils.dart +++ b/lib/focus/key_event_utils.dart @@ -83,6 +83,62 @@ KeyEventResult handleBackKeyNavigation(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. /// diff --git a/lib/mixins/deletion_aware.dart b/lib/mixins/deletion_aware.dart index 542eafc1..f6313ad7 100644 --- a/lib/mixins/deletion_aware.dart +++ b/lib/mixins/deletion_aware.dart @@ -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 on State { @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( + notifier: DeletionNotifier(), + mounted: () => mounted, + serverId: () => deletionServerId, + globalKeys: () => deletionGlobalKeys, + ratingKeys: () => deletionRatingKeys, + onEvent: onDeletionEvent, + ); } @override diff --git a/lib/mixins/event_aware.dart b/lib/mixins/event_aware.dart new file mode 100644 index 00000000..b9b78792 --- /dev/null +++ b/lib/mixins/event_aware.dart @@ -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 subscribeToHierarchicalEvents({ + required BaseNotifier notifier, + required bool Function() mounted, + required String? Function() serverId, + required Set? Function() globalKeys, + required Set? 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); + } + }); +} diff --git a/lib/mixins/watch_state_aware.dart b/lib/mixins/watch_state_aware.dart index a0efe029..8fe7c11f 100644 --- a/lib/mixins/watch_state_aware.dart +++ b/lib/mixins/watch_state_aware.dart @@ -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 on State { @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( + notifier: WatchStateNotifier(), + mounted: () => mounted, + serverId: () => watchStateServerId, + globalKeys: () => watchedGlobalKeys, + ratingKeys: () => watchedRatingKeys, + onEvent: onWatchStateChanged, + ); } @override diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index 16fb09da..bf3cadde 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -59,77 +59,68 @@ class SettingsProvider extends ChangeNotifier { bool get showUnwatchedCount => _showUnwatchedCount; - Future setLibraryDensity(LibraryDensity density) async { + /// Helper to update a setting: ensures init, deduplicates, persists, notifies. + Future _updateSetting({ + required T current, + required T value, + required void Function(T) setLocal, + required Future 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 setViewMode(ViewMode mode) async { - if (!_isInitialized) await _initializeSettings(); - if (_viewMode != mode) { - _viewMode = mode; - await _settingsService!.setViewMode(mode); - notifyListeners(); - } - } + Future setLibraryDensity(LibraryDensity density) => _updateSetting( + current: _libraryDensity, value: density, + setLocal: (v) => _libraryDensity = v, + persist: _settingsService!.setLibraryDensity, + ); - Future setEpisodePosterMode(EpisodePosterMode mode) async { - if (!_isInitialized) await _initializeSettings(); - if (_episodePosterMode != mode) { - _episodePosterMode = mode; - await _settingsService!.setEpisodePosterMode(mode); - notifyListeners(); - } - } + Future setViewMode(ViewMode mode) => _updateSetting( + current: _viewMode, value: mode, + setLocal: (v) => _viewMode = v, + persist: _settingsService!.setViewMode, + ); - Future setShowHeroSection(bool value) async { - if (!_isInitialized) await _initializeSettings(); - if (_showHeroSection != value) { - _showHeroSection = value; - await _settingsService!.setShowHeroSection(value); - notifyListeners(); - } - } + Future setEpisodePosterMode(EpisodePosterMode mode) => _updateSetting( + current: _episodePosterMode, value: mode, + setLocal: (v) => _episodePosterMode = v, + persist: _settingsService!.setEpisodePosterMode, + ); - Future setUseGlobalHubs(bool value) async { - if (!_isInitialized) await _initializeSettings(); - if (_useGlobalHubs != value) { - _useGlobalHubs = value; - await _settingsService!.setUseGlobalHubs(value); - notifyListeners(); - } - } + Future setShowHeroSection(bool value) => _updateSetting( + current: _showHeroSection, value: value, + setLocal: (v) => _showHeroSection = v, + persist: _settingsService!.setShowHeroSection, + ); - Future setShowServerNameOnHubs(bool value) async { - if (!_isInitialized) await _initializeSettings(); - if (_showServerNameOnHubs != value) { - _showServerNameOnHubs = value; - await _settingsService!.setShowServerNameOnHubs(value); - notifyListeners(); - } - } + Future setUseGlobalHubs(bool value) => _updateSetting( + current: _useGlobalHubs, value: value, + setLocal: (v) => _useGlobalHubs = v, + persist: _settingsService!.setUseGlobalHubs, + ); - Future setAlwaysKeepSidebarOpen(bool value) async { - if (!_isInitialized) await _initializeSettings(); - if (_alwaysKeepSidebarOpen != value) { - _alwaysKeepSidebarOpen = value; - await _settingsService!.setAlwaysKeepSidebarOpen(value); - notifyListeners(); - } - } + Future setShowServerNameOnHubs(bool value) => _updateSetting( + current: _showServerNameOnHubs, value: value, + setLocal: (v) => _showServerNameOnHubs = v, + persist: _settingsService!.setShowServerNameOnHubs, + ); - Future setShowUnwatchedCount(bool value) async { - if (!_isInitialized) await _initializeSettings(); - if (_showUnwatchedCount != value) { - _showUnwatchedCount = value; - await _settingsService!.setShowUnwatchedCount(value); - notifyListeners(); - } - } + Future setAlwaysKeepSidebarOpen(bool value) => _updateSetting( + current: _alwaysKeepSidebarOpen, value: value, + setLocal: (v) => _alwaysKeepSidebarOpen = v, + persist: _settingsService!.setAlwaysKeepSidebarOpen, + ); + + Future setShowUnwatchedCount(bool value) => _updateSetting( + current: _showUnwatchedCount, value: value, + setLocal: (v) => _showUnwatchedCount = v, + persist: _settingsService!.setShowUnwatchedCount, + ); String get libraryDensityDisplayName { switch (_libraryDensity) { diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index 8a4ad358..1d54a293 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -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 } /// 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(); _showUserMenu(context, userProvider); - return KeyEventResult.handled; - } - - return KeyEventResult.ignored; - } + }, + ); @override void dispose() { diff --git a/lib/screens/libraries/libraries_screen.dart b/lib/screens/libraries/libraries_screen.dart index 9bd1d5dc..33e68e4a 100644 --- a/lib/screens/libraries/libraries_screen.dart +++ b/lib/screens/libraries/libraries_screen.dart @@ -351,61 +351,29 @@ class _LibrariesScreenState extends State } /// 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(); 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 _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( - 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( + 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( - 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 diff --git a/lib/screens/livetv/program_details_sheet.dart b/lib/screens/livetv/program_details_sheet.dart index fe3d2ce5..b1d72838 100644 --- a/lib/screens/livetv/program_details_sheet.dart +++ b/lib/screens/livetv/program_details_sheet.dart @@ -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 = []; 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; diff --git a/lib/services/base_shared_preferences_service.dart b/lib/services/base_shared_preferences_service.dart index 0530fd23..b986e344 100644 --- a/lib/services/base_shared_preferences_service.dart +++ b/lib/services/base_shared_preferences_service.dart @@ -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 decodeJsonStringToMap(String jsonString, {bool legacyStringOk = false}) { + try { + return json.decode(jsonString) as Map; + } 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 diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart index c12c0199..cb68e9e5 100644 --- a/lib/services/settings_service.dart +++ b/lib/services/settings_service.dart @@ -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 _decodeJsonStringToMap(String jsonString) { - try { - return json.decode(jsonString) as Map; - } catch (e) { - return {}; - } - } - // App Locale Future 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 decoded = json.decode(jsonString); return decoded.map((e) => ExternalPlayer.fromJson(e as Map)).toList(); - } catch (_) { + } catch (e) { + appLogger.d('Failed to parse custom external players', error: e); return []; } } diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart index 3cd97dea..4abf0138 100644 --- a/lib/services/storage_service.dart +++ b/lib/services/storage_service.dart @@ -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 _decodeJsonStringToMap(String jsonString, {bool legacyStringOk = false}) { - try { - return json.decode(jsonString) as Map; - } 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 diff --git a/lib/widgets/bottom_sheet_header.dart b/lib/widgets/bottom_sheet_header.dart index 1c1a48cb..534a48ca 100644 --- a/lib/widgets/bottom_sheet_header.dart +++ b/lib/widgets/bottom_sheet_header.dart @@ -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), ), ), ], diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index 768e9246..c8e05810 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -309,26 +309,14 @@ class MediaContextMenuState extends State { _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( - builder: (context) => _FocusableContextMenuSheet( - title: widget.item.title, - actions: menuActions, - focusFirstItem: openedFromKeyboard, - ), - ); - } else { - selected = await showModalBottomSheet( - context: context, - builder: (context) => _FocusableContextMenuSheet( - title: widget.item.title, - actions: menuActions, - focusFirstItem: openedFromKeyboard, - ), - ); - } + selected = await OverlaySheetController.showAdaptive( + 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 { 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 { String? selected; if (useBottomSheet) { - final overlayController = OverlaySheetController.maybeOf(context); - if (overlayController != null) { - selected = await overlayController.push( - 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( + 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( - 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( 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, ); }), diff --git a/lib/widgets/overlay_sheet.dart b/lib/widgets/overlay_sheet.dart index 7464843b..856b99d9 100644 --- a/lib/widgets/overlay_sheet.dart +++ b/lib/widgets/overlay_sheet.dart @@ -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 showAdaptive( + 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( + builder: builder, + constraints: constraints, + backgroundColor: backgroundColor, + barrierDismissible: barrierDismissible, + initialFocusNode: initialFocusNode, + ); + } + return showModalBottomSheet( + 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 pushAdaptive( + BuildContext context, { + required WidgetBuilder builder, + FocusNode? initialFocusNode, + }) { + final controller = maybeOf(context); + if (controller != null) { + return controller.push(builder: builder, initialFocusNode: initialFocusNode); + } + return showModalBottomSheet(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. diff --git a/lib/widgets/video_controls/widgets/sleep_timer_duration_list.dart b/lib/widgets/video_controls/widgets/sleep_timer_duration_list.dart index badf6cf6..a53b7813 100644 --- a/lib/widgets/video_controls/widgets/sleep_timer_duration_list.dart +++ b/lib/widgets/video_controls/widgets/sleep_timer_duration_list.dart @@ -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));