diff --git a/lib/focus/dpad_navigator.dart b/lib/focus/dpad_navigator.dart index 0cdc1544..5f1d633f 100644 --- a/lib/focus/dpad_navigator.dart +++ b/lib/focus/dpad_navigator.dart @@ -71,6 +71,10 @@ class SelectKeyUpSuppressor { _suppressSelectUntilKeyUp = true; } + static void clearSuppression() { + _suppressSelectUntilKeyUp = false; + } + static bool consumeIfSuppressed(KeyEvent event) { if (!_suppressSelectUntilKeyUp) return false; if (event.logicalKey.isSelectKey) { diff --git a/lib/screens/companion_remote/mobile_remote_screen.dart b/lib/screens/companion_remote/mobile_remote_screen.dart index 2f7ca391..677b70b7 100644 --- a/lib/screens/companion_remote/mobile_remote_screen.dart +++ b/lib/screens/companion_remote/mobile_remote_screen.dart @@ -8,6 +8,7 @@ import '../../i18n/strings.g.dart'; import '../../providers/companion_remote_provider.dart'; import '../../utils/platform_detector.dart'; import '../../utils/app_logger.dart'; +import '../../widgets/overlay_sheet.dart'; import 'pairing_screen.dart'; class MobileRemoteScreen extends StatefulWidget { @@ -20,101 +21,103 @@ class MobileRemoteScreen extends StatefulWidget { class _MobileRemoteScreenState extends State { @override Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: Text(t.companionRemote.title), - actions: [ - Consumer( - builder: (context, provider, child) { - if (provider.isConnected) { - return IconButton( - icon: const Icon(Icons.link_off), - onPressed: () async { - final confirmed = await showDialog( - context: context, - builder: (context) => AlertDialog( - title: Text(t.common.disconnect), - content: Text(t.companionRemote.remote.disconnectConfirm), - actions: [ - TextButton(onPressed: () => Navigator.pop(context, false), child: Text(t.common.cancel)), - TextButton(onPressed: () => Navigator.pop(context, true), child: Text(t.common.disconnect)), - ], - ), - ); + return OverlaySheetHost( + child: Scaffold( + appBar: AppBar( + title: Text(t.companionRemote.title), + actions: [ + Consumer( + builder: (context, provider, child) { + if (provider.isConnected) { + return IconButton( + icon: const Icon(Icons.link_off), + onPressed: () async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(t.common.disconnect), + content: Text(t.companionRemote.remote.disconnectConfirm), + actions: [ + TextButton(onPressed: () => Navigator.pop(context, false), child: Text(t.common.cancel)), + TextButton(onPressed: () => Navigator.pop(context, true), child: Text(t.common.disconnect)), + ], + ), + ); - if (confirmed == true && context.mounted) { - await context.read().leaveSession(); - } - }, - tooltip: t.common.disconnect, - ); - } - return const SizedBox.shrink(); - }, - ), - ], - ), - body: Consumer( - builder: (context, provider, child) { - if (provider.status == RemoteSessionStatus.reconnecting) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const CircularProgressIndicator(), - const SizedBox(height: 24), - Text(t.companionRemote.remote.reconnecting, style: Theme.of(context).textTheme.titleLarge), - const SizedBox(height: 8), - Text( - t.companionRemote.remote.attemptOf(current: provider.reconnectAttempts), - style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: Colors.grey), - ), - const SizedBox(height: 32), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - OutlinedButton(onPressed: () => provider.cancelReconnect(), child: Text(t.common.cancel)), - const SizedBox(width: 16), - FilledButton( - onPressed: () => provider.retryReconnectNow(), - child: Text(t.companionRemote.remote.retryNow), - ), - ], - ), - ], - ), - ); - } - - if (!provider.isConnected) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.phonelink_off, size: 64, color: Colors.grey), - const SizedBox(height: 16), - Text( - provider.status == RemoteSessionStatus.error - ? provider.session?.errorMessage ?? t.companionRemote.remote.connectionError - : t.companionRemote.remote.notConnected, - style: const TextStyle(fontSize: 20, color: Colors.grey), - textAlign: TextAlign.center, - ), - const SizedBox(height: 32), - FilledButton.icon( - onPressed: () { - Navigator.push(context, MaterialPageRoute(builder: (context) => const PairingScreen())); + if (confirmed == true && context.mounted) { + await context.read().leaveSession(); + } }, - icon: const Icon(Icons.link), - label: Text(t.companionRemote.connectToDevice), - ), - ], - ), - ); - } + tooltip: t.common.disconnect, + ); + } + return const SizedBox.shrink(); + }, + ), + ], + ), + body: Consumer( + builder: (context, provider, child) { + if (provider.status == RemoteSessionStatus.reconnecting) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const CircularProgressIndicator(), + const SizedBox(height: 24), + Text(t.companionRemote.remote.reconnecting, style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: 8), + Text( + t.companionRemote.remote.attemptOf(current: provider.reconnectAttempts), + style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: Colors.grey), + ), + const SizedBox(height: 32), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + OutlinedButton(onPressed: () => provider.cancelReconnect(), child: Text(t.common.cancel)), + const SizedBox(width: 16), + FilledButton( + onPressed: () => provider.retryReconnectNow(), + child: Text(t.companionRemote.remote.retryNow), + ), + ], + ), + ], + ), + ); + } - return const _RemoteControlLayout(); - }, + if (!provider.isConnected) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.phonelink_off, size: 64, color: Colors.grey), + const SizedBox(height: 16), + Text( + provider.status == RemoteSessionStatus.error + ? provider.session?.errorMessage ?? t.companionRemote.remote.connectionError + : t.companionRemote.remote.notConnected, + style: const TextStyle(fontSize: 20, color: Colors.grey), + textAlign: TextAlign.center, + ), + const SizedBox(height: 32), + FilledButton.icon( + onPressed: () { + Navigator.push(context, MaterialPageRoute(builder: (context) => const PairingScreen())); + }, + icon: const Icon(Icons.link), + label: Text(t.companionRemote.connectToDevice), + ), + ], + ), + ); + } + + return const _RemoteControlLayout(); + }, + ), ), ); } @@ -150,11 +153,7 @@ class _RemoteControlContentState extends State<_RemoteControlContent> { _sendCommand(RemoteCommandType.tabSearch); } final provider = context.read(); - showModalBottomSheet( - context: context, - isScrollControlled: true, - builder: (_) => _SearchBottomSheet(provider: provider), - ); + OverlaySheetController.of(context).show(builder: (_) => _SearchBottomSheet(provider: provider)); } void _sendCommand(RemoteCommandType type) { @@ -632,7 +631,7 @@ class _SearchBottomSheetState extends State<_SearchBottomSheet> { if (trimmed.isNotEmpty) { widget.provider.sendCommand(RemoteCommandType.search, data: {'query': trimmed}); } - Navigator.pop(context); + OverlaySheetController.of(context).close(); } @override diff --git a/lib/screens/hub_detail_screen.dart b/lib/screens/hub_detail_screen.dart index 03a330e7..fbeab7e3 100644 --- a/lib/screens/hub_detail_screen.dart +++ b/lib/screens/hub_detail_screen.dart @@ -12,6 +12,7 @@ import '../utils/provider_extensions.dart'; import '../utils/app_logger.dart'; import '../widgets/media_grid_sliver.dart'; import '../widgets/focused_scroll_scaffold.dart'; +import '../widgets/overlay_sheet.dart'; import 'libraries/sort_bottom_sheet.dart'; import 'libraries/state_messages.dart'; import '../mixins/refreshable.dart'; @@ -152,9 +153,7 @@ class _HubDetailScreenState extends State with Refreshable { } void _showSortBottomSheet() { - showModalBottomSheet( - context: context, - isScrollControlled: true, + OverlaySheetController.of(context).show( builder: (context) => SortBottomSheet( sortOptions: _sortOptions, selectedSort: _selectedSort, @@ -231,57 +230,59 @@ class _HubDetailScreenState extends State with Refreshable { @override Widget build(BuildContext context) { - return FocusedScrollScaffold( - title: Text(widget.hub.title), - actions: [ - IconButton( - icon: AppIcon(Symbols.swap_vert_rounded, fill: 1, semanticLabel: t.libraries.sort), - onPressed: _showSortBottomSheet, - ), - ], - slivers: [ - if (_errorMessage != null) - SliverFillRemaining( - child: ErrorStateWidget( - message: _errorMessage!, - icon: Symbols.error_outline_rounded, - onRetry: _loadMoreItems, - ), - ) - else if (_filteredItems.isEmpty && _isLoading) - const SliverFillRemaining(child: Center(child: CircularProgressIndicator())) - else if (_filteredItems.isEmpty) - SliverFillRemaining(child: Center(child: Text(t.hubDetail.noItemsFound))) - else - Builder( - builder: (context) { - final episodePosterMode = context.watch().episodePosterMode; - - // Determine hub content type for layout decisions - final hasEpisodes = _filteredItems.any((item) => item.usesWideAspectRatio(episodePosterMode)); - final hasNonEpisodes = _filteredItems.any((item) => !item.usesWideAspectRatio(episodePosterMode)); - - // Mixed hub = has both episodes AND non-episodes - final isMixedHub = hasEpisodes && hasNonEpisodes; - - // Episode-only = all items are episodes with thumbnails - final isEpisodeOnlyHub = hasEpisodes && !hasNonEpisodes; - - // Use 16:9 for episode-only hubs OR mixed hubs (with episode thumbnail mode) - final useWideLayout = - episodePosterMode == EpisodePosterMode.episodeThumbnail && (isEpisodeOnlyHub || isMixedHub); - - return MediaGridSliver( - items: _filteredItems, - onRefresh: _handleItemRefresh, - usePaddingAwareExtent: true, - horizontalPadding: 16, - useWideAspectRatio: useWideLayout, - mixedHubContext: isMixedHub, - ); - }, + return OverlaySheetHost( + child: FocusedScrollScaffold( + title: Text(widget.hub.title), + actions: [ + IconButton( + icon: AppIcon(Symbols.swap_vert_rounded, fill: 1, semanticLabel: t.libraries.sort), + onPressed: _showSortBottomSheet, ), - ], + ], + slivers: [ + if (_errorMessage != null) + SliverFillRemaining( + child: ErrorStateWidget( + message: _errorMessage!, + icon: Symbols.error_outline_rounded, + onRetry: _loadMoreItems, + ), + ) + else if (_filteredItems.isEmpty && _isLoading) + const SliverFillRemaining(child: Center(child: CircularProgressIndicator())) + else if (_filteredItems.isEmpty) + SliverFillRemaining(child: Center(child: Text(t.hubDetail.noItemsFound))) + else + Builder( + builder: (context) { + final episodePosterMode = context.watch().episodePosterMode; + + // Determine hub content type for layout decisions + final hasEpisodes = _filteredItems.any((item) => item.usesWideAspectRatio(episodePosterMode)); + final hasNonEpisodes = _filteredItems.any((item) => !item.usesWideAspectRatio(episodePosterMode)); + + // Mixed hub = has both episodes AND non-episodes + final isMixedHub = hasEpisodes && hasNonEpisodes; + + // Episode-only = all items are episodes with thumbnails + final isEpisodeOnlyHub = hasEpisodes && !hasNonEpisodes; + + // Use 16:9 for episode-only hubs OR mixed hubs (with episode thumbnail mode) + final useWideLayout = + episodePosterMode == EpisodePosterMode.episodeThumbnail && (isEpisodeOnlyHub || isMixedHub); + + return MediaGridSliver( + items: _filteredItems, + onRefresh: _handleItemRefresh, + usePaddingAwareExtent: true, + horizontalPadding: 16, + useWideAspectRatio: useWideLayout, + mixedHubContext: isMixedHub, + ); + }, + ), + ], + ), ); } } diff --git a/lib/screens/libraries/filters_bottom_sheet.dart b/lib/screens/libraries/filters_bottom_sheet.dart index 58b80815..822c3abb 100644 --- a/lib/screens/libraries/filters_bottom_sheet.dart +++ b/lib/screens/libraries/filters_bottom_sheet.dart @@ -4,8 +4,8 @@ import 'package:material_symbols_icons/symbols.dart'; import '../../models/plex_filter.dart'; import '../../widgets/app_bar_back_button.dart'; import '../../widgets/bottom_sheet_header.dart'; -import '../../widgets/focusable_bottom_sheet.dart'; import '../../widgets/focusable_list_tile.dart'; +import '../../widgets/overlay_sheet.dart'; import '../../utils/provider_extensions.dart'; import '../../i18n/strings.g.dart'; @@ -101,7 +101,7 @@ class _FiltersBottomSheetState extends State { void _applyFilters() { widget.onFiltersChanged(_tempSelectedFilters); - Navigator.pop(context); + OverlaySheetController.of(context).close(); } String _extractFilterValue(String key, String filterName) { @@ -118,164 +118,148 @@ class _FiltersBottomSheetState extends State { @override Widget build(BuildContext context) { - return FocusableBottomSheet( - initialFocusNode: _initialFocusNode, - child: DraggableScrollableSheet( - initialChildSize: 0.7, - minChildSize: 0.5, - maxChildSize: 0.95, - expand: false, - builder: (context, scrollController) { - if (_currentFilter != null) { - // Show filter options view - return Column( - children: [ - // Header with back button - BottomSheetHeader( - title: _currentFilter!.title, - leading: AppBarBackButton(style: BackButtonStyle.plain, onPressed: _goBack), - ), - - // Filter options list - if (_isLoadingValues) - const Expanded(child: Center(child: CircularProgressIndicator())) - else - Expanded( - child: ListView.builder( - controller: scrollController, - padding: const EdgeInsets.symmetric(vertical: 8), - itemCount: _filterValues.length + 1, - itemBuilder: (context, index) { - if (index == 0) { - final isSelected = !_tempSelectedFilters.containsKey(_currentFilter!.filter); - return FocusableListTile( - focusNode: _initialFocusNode, - title: Text(t.libraries.all), - selected: isSelected, - onTap: () { - setState(() { - _tempSelectedFilters.remove(_currentFilter!.filter); - }); - _applyFilters(); - }, - ); - } - - final value = _filterValues[index - 1]; - final filterValue = _extractFilterValue(value.key, _currentFilter!.filter); - final isSelected = _tempSelectedFilters[_currentFilter!.filter] == filterValue; - - return FocusableListTile( - title: Text(value.title), - selected: isSelected, - onTap: () { - setState(() { - _tempSelectedFilters[_currentFilter!.filter] = filterValue; - // Cache the display name for this filter value - if (_filterDisplayNames.length > _maxCachedDisplayNames) { - _filterDisplayNames.clear(); - } - _filterDisplayNames[_cacheKey(_currentFilter!.filter, filterValue)] = value.title; - }); - _applyFilters(); - }, - ); - }, - ), - ), - ], - ); - } - - // Show main filters view - return Column( - children: [ - // Header - BottomSheetHeader( - title: t.libraries.filters, - leading: const AppIcon(Symbols.filter_alt_rounded, fill: 1), - action: _tempSelectedFilters.isNotEmpty - ? TextButton.icon( - onPressed: () { - setState(() { - _tempSelectedFilters.clear(); - }); - _applyFilters(); - }, - icon: const AppIcon(Symbols.clear_all_rounded, fill: 1), - label: Text(t.libraries.clearAll), - ) - : null, - ), - - // All Filters (boolean toggles first, then regular filters) - Expanded( - child: ListView.builder( - controller: scrollController, - padding: const EdgeInsets.symmetric(vertical: 8), - itemCount: _sortedFilters.length, - itemBuilder: (context, index) { - final filter = _sortedFilters[index]; - - // Handle boolean filters as switches (unwatched, inProgress, unmatched, hdr, etc.) - if (_isBooleanFilter(filter)) { - final isActive = - _tempSelectedFilters.containsKey(filter.filter) && _tempSelectedFilters[filter.filter] == '1'; - return FocusableSwitchListTile( - focusNode: index == 0 ? _initialFocusNode : null, - value: isActive, - onChanged: (value) { - setState(() { - if (value) { - _tempSelectedFilters[filter.filter] = '1'; - } else { - _tempSelectedFilters.remove(filter.filter); - } - }); - _applyFilters(); - }, - title: Text(filter.title), - ); - } - - // Regular navigable filters - show selected value instead of checkmark - final selectedValue = _tempSelectedFilters[filter.filter]; - String? displayValue; - if (selectedValue != null) { - // Try to get the cached display name, fall back to the value itself - displayValue = _filterDisplayNames[_cacheKey(filter.filter, selectedValue)] ?? selectedValue; - } + if (_currentFilter != null) { + // Show filter options view + return Column( + children: [ + // Header with back button + BottomSheetHeader( + title: _currentFilter!.title, + leading: AppBarBackButton(style: BackButtonStyle.plain, onPressed: _goBack), + ), + // Filter options list + if (_isLoadingValues) + const Expanded(child: Center(child: CircularProgressIndicator())) + else + Expanded( + child: ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: _filterValues.length + 1, + itemBuilder: (context, index) { + if (index == 0) { + final isSelected = !_tempSelectedFilters.containsKey(_currentFilter!.filter); return FocusableListTile( - focusNode: index == 0 ? _initialFocusNode : null, - title: Text(filter.title), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (displayValue != null) - Flexible( - child: Text( - displayValue, - style: TextStyle( - color: Theme.of(context).colorScheme.primary, - fontWeight: FontWeight.w500, - ), - overflow: TextOverflow.ellipsis, - ), - ), - if (displayValue != null) const SizedBox(width: 8), - const AppIcon(Symbols.chevron_right_rounded, fill: 1), - ], - ), - onTap: () => _loadFilterValues(filter), + focusNode: _initialFocusNode, + title: Text(t.libraries.all), + selected: isSelected, + onTap: () { + setState(() { + _tempSelectedFilters.remove(_currentFilter!.filter); + }); + _applyFilters(); + }, ); - }, - ), + } + + final value = _filterValues[index - 1]; + final filterValue = _extractFilterValue(value.key, _currentFilter!.filter); + final isSelected = _tempSelectedFilters[_currentFilter!.filter] == filterValue; + + return FocusableListTile( + title: Text(value.title), + selected: isSelected, + onTap: () { + setState(() { + _tempSelectedFilters[_currentFilter!.filter] = filterValue; + // Cache the display name for this filter value + if (_filterDisplayNames.length > _maxCachedDisplayNames) { + _filterDisplayNames.clear(); + } + _filterDisplayNames[_cacheKey(_currentFilter!.filter, filterValue)] = value.title; + }); + _applyFilters(); + }, + ); + }, ), - ], - ); - }, - ), + ), + ], + ); + } + + // Show main filters view + return Column( + children: [ + // Header + BottomSheetHeader( + title: t.libraries.filters, + leading: const AppIcon(Symbols.filter_alt_rounded, fill: 1), + action: _tempSelectedFilters.isNotEmpty + ? TextButton.icon( + onPressed: () { + setState(() { + _tempSelectedFilters.clear(); + }); + _applyFilters(); + }, + icon: const AppIcon(Symbols.clear_all_rounded, fill: 1), + label: Text(t.libraries.clearAll), + ) + : null, + ), + + // All Filters (boolean toggles first, then regular filters) + Expanded( + child: ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: _sortedFilters.length, + itemBuilder: (context, index) { + final filter = _sortedFilters[index]; + + // Handle boolean filters as switches (unwatched, inProgress, unmatched, hdr, etc.) + if (_isBooleanFilter(filter)) { + final isActive = + _tempSelectedFilters.containsKey(filter.filter) && _tempSelectedFilters[filter.filter] == '1'; + return FocusableSwitchListTile( + focusNode: index == 0 ? _initialFocusNode : null, + value: isActive, + onChanged: (value) { + setState(() { + if (value) { + _tempSelectedFilters[filter.filter] = '1'; + } else { + _tempSelectedFilters.remove(filter.filter); + } + }); + _applyFilters(); + }, + title: Text(filter.title), + ); + } + + // Regular navigable filters - show selected value instead of checkmark + final selectedValue = _tempSelectedFilters[filter.filter]; + String? displayValue; + if (selectedValue != null) { + // Try to get the cached display name, fall back to the value itself + displayValue = _filterDisplayNames[_cacheKey(filter.filter, selectedValue)] ?? selectedValue; + } + + return FocusableListTile( + focusNode: index == 0 ? _initialFocusNode : null, + title: Text(filter.title), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (displayValue != null) + Flexible( + child: Text( + displayValue, + style: TextStyle(color: Theme.of(context).colorScheme.primary, fontWeight: FontWeight.w500), + overflow: TextOverflow.ellipsis, + ), + ), + if (displayValue != null) const SizedBox(width: 8), + const AppIcon(Symbols.chevron_right_rounded, fill: 1), + ], + ), + onTap: () => _loadFilterValues(filter), + ); + }, + ), + ), + ], ); } } diff --git a/lib/screens/libraries/libraries_screen.dart b/lib/screens/libraries/libraries_screen.dart index cc0bf58c..f81b91b6 100644 --- a/lib/screens/libraries/libraries_screen.dart +++ b/lib/screens/libraries/libraries_screen.dart @@ -23,6 +23,7 @@ import '../../utils/snackbar_helper.dart'; import '../../utils/content_utils.dart'; import '../../widgets/desktop_app_bar.dart'; import '../../widgets/focusable_tab_chip.dart'; +import '../../widgets/overlay_sheet.dart'; import '../../services/storage_service.dart'; import '../../mixins/refreshable.dart'; import '../../mixins/item_updatable.dart'; @@ -786,9 +787,7 @@ class _LibrariesScreenState extends State ), ); } else { - showModalBottomSheet( - context: context, - isScrollControlled: true, + OverlaySheetController.of(context).show( builder: (context) => _LibraryManagementSheet( allLibraries: List.from(allLibraries), hiddenLibraryKeys: hiddenLibrariesProvider.hiddenLibraryKeys, @@ -1066,140 +1065,142 @@ class _LibrariesScreenState extends State // Compute visible libraries (filtered from all libraries) final visibleLibraries = allLibraries.where((lib) => !hiddenKeys.contains(lib.globalKey)).toList(); - return Scaffold( - body: ScrollConfiguration( - behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false), - child: CustomScrollView( - controller: _outerScrollController, - slivers: [ - DesktopSliverAppBar( - title: _buildAppBarTitle(visibleLibraries), - pinned: true, - backgroundColor: Theme.of(context).scaffoldBackgroundColor, - surfaceTintColor: Colors.transparent, - shadowColor: Colors.transparent, - scrolledUnderElevation: 0, - actions: [ - if (allLibraries.isNotEmpty) + return OverlaySheetHost( + child: Scaffold( + body: ScrollConfiguration( + behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false), + child: CustomScrollView( + controller: _outerScrollController, + slivers: [ + DesktopSliverAppBar( + title: _buildAppBarTitle(visibleLibraries), + pinned: true, + backgroundColor: Theme.of(context).scaffoldBackgroundColor, + surfaceTintColor: Colors.transparent, + shadowColor: Colors.transparent, + scrolledUnderElevation: 0, + actions: [ + if (allLibraries.isNotEmpty) + Focus( + focusNode: _editButtonFocusNode, + onKeyEvent: _handleEditKeyEvent, + child: Container( + decoration: BoxDecoration( + color: _isEditFocused ? Colors.white.withValues(alpha: 0.2) : Colors.transparent, + borderRadius: const BorderRadius.all(Radius.circular(20)), + ), + child: IconButton( + icon: const AppIcon(Symbols.edit_rounded, fill: 1), + tooltip: t.libraries.manageLibraries, + onPressed: _showLibraryManagementSheet, + ), + ), + ), Focus( - focusNode: _editButtonFocusNode, - onKeyEvent: _handleEditKeyEvent, + focusNode: _refreshButtonFocusNode, + onKeyEvent: _handleRefreshKeyEvent, child: Container( decoration: BoxDecoration( - color: _isEditFocused ? Colors.white.withValues(alpha: 0.2) : Colors.transparent, + color: _isRefreshFocused ? Colors.white.withValues(alpha: 0.2) : Colors.transparent, borderRadius: const BorderRadius.all(Radius.circular(20)), ), child: IconButton( - icon: const AppIcon(Symbols.edit_rounded, fill: 1), - tooltip: t.libraries.manageLibraries, - onPressed: _showLibraryManagementSheet, + icon: const AppIcon(Symbols.refresh_rounded, fill: 1), + tooltip: t.common.refresh, + onPressed: _refreshCurrentTab, ), ), ), - Focus( - focusNode: _refreshButtonFocusNode, - onKeyEvent: _handleRefreshKeyEvent, - child: Container( - decoration: BoxDecoration( - color: _isRefreshFocused ? Colors.white.withValues(alpha: 0.2) : Colors.transparent, - borderRadius: const BorderRadius.all(Radius.circular(20)), - ), - child: IconButton( - icon: const AppIcon(Symbols.refresh_rounded, fill: 1), - tooltip: t.common.refresh, - onPressed: _refreshCurrentTab, - ), - ), - ), - ], - ), - if (isLoadingLibraries) - const SliverFillRemaining(child: Center(child: CircularProgressIndicator())) - else if (_errorMessage != null && visibleLibraries.isEmpty) - SliverFillRemaining( - child: ErrorStateWidget( - message: _errorMessage!, - icon: Symbols.error_outline_rounded, - onRetry: () { - final librariesProvider = context.read(); - librariesProvider.refresh(); - }, - ), - ) - else if (visibleLibraries.isEmpty) - SliverFillRemaining( - child: EmptyStateWidget(message: t.libraries.noLibrariesFound, icon: Symbols.video_library_rounded), - ) - else ...[ - // Tab selector chips (only on mobile - desktop has them in app bar) - if (_selectedLibraryGlobalKey != null && !PlatformDetector.shouldUseSideNavigation(context)) - SliverToBoxAdapter( - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: [ - _buildTabChip(t.libraries.tabs.recommended, 0), - const SizedBox(width: 8), - _buildTabChip(t.libraries.tabs.browse, 1), - const SizedBox(width: 8), - _buildTabChip(t.libraries.tabs.collections, 2), - const SizedBox(width: 8), - _buildTabChip(t.libraries.tabs.playlists, 3), - ], - ), - ), - ), - ), - - // Tab content - if (_selectedLibraryGlobalKey != null) + ], + ), + if (isLoadingLibraries) + const SliverFillRemaining(child: Center(child: CircularProgressIndicator())) + else if (_errorMessage != null && visibleLibraries.isEmpty) SliverFillRemaining( - child: TabBarView( - key: ValueKey(_selectedLibraryGlobalKey), - controller: tabController, - // Disable swipe on desktop - trackpad scrolling triggers accidental tab switches - // See: https://github.com/flutter/flutter/issues/11132 - physics: PlatformDetector.isDesktop(context) ? const NeverScrollableScrollPhysics() : null, - children: [ - LibraryRecommendedTab( - key: _recommendedTabKey, - library: allLibraries.firstWhere((lib) => lib.globalKey == _selectedLibraryGlobalKey), - isActive: tabController.index == 0, - suppressAutoFocus: suppressAutoFocus, - onDataLoaded: () => _handleTabDataLoaded(0), - onBack: focusTabBar, - ), - LibraryBrowseTab( - key: _browseTabKey, - library: allLibraries.firstWhere((lib) => lib.globalKey == _selectedLibraryGlobalKey), - isActive: tabController.index == 1, - suppressAutoFocus: suppressAutoFocus, - onDataLoaded: () => _handleTabDataLoaded(1), - onBack: focusTabBar, - ), - LibraryCollectionsTab( - key: _collectionsTabKey, - library: allLibraries.firstWhere((lib) => lib.globalKey == _selectedLibraryGlobalKey), - isActive: tabController.index == 2, - suppressAutoFocus: suppressAutoFocus, - onDataLoaded: () => _handleTabDataLoaded(2), - onBack: focusTabBar, - ), - LibraryPlaylistsTab( - key: _playlistsTabKey, - library: allLibraries.firstWhere((lib) => lib.globalKey == _selectedLibraryGlobalKey), - isActive: tabController.index == 3, - suppressAutoFocus: suppressAutoFocus, - onDataLoaded: () => _handleTabDataLoaded(3), - onBack: focusTabBar, - ), - ], + child: ErrorStateWidget( + message: _errorMessage!, + icon: Symbols.error_outline_rounded, + onRetry: () { + final librariesProvider = context.read(); + librariesProvider.refresh(); + }, ), - ), + ) + else if (visibleLibraries.isEmpty) + SliverFillRemaining( + child: EmptyStateWidget(message: t.libraries.noLibrariesFound, icon: Symbols.video_library_rounded), + ) + else ...[ + // Tab selector chips (only on mobile - desktop has them in app bar) + if (_selectedLibraryGlobalKey != null && !PlatformDetector.shouldUseSideNavigation(context)) + SliverToBoxAdapter( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + _buildTabChip(t.libraries.tabs.recommended, 0), + const SizedBox(width: 8), + _buildTabChip(t.libraries.tabs.browse, 1), + const SizedBox(width: 8), + _buildTabChip(t.libraries.tabs.collections, 2), + const SizedBox(width: 8), + _buildTabChip(t.libraries.tabs.playlists, 3), + ], + ), + ), + ), + ), + + // Tab content + if (_selectedLibraryGlobalKey != null) + SliverFillRemaining( + child: TabBarView( + key: ValueKey(_selectedLibraryGlobalKey), + controller: tabController, + // Disable swipe on desktop - trackpad scrolling triggers accidental tab switches + // See: https://github.com/flutter/flutter/issues/11132 + physics: PlatformDetector.isDesktop(context) ? const NeverScrollableScrollPhysics() : null, + children: [ + LibraryRecommendedTab( + key: _recommendedTabKey, + library: allLibraries.firstWhere((lib) => lib.globalKey == _selectedLibraryGlobalKey), + isActive: tabController.index == 0, + suppressAutoFocus: suppressAutoFocus, + onDataLoaded: () => _handleTabDataLoaded(0), + onBack: focusTabBar, + ), + LibraryBrowseTab( + key: _browseTabKey, + library: allLibraries.firstWhere((lib) => lib.globalKey == _selectedLibraryGlobalKey), + isActive: tabController.index == 1, + suppressAutoFocus: suppressAutoFocus, + onDataLoaded: () => _handleTabDataLoaded(1), + onBack: focusTabBar, + ), + LibraryCollectionsTab( + key: _collectionsTabKey, + library: allLibraries.firstWhere((lib) => lib.globalKey == _selectedLibraryGlobalKey), + isActive: tabController.index == 2, + suppressAutoFocus: suppressAutoFocus, + onDataLoaded: () => _handleTabDataLoaded(2), + onBack: focusTabBar, + ), + LibraryPlaylistsTab( + key: _playlistsTabKey, + library: allLibraries.firstWhere((lib) => lib.globalKey == _selectedLibraryGlobalKey), + isActive: tabController.index == 3, + suppressAutoFocus: suppressAutoFocus, + onDataLoaded: () => _handleTabDataLoaded(3), + onBack: focusTabBar, + ), + ], + ), + ), + ], ], - ], + ), ), ), ); @@ -1420,27 +1421,52 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> { Future _showLibraryMenuBottomSheet(BuildContext outerContext, PlexLibrary library) async { final menuItems = widget.getLibraryMenuItems(library); - final 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), + 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)), ), - ), - ], + ...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/libraries/sort_bottom_sheet.dart b/lib/screens/libraries/sort_bottom_sheet.dart index 6aeb2e9b..24229c23 100644 --- a/lib/screens/libraries/sort_bottom_sheet.dart +++ b/lib/screens/libraries/sort_bottom_sheet.dart @@ -4,8 +4,8 @@ import 'package:material_symbols_icons/symbols.dart'; import '../../focus/dpad_navigator.dart'; import '../../models/plex_sort.dart'; import '../../widgets/bottom_sheet_header.dart'; -import '../../widgets/focusable_bottom_sheet.dart'; import '../../widgets/focusable_list_tile.dart'; +import '../../widgets/overlay_sheet.dart'; import '../../i18n/strings.g.dart'; class SortBottomSheet extends StatefulWidget { @@ -45,6 +45,11 @@ class _SortBottomSheetState extends State { if (ctx != null) { Scrollable.ensureVisible(ctx, alignment: 0.5); } + // Schedule after overlay's _autoFocus second callback so we override it. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _initialFocusNode.requestFocus(); + }); }); } @@ -68,7 +73,7 @@ class _SortBottomSheetState extends State { _currentDescending = descending; }); widget.onSortChanged(sort, descending); - Navigator.pop(context); + OverlaySheetController.of(context).close(); } void _handleClear() { @@ -77,89 +82,73 @@ class _SortBottomSheetState extends State { _currentDescending = false; }); widget.onClear?.call(); + OverlaySheetController.of(context).close(); } @override Widget build(BuildContext context) { - return FocusableBottomSheet( - initialFocusNode: _initialFocusNode, - child: DraggableScrollableSheet( - initialChildSize: 0.6, - minChildSize: 0.4, - maxChildSize: 0.9, - expand: false, - builder: (context, scrollController) { - return Column( - children: [ - BottomSheetHeader( - title: t.libraries.sortBy, - action: widget.onClear != null - ? TextButton(onPressed: _handleClear, child: Text(t.common.clear)) - : null, - ), - Expanded( - child: ListView.builder( - controller: scrollController, - padding: const EdgeInsets.symmetric(vertical: 8), - itemCount: widget.sortOptions.length, - itemBuilder: (context, index) { - final sort = widget.sortOptions[index]; - final isSelected = _currentSort?.key == sort.key; + return Column( + children: [ + BottomSheetHeader( + title: t.libraries.sortBy, + action: widget.onClear != null ? TextButton(onPressed: _handleClear, child: Text(t.common.clear)) : null, + ), + Expanded( + child: ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: widget.sortOptions.length, + itemBuilder: (context, index) { + final sort = widget.sortOptions[index]; + final isSelected = _currentSort?.key == sort.key; - return Focus( - canRequestFocus: false, - skipTraversal: true, - onKeyEvent: (node, event) { - if (!event.isActionable) return KeyEventResult.ignored; - if (!isSelected) return KeyEventResult.ignored; - if (event.logicalKey.isLeftKey) { - _handleDirectionChange(sort, false); - return KeyEventResult.handled; - } - if (event.logicalKey.isRightKey) { - _handleDirectionChange(sort, true); - return KeyEventResult.handled; - } - return KeyEventResult.ignored; - }, - child: FocusableRadioListTile( - focusNode: (widget.selectedSort?.key == sort.key || (widget.selectedSort == null && index == 0)) - ? _initialFocusNode - : null, - title: Text(sort.title), - value: sort, - groupValue: _currentSort, - onChanged: (value) { - if (value != null) _handleSortSelect(value); - }, - secondary: isSelected - ? SegmentedButton( - showSelectedIcon: false, - segments: const [ - ButtonSegment( - value: false, - icon: AppIcon(Symbols.arrow_upward_rounded, fill: 1, size: 16), - ), - ButtonSegment( - value: true, - icon: AppIcon(Symbols.arrow_downward_rounded, fill: 1, size: 16), - ), - ], - selected: {_currentDescending}, - onSelectionChanged: (Set newSelection) { - _handleDirectionChange(sort, newSelection.first); - }, - ) - : null, - ), - ); + return Focus( + canRequestFocus: false, + skipTraversal: true, + onKeyEvent: (node, event) { + if (!event.isActionable) return KeyEventResult.ignored; + if (!isSelected) return KeyEventResult.ignored; + if (event.logicalKey.isLeftKey) { + _handleDirectionChange(sort, false); + return KeyEventResult.handled; + } + if (event.logicalKey.isRightKey) { + _handleDirectionChange(sort, true); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + }, + child: FocusableRadioListTile( + focusNode: (widget.selectedSort?.key == sort.key || (widget.selectedSort == null && index == 0)) + ? _initialFocusNode + : null, + title: Text(sort.title), + value: sort, + groupValue: _currentSort, + onChanged: (value) { + if (value != null) _handleSortSelect(value); }, + secondary: isSelected + ? SegmentedButton( + showSelectedIcon: false, + segments: const [ + ButtonSegment(value: false, icon: AppIcon(Symbols.arrow_upward_rounded, fill: 1, size: 16)), + ButtonSegment( + value: true, + icon: AppIcon(Symbols.arrow_downward_rounded, fill: 1, size: 16), + ), + ], + selected: {_currentDescending}, + onSelectionChanged: (Set newSelection) { + _handleDirectionChange(sort, newSelection.first); + }, + ) + : null, ), - ), - ], - ); - }, - ), + ); + }, + ), + ), + ], ); } } diff --git a/lib/screens/libraries/tabs/library_browse_tab.dart b/lib/screens/libraries/tabs/library_browse_tab.dart index b5db7529..d54aafec 100644 --- a/lib/screens/libraries/tabs/library_browse_tab.dart +++ b/lib/screens/libraries/tabs/library_browse_tab.dart @@ -20,6 +20,7 @@ import '../../../widgets/alpha_scroll_handle.dart'; import '../../../widgets/focusable_media_card.dart'; import '../../../widgets/focusable_filter_chip.dart'; import '../../../widgets/media_grid_delegate.dart'; +import '../../../widgets/overlay_sheet.dart'; import '../../../mixins/library_tab_focus_mixin.dart'; import '../folder_tree_view.dart'; import '../filters_bottom_sheet.dart'; @@ -482,52 +483,51 @@ class _LibraryBrowseTabState extends BaseLibraryTabState( - title: Text(_getGroupingLabel(grouping)), - value: grouping, - groupValue: pendingGrouping, - onChanged: (value) { - if (value == null) return; - setSheetState(() { - pendingGrouping = value; - }); + OverlaySheetController.of(context) + .show( + builder: (sheetContext) { + final options = _getGroupingOptions(); + return StatefulBuilder( + builder: (context, setSheetState) { + return ListView.builder( + shrinkWrap: true, + itemCount: options.length, + itemBuilder: (context, index) { + final grouping = options[index]; + return RadioListTile( + title: Text(_getGroupingLabel(grouping)), + value: grouping, + groupValue: pendingGrouping, + onChanged: (value) { + if (value == null) return; + setSheetState(() { + pendingGrouping = value; + }); + }, + ); }, ); }, ); }, - ); - }, - ).then((_) { - if (!mounted) return; - if (pendingGrouping == _selectedGrouping) return; - setState(() { - _selectedGrouping = pendingGrouping; - }); - StorageService.getInstance().then((storage) { - storage.saveLibraryGrouping(widget.library.globalKey, pendingGrouping); - }); - _loadItems(); - _loadFirstCharacters(); - }); + ) + .then((_) { + if (!mounted) return; + if (pendingGrouping == _selectedGrouping) return; + setState(() { + _selectedGrouping = pendingGrouping; + }); + StorageService.getInstance().then((storage) { + storage.saveLibraryGrouping(widget.library.globalKey, pendingGrouping); + }); + _loadItems(); + _loadFirstCharacters(); + }); } void _showFiltersBottomSheet() { SelectKeyUpSuppressor.suppressSelectUntilKeyUp(); - showModalBottomSheet( - context: context, - isScrollControlled: true, + OverlaySheetController.of(context).show( builder: (context) => FiltersBottomSheet( filters: _filters, selectedFilters: _selectedFilters, @@ -557,46 +557,46 @@ class _LibraryBrowseTabState extends BaseLibraryTabState SortBottomSheet( - sortOptions: _sortOptions, - selectedSort: _selectedSort, - isSortDescending: _isSortDescending, - onSortChanged: (sort, descending) { - pendingSort = sort; - pendingDescending = descending; - pendingCleared = false; - }, - onClear: () { - pendingSort = null; - pendingDescending = false; - pendingCleared = true; - }, - ), - ).then((_) { - if (!mounted) return; - if (pendingCleared) { - setState(() { - _selectedSort = null; - _isSortDescending = false; + OverlaySheetController.of(context) + .show( + builder: (context) => SortBottomSheet( + sortOptions: _sortOptions, + selectedSort: _selectedSort, + isSortDescending: _isSortDescending, + onSortChanged: (sort, descending) { + pendingSort = sort; + pendingDescending = descending; + pendingCleared = false; + }, + onClear: () { + pendingSort = null; + pendingDescending = false; + pendingCleared = true; + }, + ), + ) + .then((_) { + if (!mounted) return; + if (pendingCleared) { + setState(() { + _selectedSort = null; + _isSortDescending = false; + }); + _loadItems(); + _loadFirstCharacters(); + } else if (pendingSort != null && + (pendingSort!.key != _selectedSort?.key || pendingDescending != _isSortDescending)) { + setState(() { + _selectedSort = pendingSort; + _isSortDescending = pendingDescending; + }); + StorageService.getInstance().then((storage) { + storage.saveLibrarySort(widget.library.globalKey, pendingSort!.key, descending: pendingDescending); + }); + _loadItems(); + _loadFirstCharacters(); + } }); - _loadItems(); - _loadFirstCharacters(); - } else if (pendingSort != null && - (pendingSort!.key != _selectedSort?.key || pendingDescending != _isSortDescending)) { - setState(() { - _selectedSort = pendingSort; - _isSortDescending = pendingDescending; - }); - StorageService.getInstance().then((storage) { - storage.saveLibrarySort(widget.library.globalKey, pendingSort!.key, descending: pendingDescending); - }); - _loadItems(); - _loadFirstCharacters(); - } - }); } /// Navigate focus from chips down to the grid item. @@ -831,52 +831,56 @@ class _LibraryBrowseTabState extends BaseLibraryTabState { @override Widget build(BuildContext context) { - return FocusedScrollScaffold( - title: Text(widget.showTitle), - slivers: [ - if (_isLoading) - const SliverFillRemaining(child: Center(child: CircularProgressIndicator())) - else if (_programs.isEmpty) - SliverFillRemaining(child: Center(child: Text(t.liveTv.noPrograms))) - else - SliverList( - delegate: SliverChildBuilderDelegate((context, index) { - final program = _programs[index]; - final channel = _findChannel(program.channelIdentifier); - void onTap() { - if (program.isCurrentlyAiring && channel != null) { - _tuneChannel(channel); - } else { - _showProgramDetails(program, channel); + return OverlaySheetHost( + child: FocusedScrollScaffold( + title: Text(widget.showTitle), + slivers: [ + if (_isLoading) + const SliverFillRemaining(child: Center(child: CircularProgressIndicator())) + else if (_programs.isEmpty) + SliverFillRemaining(child: Center(child: Text(t.liveTv.noPrograms))) + else + SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + final program = _programs[index]; + final channel = _findChannel(program.channelIdentifier); + void onTap() { + if (program.isCurrentlyAiring && channel != null) { + _tuneChannel(channel); + } else { + _showProgramDetails(program, channel); + } } - } - return FocusableWrapper( - autofocus: index == 0, - autoScroll: true, - useComfortableZone: true, - useBackgroundFocus: true, - disableScale: true, - onSelect: onTap, - onBack: () => Navigator.pop(context), - child: _ScheduleListTile(program: program, channel: channel, onTap: onTap), - ); - }, childCount: _programs.length), - ), - ], + return FocusableWrapper( + autofocus: index == 0, + autoScroll: true, + useComfortableZone: true, + useBackgroundFocus: true, + disableScale: true, + onSelect: onTap, + onBack: () => Navigator.pop(context), + child: _ScheduleListTile(program: program, channel: channel, onTap: onTap), + ); + }, childCount: _programs.length), + ), + ], + ), ); } } diff --git a/lib/screens/livetv/program_details_sheet.dart b/lib/screens/livetv/program_details_sheet.dart index dd56e31f..5065ee31 100644 --- a/lib/screens/livetv/program_details_sheet.dart +++ b/lib/screens/livetv/program_details_sheet.dart @@ -7,7 +7,7 @@ import '../../models/livetv_channel.dart'; import '../../models/livetv_program.dart'; import '../../utils/formatters.dart'; import '../../widgets/app_icon.dart'; -import '../../widgets/focusable_bottom_sheet.dart'; +import '../../widgets/overlay_sheet.dart'; /// Shows a bottom sheet with program details and actions (Record, Watch Channel, Play). void showProgramDetailsSheet( @@ -17,17 +17,31 @@ void showProgramDetailsSheet( required String? posterUrl, required VoidCallback? onTuneChannel, }) { - showModalBottomSheet( - context: context, - builder: (sheetContext) { - return _ProgramDetailsSheetContent( - program: program, - channel: channel, - posterUrl: posterUrl, - onTuneChannel: 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, + ); + }, + ); + } } class _ProgramDetailsSheetContent extends StatefulWidget { @@ -50,8 +64,6 @@ class _ProgramDetailsSheetContent extends StatefulWidget { class _ProgramDetailsSheetContentState extends State<_ProgramDetailsSheetContent> { final List _buttonFocusNodes = []; - FocusNode get _initialFocusNode => _buttonFocusNodes.isNotEmpty ? _buttonFocusNodes.first : FocusNode(); - @override void initState() { super.initState(); @@ -94,25 +106,34 @@ 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(); + } + } + if (program.isCurrentlyAiring && widget.onTuneChannel != null) { final idx = buttonIndex; buttons.add( FocusableWrapper( focusNode: _buttonFocusNodes[idx], onSelect: () { - Navigator.of(context).pop(); + closeSheet(); widget.onTuneChannel!(); }, onNavigateLeft: idx > 0 ? () => _focusButton(idx - 1) : null, onNavigateRight: idx < _buttonFocusNodes.length - 1 ? () => _focusButton(idx + 1) : null, - onBack: () => Navigator.of(context).pop(), + onBack: closeSheet, borderRadius: 100, useBackgroundFocus: true, disableScale: true, child: FilledButton.icon( style: FilledButton.styleFrom(tapTargetSize: MaterialTapTargetSize.shrinkWrap), onPressed: () { - Navigator.of(context).pop(); + closeSheet(); widget.onTuneChannel!(); }, icon: const AppIcon(Symbols.play_arrow_rounded), @@ -162,19 +183,19 @@ class _ProgramDetailsSheetContentState extends State<_ProgramDetailsSheetContent FocusableWrapper( focusNode: _buttonFocusNodes[idx], onSelect: () { - Navigator.of(context).pop(); + closeSheet(); widget.onTuneChannel!(); }, onNavigateLeft: idx > 0 ? () => _focusButton(idx - 1) : null, onNavigateRight: idx < _buttonFocusNodes.length - 1 ? () => _focusButton(idx + 1) : null, - onBack: () => Navigator.of(context).pop(), + onBack: closeSheet, borderRadius: 100, useBackgroundFocus: true, disableScale: true, child: OutlinedButton.icon( style: OutlinedButton.styleFrom(tapTargetSize: MaterialTapTargetSize.shrinkWrap), onPressed: () { - Navigator.of(context).pop(); + closeSheet(); widget.onTuneChannel!(); }, icon: const AppIcon(Symbols.live_tv_rounded), @@ -185,79 +206,76 @@ class _ProgramDetailsSheetContentState extends State<_ProgramDetailsSheetContent buttonIndex++; } - return FocusableBottomSheet( - initialFocusNode: _initialFocusNode, - child: Padding( - padding: const EdgeInsets.all(20), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (widget.posterUrl != null) ...[ - ClipRRect( - borderRadius: const BorderRadius.all(Radius.circular(6)), - child: Image.network( - widget.posterUrl!, - width: 80, - height: 120, - fit: BoxFit.cover, - errorBuilder: (_, _, _) => const SizedBox.shrink(), - ), - ), - const SizedBox(width: 14), - ], - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded(child: Text(program.displayTitle, style: theme.textTheme.titleMedium)), - if (program.isCurrentlyAiring) - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: const BoxDecoration( - color: Colors.red, - borderRadius: BorderRadius.all(Radius.circular(4)), - ), - child: Text( - t.liveTv.live, - style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 11), - ), - ), - ], - ), - const SizedBox(height: 4), - Text( - [ - if (channel != null) channel.displayName, - if (program.startTime != null && program.endTime != null) - '${program.startTime!.hour.toString().padLeft(2, '0')}:${program.startTime!.minute.toString().padLeft(2, '0')} - ${program.endTime!.hour.toString().padLeft(2, '0')}:${program.endTime!.minute.toString().padLeft(2, '0')}', - if (program.durationMinutes > 0) formatDurationTextual(program.durationMinutes * 60000), - ].join(' · '), - style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), - ), - if (program.summary != null && program.summary!.isNotEmpty) ...[ - const SizedBox(height: 12), - Text( - program.summary!, - style: theme.textTheme.bodyMedium, - maxLines: 4, - overflow: TextOverflow.ellipsis, - ), - ], - ], + return Padding( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (widget.posterUrl != null) ...[ + ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(6)), + child: Image.network( + widget.posterUrl!, + width: 80, + height: 120, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => const SizedBox.shrink(), ), ), + const SizedBox(width: 14), ], - ), - const SizedBox(height: 16), - Row(children: buttons), - ], - ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded(child: Text(program.displayTitle, style: theme.textTheme.titleMedium)), + if (program.isCurrentlyAiring) + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: const BoxDecoration( + color: Colors.red, + borderRadius: BorderRadius.all(Radius.circular(4)), + ), + child: Text( + t.liveTv.live, + style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 11), + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + [ + if (channel != null) channel.displayName, + if (program.startTime != null && program.endTime != null) + '${program.startTime!.hour.toString().padLeft(2, '0')}:${program.startTime!.minute.toString().padLeft(2, '0')} - ${program.endTime!.hour.toString().padLeft(2, '0')}:${program.endTime!.minute.toString().padLeft(2, '0')}', + if (program.durationMinutes > 0) formatDurationTextual(program.durationMinutes * 60000), + ].join(' · '), + style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ), + if (program.summary != null && program.summary!.isNotEmpty) ...[ + const SizedBox(height: 12), + Text( + program.summary!, + style: theme.textTheme.bodyMedium, + maxLines: 4, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), + ), + ], + ), + const SizedBox(height: 16), + Row(children: buttons), + ], ), ); } diff --git a/lib/screens/livetv/tabs/guide_tab.dart b/lib/screens/livetv/tabs/guide_tab.dart index 4f4ba172..db9cda78 100644 --- a/lib/screens/livetv/tabs/guide_tab.dart +++ b/lib/screens/livetv/tabs/guide_tab.dart @@ -17,6 +17,7 @@ import '../../../utils/formatters.dart'; import '../../../utils/plex_image_helper.dart'; import '../../../utils/live_tv_player_navigation.dart'; import '../../../widgets/app_icon.dart'; +import '../../../widgets/overlay_sheet.dart'; import '../../../widgets/plex_optimized_image.dart'; import '../program_details_sheet.dart'; @@ -508,11 +509,13 @@ class GuideTabState extends State { return const Center(child: CircularProgressIndicator()); } - return Focus( - focusNode: _guideFocusNode, - onFocusChange: (hasFocus) => setState(() => _hasFocus = hasFocus), - onKeyEvent: _handleKeyEvent, - child: _buildGuideGrid(theme), + return OverlaySheetHost( + child: Focus( + focusNode: _guideFocusNode, + onFocusChange: (hasFocus) => setState(() => _hasFocus = hasFocus), + onKeyEvent: _handleKeyEvent, + child: _buildGuideGrid(theme), + ), ); } diff --git a/lib/screens/livetv/tabs/whats_on_tab.dart b/lib/screens/livetv/tabs/whats_on_tab.dart index 61777700..e888fee2 100644 --- a/lib/screens/livetv/tabs/whats_on_tab.dart +++ b/lib/screens/livetv/tabs/whats_on_tab.dart @@ -22,6 +22,7 @@ import '../../../utils/plex_image_helper.dart'; import '../../../utils/provider_extensions.dart'; import '../../../widgets/app_icon.dart'; import '../../../widgets/focus_builders.dart'; +import '../../../widgets/overlay_sheet.dart'; import '../../../utils/scroll_utils.dart'; import '../../../widgets/horizontal_scroll_with_arrows.dart'; import '../../../widgets/plex_optimized_image.dart'; @@ -222,20 +223,22 @@ class WhatsOnTabState extends State { return Center(child: Text(t.liveTv.noPrograms)); } - return ListView.builder( - padding: const EdgeInsets.symmetric(vertical: 8), - clipBehavior: Clip.none, - itemCount: _hubs.length, - itemBuilder: (context, index) { - return _LiveTvHubSection( - key: _hubKeys[index], - hub: _hubs[index], - onTap: _onItemTap, - onLongPress: (entry) => _showProgramDetails(entry, _findChannel(entry.program.channelIdentifier)), - onVerticalNavigation: (isUp) => _handleVerticalNavigation(index, isUp), - onBack: widget.onBack, - ); - }, + return OverlaySheetHost( + child: ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 8), + clipBehavior: Clip.none, + itemCount: _hubs.length, + itemBuilder: (context, index) { + return _LiveTvHubSection( + key: _hubKeys[index], + hub: _hubs[index], + onTap: _onItemTap, + onLongPress: (entry) => _showProgramDetails(entry, _findChannel(entry.program.channelIdentifier)), + onVerticalNavigation: (isUp) => _handleVerticalNavigation(index, isUp), + onBack: widget.onBack, + ); + }, + ), ); } } diff --git a/lib/watch_together/widgets/watch_together_overlay.dart b/lib/watch_together/widgets/watch_together_overlay.dart index d94480bb..986a7cf6 100644 --- a/lib/watch_together/widgets/watch_together_overlay.dart +++ b/lib/watch_together/widgets/watch_together_overlay.dart @@ -8,6 +8,7 @@ import 'package:provider/provider.dart'; import '../../i18n/strings.g.dart'; import '../../utils/dialogs.dart'; import '../../utils/snackbar_helper.dart'; +import '../../widgets/overlay_sheet.dart'; import '../models/watch_session.dart'; import '../providers/watch_together_provider.dart'; @@ -43,8 +44,7 @@ class WatchTogetherOverlay extends StatelessWidget { } void _showSessionMenu(BuildContext context, WatchTogetherProvider provider) { - showModalBottomSheet( - context: context, + OverlaySheetController.of(context).show( builder: (context) => _SessionMenuSheet(provider: provider, onLeaveSession: onLeaveSession), ); } @@ -249,7 +249,7 @@ class _SessionMenuSheet extends StatelessWidget { style: TextStyle(color: theme.colorScheme.error), ), onTap: () { - Navigator.pop(context); + OverlaySheetController.of(context).close(); _confirmLeave(context); }, contentPadding: EdgeInsets.zero, diff --git a/lib/widgets/file_info_bottom_sheet.dart b/lib/widgets/file_info_bottom_sheet.dart index 2161bdc8..ce1bf2a5 100644 --- a/lib/widgets/file_info_bottom_sheet.dart +++ b/lib/widgets/file_info_bottom_sheet.dart @@ -3,7 +3,6 @@ import 'package:material_symbols_icons/symbols.dart'; import '../models/plex_file_info.dart'; import '../i18n/strings.g.dart'; import 'bottom_sheet_header.dart'; -import 'focusable_bottom_sheet.dart'; class FileInfoBottomSheet extends StatefulWidget { final PlexFileInfo fileInfo; @@ -32,100 +31,84 @@ class _FileInfoBottomSheetState extends State { @override Widget build(BuildContext context) { - return FocusableBottomSheet( - initialFocusNode: _initialFocusNode, - child: Container( - decoration: BoxDecoration( - color: Colors.grey[900], - borderRadius: const BorderRadius.only(topLeft: Radius.circular(16), topRight: Radius.circular(16)), + return Column( + children: [ + // Header + BottomSheetHeader( + title: t.fileInfo.title, + icon: Symbols.info_rounded, + iconColor: Colors.white, + titleColor: Colors.white, + closeFocusNode: _initialFocusNode, ), - child: SafeArea( - child: SizedBox( - height: MediaQuery.of(context).size.height * 0.75, - child: Column( - children: [ - // Header - BottomSheetHeader( - title: t.fileInfo.title, - icon: Symbols.info_rounded, - iconColor: Colors.white, - titleColor: Colors.white, - closeFocusNode: _initialFocusNode, - ), - // Content - Expanded( - child: ListView( - padding: const EdgeInsets.all(16), - children: [ - // Title - if (widget.title.isNotEmpty) ...[ - Text( - widget.title, - style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.w500), - ), - const SizedBox(height: 20), - ], - - // Video Section - _buildSectionHeader(t.fileInfo.video), - const SizedBox(height: 8), - _buildInfoRow(t.fileInfo.codec, widget.fileInfo.videoCodec ?? t.common.unknown), - _buildInfoRow(t.fileInfo.resolution, widget.fileInfo.resolutionFormatted), - _buildInfoRow(t.fileInfo.bitrate, widget.fileInfo.bitrateFormatted), - _buildInfoRow(t.fileInfo.frameRate, widget.fileInfo.frameRateFormatted), - _buildInfoRow(t.fileInfo.aspectRatio, widget.fileInfo.aspectRatioFormatted), - if (widget.fileInfo.videoProfile != null) - _buildInfoRow(t.fileInfo.profile, widget.fileInfo.videoProfile!), - if (widget.fileInfo.bitDepth != null) - _buildInfoRow(t.fileInfo.bitDepth, '${widget.fileInfo.bitDepth} bit'), - if (widget.fileInfo.colorSpace != null) - _buildInfoRow(t.fileInfo.colorSpace, widget.fileInfo.colorSpace!), - if (widget.fileInfo.colorRange != null) - _buildInfoRow(t.fileInfo.colorRange, widget.fileInfo.colorRange!), - if (widget.fileInfo.colorPrimaries != null) - _buildInfoRow(t.fileInfo.colorPrimaries, widget.fileInfo.colorPrimaries!), - if (widget.fileInfo.chromaSubsampling != null) - _buildInfoRow(t.fileInfo.chromaSubsampling, widget.fileInfo.chromaSubsampling!), - const SizedBox(height: 20), - - // Audio Section - _buildSectionHeader(t.fileInfo.audio), - const SizedBox(height: 8), - _buildInfoRow(t.fileInfo.codec, widget.fileInfo.audioCodec ?? t.common.unknown), - _buildInfoRow(t.fileInfo.channels, widget.fileInfo.audioChannelsFormatted), - if (widget.fileInfo.audioProfile != null) - _buildInfoRow(t.fileInfo.profile, widget.fileInfo.audioProfile!), - const SizedBox(height: 20), - - // File Section - _buildSectionHeader(t.fileInfo.file), - const SizedBox(height: 8), - if (widget.fileInfo.filePath != null) - _buildInfoRow(t.fileInfo.path, widget.fileInfo.filePath!, isMonospace: true), - _buildInfoRow(t.fileInfo.size, widget.fileInfo.fileSizeFormatted), - _buildInfoRow(t.fileInfo.container, widget.fileInfo.container ?? t.common.unknown), - _buildInfoRow(t.fileInfo.duration, widget.fileInfo.durationFormatted), - const SizedBox(height: 20), - - // Advanced Section - _buildSectionHeader(t.fileInfo.advanced), - const SizedBox(height: 8), - _buildInfoRow( - t.fileInfo.optimizedForStreaming, - widget.fileInfo.optimizedForStreaming == true ? t.common.yes : t.common.no, - ), - _buildInfoRow( - t.fileInfo.has64bitOffsets, - widget.fileInfo.has64bitOffsets == true ? t.common.yes : t.common.no, - ), - ], - ), + // Content + Expanded( + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + // Title + if (widget.title.isNotEmpty) ...[ + Text( + widget.title, + style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.w500), ), + const SizedBox(height: 20), ], - ), + + // Video Section + _buildSectionHeader(t.fileInfo.video), + const SizedBox(height: 8), + _buildInfoRow(t.fileInfo.codec, widget.fileInfo.videoCodec ?? t.common.unknown), + _buildInfoRow(t.fileInfo.resolution, widget.fileInfo.resolutionFormatted), + _buildInfoRow(t.fileInfo.bitrate, widget.fileInfo.bitrateFormatted), + _buildInfoRow(t.fileInfo.frameRate, widget.fileInfo.frameRateFormatted), + _buildInfoRow(t.fileInfo.aspectRatio, widget.fileInfo.aspectRatioFormatted), + if (widget.fileInfo.videoProfile != null) + _buildInfoRow(t.fileInfo.profile, widget.fileInfo.videoProfile!), + if (widget.fileInfo.bitDepth != null) + _buildInfoRow(t.fileInfo.bitDepth, '${widget.fileInfo.bitDepth} bit'), + if (widget.fileInfo.colorSpace != null) _buildInfoRow(t.fileInfo.colorSpace, widget.fileInfo.colorSpace!), + if (widget.fileInfo.colorRange != null) _buildInfoRow(t.fileInfo.colorRange, widget.fileInfo.colorRange!), + if (widget.fileInfo.colorPrimaries != null) + _buildInfoRow(t.fileInfo.colorPrimaries, widget.fileInfo.colorPrimaries!), + if (widget.fileInfo.chromaSubsampling != null) + _buildInfoRow(t.fileInfo.chromaSubsampling, widget.fileInfo.chromaSubsampling!), + const SizedBox(height: 20), + + // Audio Section + _buildSectionHeader(t.fileInfo.audio), + const SizedBox(height: 8), + _buildInfoRow(t.fileInfo.codec, widget.fileInfo.audioCodec ?? t.common.unknown), + _buildInfoRow(t.fileInfo.channels, widget.fileInfo.audioChannelsFormatted), + if (widget.fileInfo.audioProfile != null) + _buildInfoRow(t.fileInfo.profile, widget.fileInfo.audioProfile!), + const SizedBox(height: 20), + + // File Section + _buildSectionHeader(t.fileInfo.file), + const SizedBox(height: 8), + if (widget.fileInfo.filePath != null) + _buildInfoRow(t.fileInfo.path, widget.fileInfo.filePath!, isMonospace: true), + _buildInfoRow(t.fileInfo.size, widget.fileInfo.fileSizeFormatted), + _buildInfoRow(t.fileInfo.container, widget.fileInfo.container ?? t.common.unknown), + _buildInfoRow(t.fileInfo.duration, widget.fileInfo.durationFormatted), + const SizedBox(height: 20), + + // Advanced Section + _buildSectionHeader(t.fileInfo.advanced), + const SizedBox(height: 8), + _buildInfoRow( + t.fileInfo.optimizedForStreaming, + widget.fileInfo.optimizedForStreaming == true ? t.common.yes : t.common.no, + ), + _buildInfoRow( + t.fileInfo.has64bitOffsets, + widget.fileInfo.has64bitOffsets == true ? t.common.yes : t.common.no, + ), + ], ), ), - ), + ], ); } diff --git a/lib/widgets/focusable_bottom_sheet.dart b/lib/widgets/focusable_bottom_sheet.dart deleted file mode 100644 index 27270c57..00000000 --- a/lib/widgets/focusable_bottom_sheet.dart +++ /dev/null @@ -1,77 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../focus/input_mode_tracker.dart'; -import '../focus/dpad_navigator.dart'; -import '../focus/key_event_utils.dart'; - -/// A wrapper widget that provides autofocus functionality for bottom sheets. -/// -/// When the sheet opens and keyboard/controller mode is active, this widget -/// will automatically request focus on the provided [initialFocusNode]. -/// This enables keyboard/controller navigation within the sheet. -/// -/// When opened via touch/mouse, no autofocus occurs to avoid showing -/// focus indicators unnecessarily. -class FocusableBottomSheet extends StatefulWidget { - /// The content of the bottom sheet. - final Widget child; - - /// The FocusNode to focus when the sheet opens in keyboard mode. - /// If null, no autofocus occurs. - final FocusNode? initialFocusNode; - - const FocusableBottomSheet({super.key, required this.child, this.initialFocusNode}); - - @override - State createState() => _FocusableBottomSheetState(); -} - -class _FocusableBottomSheetState extends State { - @override - void initState() { - super.initState(); - // Clear any stale back key suppression from previous sheet closes - BackKeyUpSuppressor.clearSuppression(); - _requestInitialFocus(); - } - - void _requestInitialFocus() { - if (widget.initialFocusNode == null) return; - - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; - - // Only autofocus when in keyboard/controller mode - if (InputModeTracker.isKeyboardMode(context)) { - widget.initialFocusNode?.requestFocus(); - } - }); - } - - @override - void didUpdateWidget(FocusableBottomSheet oldWidget) { - super.didUpdateWidget(oldWidget); - - // If the focus node changed, request focus on the new one - if (widget.initialFocusNode != oldWidget.initialFocusNode) { - _requestInitialFocus(); - } - } - - @override - Widget build(BuildContext context) { - return Focus( - canRequestFocus: false, - skipTraversal: true, - onKeyEvent: (node, event) { - // Handle select key suppression (for when sheet was opened via select key) - if (SelectKeyUpSuppressor.consumeIfSuppressed(event)) { - return KeyEventResult.handled; - } - // Handle back key to close the bottom sheet - return handleBackKeyNavigation(context, event); - }, - child: widget.child, - ); - } -} diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index 381b8c36..df8cd1c3 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -24,8 +24,8 @@ import '../utils/smart_deletion_handler.dart'; import '../utils/deletion_notifier.dart'; import '../theme/mono_tokens.dart'; import '../widgets/file_info_bottom_sheet.dart'; -import '../widgets/focusable_bottom_sheet.dart'; import '../widgets/focusable_list_tile.dart'; +import '../widgets/overlay_sheet.dart'; import '../i18n/strings.g.dart'; /// Helper class to store menu action data @@ -286,15 +286,26 @@ class MediaContextMenuState extends State { _openedFromKeyboard = false; if (useBottomSheet) { - // Show bottom sheet on mobile - selected = await showModalBottomSheet( - context: context, - builder: (context) => _FocusableContextMenuSheet( - title: widget.item.title, - actions: menuActions, - focusFirstItem: openedFromKeyboard, - ), - ); + // 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, + ), + ); + } } else { // Show custom focusable popup menu on larger screens // Use stored tap position or fallback to widget position @@ -529,12 +540,19 @@ class MediaContextMenuState extends State { if (fileInfo != null && context.mounted) { // Show file info bottom sheet - await showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: Colors.transparent, - builder: (context) => FileInfoBottomSheet(fileInfo: fileInfo, title: metadata.title), - ); + 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), + ); + } } else if (context.mounted) { showErrorSnackBar(context, t.messages.fileInfoNotAvailable); } @@ -574,48 +592,77 @@ class MediaContextMenuState extends State { _MenuAction(value: 'collection', icon: Symbols.collections_rounded, label: t.collections.collection), ]; - final selected = useBottomSheet - ? 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), - ], - ), - ), - ) - : await showMenu( - context: context, - position: RelativeRect.fromLTRB( - _tapPosition?.dx ?? 0, - _tapPosition?.dy ?? 0, - _tapPosition?.dx ?? 0, - _tapPosition?.dy ?? 0, - ), - items: submenuActions.map((action) { - return PopupMenuItem( - value: action.value, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [AppIcon(action.icon, fill: 1, size: 20), const SizedBox(width: 12), Text(action.label)], + 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), ), - ); - }).toList(), + ...submenuActions.map((action) { + return ListTile( + leading: AppIcon(action.icon, fill: 1), + title: Text(action.label), + onTap: () => overlayController.pop(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, + position: RelativeRect.fromLTRB( + _tapPosition?.dx ?? 0, + _tapPosition?.dy ?? 0, + _tapPosition?.dx ?? 0, + _tapPosition?.dy ?? 0, + ), + items: submenuActions.map((action) { + return PopupMenuItem( + value: action.value, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [AppIcon(action.icon, fill: 1, size: 20), const SizedBox(width: 12), Text(action.label)], + ), ); + }).toList(), + ); + } // Handle the submenu selection if (selected == 'playlist' && context.mounted) { @@ -1243,43 +1290,47 @@ class _FocusableContextMenuSheetState extends State<_FocusableContextMenuSheet> @override Widget build(BuildContext context) { - return FocusableBottomSheet( - initialFocusNode: widget.focusFirstItem ? _initialFocusNode : null, - child: SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Padding( - padding: const EdgeInsets.all(16.0), - child: Text( - widget.title, - style: Theme.of(context).textTheme.titleMedium, - maxLines: 1, - overflow: TextOverflow.ellipsis, + return SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.all(16.0), + child: Text( + widget.title, + style: Theme.of(context).textTheme.titleMedium, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + Flexible( + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ...widget.actions.asMap().entries.map((entry) { + final index = entry.key; + final action = entry.value; + return FocusableListTile( + 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); + } + }, + hoverColor: action.hoverColor, + ); + }), + ], ), ), - Flexible( - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ...widget.actions.asMap().entries.map((entry) { - final index = entry.key; - final action = entry.value; - return FocusableListTile( - focusNode: index == 0 ? _initialFocusNode : null, - leading: AppIcon(action.icon, fill: 1), - title: Text(action.label), - onTap: () => Navigator.pop(context, action.value), - hoverColor: action.hoverColor, - ); - }), - ], - ), - ), - ), - ], - ), + ), + ], ), ); } diff --git a/lib/widgets/overlay_sheet.dart b/lib/widgets/overlay_sheet.dart index 70ee2a4d..0d5c7318 100644 --- a/lib/widgets/overlay_sheet.dart +++ b/lib/widgets/overlay_sheet.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import '../focus/dpad_navigator.dart'; import '../focus/key_event_utils.dart'; @@ -10,8 +11,9 @@ import '../utils/platform_detector.dart'; class _OverlaySheetEntry { final WidgetBuilder builder; final Completer completer; + final FocusNode? initialFocusNode; - _OverlaySheetEntry({required this.builder, required this.completer}); + _OverlaySheetEntry({required this.builder, required this.completer, this.initialFocusNode}); } /// Provides [OverlaySheetController] to descendants via [of] / [maybeOf]. @@ -21,8 +23,7 @@ class _OverlaySheetScope extends InheritedWidget { const _OverlaySheetScope({required this.controller, required super.child}); @override - bool updateShouldNotify(_OverlaySheetScope oldWidget) => - controller != oldWidget.controller; + bool updateShouldNotify(_OverlaySheetScope oldWidget) => controller != oldWidget.controller; } /// Controller for the overlay-based bottom sheet system. @@ -53,18 +54,21 @@ class OverlaySheetController { BoxConstraints? constraints, Color? backgroundColor, bool barrierDismissible = true, + FocusNode? initialFocusNode, }) { return _state._show( builder: builder, constraints: constraints, backgroundColor: backgroundColor, barrierDismissible: barrierDismissible, + initialFocusNode: initialFocusNode, ); } - /// Push a sub-page within the open sheet. - void push({required WidgetBuilder builder}) { - _state._push(builder: builder); + /// Push a sub-page within the open sheet. Returns a Future that completes + /// when the pushed page is popped (with an optional result). + Future push({required WidgetBuilder builder, FocusNode? initialFocusNode}) { + return _state._push(builder: builder, initialFocusNode: initialFocusNode); } /// Pop the top sub-page, or close the sheet if on the last page. @@ -102,8 +106,7 @@ class OverlaySheetHost extends StatefulWidget { State createState() => _OverlaySheetHostState(); } -class _OverlaySheetHostState extends State - with SingleTickerProviderStateMixin { +class _OverlaySheetHostState extends State with SingleTickerProviderStateMixin { late final AnimationController _animationController; late final Animation _slideAnimation; late final Animation _barrierAnimation; @@ -127,23 +130,16 @@ class _OverlaySheetHostState extends State super.initState(); _controller = OverlaySheetController._(this); - _animationController = AnimationController( - duration: const Duration(milliseconds: 250), - vsync: this, + _animationController = AnimationController(duration: const Duration(milliseconds: 250), vsync: this); + + _slideAnimation = Tween(begin: const Offset(0, 1), end: Offset.zero).animate( + CurvedAnimation(parent: _animationController, curve: Curves.easeOutCubic, reverseCurve: Curves.easeInCubic), ); - _slideAnimation = Tween( - begin: const Offset(0, 1), - end: Offset.zero, - ).animate(CurvedAnimation( - parent: _animationController, - curve: Curves.easeOutCubic, - reverseCurve: Curves.easeInCubic, - )); - - _barrierAnimation = Tween(begin: 0, end: 0.5).animate( - CurvedAnimation(parent: _animationController, curve: Curves.easeOutCubic), - ); + _barrierAnimation = Tween( + begin: 0, + end: 0.5, + ).animate(CurvedAnimation(parent: _animationController, curve: Curves.easeOutCubic)); } @override @@ -163,6 +159,7 @@ class _OverlaySheetHostState extends State BoxConstraints? constraints, Color? backgroundColor, bool barrierDismissible = true, + FocusNode? initialFocusNode, }) { // If already open, close first (instant) if (_isOpen) { @@ -176,7 +173,7 @@ class _OverlaySheetHostState extends State } final completer = Completer(); - final entry = _OverlaySheetEntry(builder: builder, completer: completer); + final entry = _OverlaySheetEntry(builder: builder, completer: completer, initialFocusNode: initialFocusNode); setState(() { _pageStack.add(entry); @@ -196,17 +193,20 @@ class _OverlaySheetHostState extends State return completer.future; } - void _push({required WidgetBuilder builder}) { - if (!_isOpen || _isClosing) return; + Future _push({required WidgetBuilder builder, FocusNode? initialFocusNode}) { + if (!_isOpen || _isClosing) { + return Future.value(null); + } - final completer = Completer(); - final entry = _OverlaySheetEntry(builder: builder, completer: completer); + final completer = Completer(); + final entry = _OverlaySheetEntry(builder: builder, completer: completer, initialFocusNode: initialFocusNode); setState(() { _pageStack.add(entry); }); _autoFocus(); + return completer.future; } void _pop([dynamic result]) { @@ -257,7 +257,25 @@ class _OverlaySheetHostState extends State _sheetFocusScopeNode.requestFocus(); WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted || !_isOpen) return; - _focusFirstDescendant(); + // If the current top entry has an initialFocusNode that is attached, + // focus that instead of the first descendant. + final topEntry = _pageStack.isNotEmpty ? _pageStack.last : null; + final initialNode = topEntry?.initialFocusNode; + if (initialNode != null && initialNode.context != null) { + initialNode.requestFocus(); + } else { + _focusFirstDescendant(); + } + + // Clear stale select suppression from the press that opened this sheet, + // but only if no select key is currently held down. This handles: + // - Short press: key already released → clear flag (prevents first + // select inside the sheet from being eaten). + // - Long press: key still held → keep flag so KeyRepeat/KeyUp events + // from the long press are correctly suppressed. + if (!HardwareKeyboard.instance.logicalKeysPressed.any((k) => k.isSelectKey)) { + SelectKeyUpSuppressor.clearSuppression(); + } }); }); } @@ -268,7 +286,13 @@ class _OverlaySheetHostState extends State _sheetFocusScopeNode.requestFocus(); WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted || !_isOpen) return; - _focusFirstDescendant(); + final topEntry = _pageStack.isNotEmpty ? _pageStack.last : null; + final initialNode = topEntry?.initialFocusNode; + if (initialNode != null && initialNode.context != null) { + initialNode.requestFocus(); + } else { + _focusFirstDescendant(); + } }); }); } @@ -331,9 +355,7 @@ class _OverlaySheetHostState extends State builder: (context, child) { return GestureDetector( onTap: _barrierDismissible ? () => _close() : null, - child: Container( - color: Colors.black.withValues(alpha: _barrierAnimation.value), - ), + child: Container(color: Colors.black.withValues(alpha: _barrierAnimation.value)), ); }, ), @@ -349,7 +371,8 @@ class _OverlaySheetHostState extends State final size = MediaQuery.of(context).size; final isDesktop = size.width > 600; - final effectiveConstraints = _constraints ?? + final effectiveConstraints = + _constraints ?? BoxConstraints( maxWidth: isDesktop ? 700 : double.infinity, maxHeight: isDesktop ? 400 : size.height * 0.75, @@ -376,9 +399,7 @@ class _OverlaySheetHostState extends State top: false, child: ConstrainedBox( constraints: effectiveConstraints, - child: _pageStack.isNotEmpty - ? _pageStack.last.builder(context) - : const SizedBox.shrink(), + child: _pageStack.isNotEmpty ? _pageStack.last.builder(context) : const SizedBox.shrink(), ), ), ), diff --git a/lib/widgets/video_controls/sheets/base_video_control_sheet.dart b/lib/widgets/video_controls/sheets/base_video_control_sheet.dart index 8f22866b..386de375 100644 --- a/lib/widgets/video_controls/sheets/base_video_control_sheet.dart +++ b/lib/widgets/video_controls/sheets/base_video_control_sheet.dart @@ -21,37 +21,6 @@ class BaseVideoControlSheet extends StatelessWidget { this.onBack, }); - /// Get consistent bottom sheet constraints across all video control sheets - static BoxConstraints getBottomSheetConstraints(BuildContext context) { - final size = MediaQuery.of(context).size; - final isDesktop = size.width > 600; - - return BoxConstraints( - maxWidth: isDesktop ? 700 : double.infinity, - maxHeight: isDesktop ? 400 : size.height * 0.75, - minHeight: isDesktop ? 300 : size.height * 0.5, - ); - } - - /// Helper method to show a modal bottom sheet with consistent styling - static Future showSheet({ - required BuildContext context, - required WidgetBuilder builder, - VoidCallback? onOpen, - VoidCallback? onClose, - }) { - onOpen?.call(); - return showModalBottomSheet( - context: context, - backgroundColor: Colors.grey[900], - isScrollControlled: true, - constraints: getBottomSheetConstraints(context), - builder: builder, - ).whenComplete(() { - onClose?.call(); - }); - } - @override Widget build(BuildContext context) { Widget content = Column( @@ -79,10 +48,7 @@ class BaseVideoControlSheet extends StatelessWidget { } return SafeArea( - child: SizedBox( - height: MediaQuery.of(context).size.height * 0.75, - child: content, - ), + child: SizedBox(height: MediaQuery.of(context).size.height * 0.75, child: content), ); } } diff --git a/lib/widgets/video_controls/sheets/video_control_sheet_launcher.dart b/lib/widgets/video_controls/sheets/video_control_sheet_launcher.dart deleted file mode 100644 index d7dace51..00000000 --- a/lib/widgets/video_controls/sheets/video_control_sheet_launcher.dart +++ /dev/null @@ -1,17 +0,0 @@ -import 'package:flutter/material.dart'; -import 'base_video_control_sheet.dart'; - -/// Helper class to launch video control sheets with consistent behavior -/// -/// This eliminates the need for each sheet to duplicate the showSheet wrapper. -class VideoControlSheetLauncher { - /// Show a video control sheet with consistent styling and callbacks - static Future show({ - required BuildContext context, - required WidgetBuilder builder, - VoidCallback? onOpen, - VoidCallback? onClose, - }) { - return BaseVideoControlSheet.showSheet(context: context, onOpen: onOpen, onClose: onClose, builder: builder); - } -}