refactor: migrate bottom sheets to overlay system

This commit is contained in:
edde746
2026-02-19 00:18:35 +01:00
parent 63d711ae43
commit 9e7c66f66a
18 changed files with 1095 additions and 1134 deletions
+4
View File
@@ -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) {
@@ -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<MobileRemoteScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(t.companionRemote.title),
actions: [
Consumer<CompanionRemoteProvider>(
builder: (context, provider, child) {
if (provider.isConnected) {
return IconButton(
icon: const Icon(Icons.link_off),
onPressed: () async {
final confirmed = await showDialog<bool>(
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<CompanionRemoteProvider>(
builder: (context, provider, child) {
if (provider.isConnected) {
return IconButton(
icon: const Icon(Icons.link_off),
onPressed: () async {
final confirmed = await showDialog<bool>(
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<CompanionRemoteProvider>().leaveSession();
}
},
tooltip: t.common.disconnect,
);
}
return const SizedBox.shrink();
},
),
],
),
body: Consumer<CompanionRemoteProvider>(
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<CompanionRemoteProvider>().leaveSession();
}
},
icon: const Icon(Icons.link),
label: Text(t.companionRemote.connectToDevice),
),
],
),
);
}
tooltip: t.common.disconnect,
);
}
return const SizedBox.shrink();
},
),
],
),
body: Consumer<CompanionRemoteProvider>(
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<CompanionRemoteProvider>();
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
+54 -53
View File
@@ -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<HubDetailScreen> 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<HubDetailScreen> 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<SettingsProvider>().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<SettingsProvider>().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,
);
},
),
],
),
);
}
}
+140 -156
View File
@@ -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<FiltersBottomSheet> {
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<FiltersBottomSheet> {
@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),
);
},
),
),
],
);
}
}
+170 -144
View File
@@ -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<LibrariesScreen>
),
);
} 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<LibrariesScreen>
// 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>();
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>();
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<void> _showLibraryMenuBottomSheet(BuildContext outerContext, PlexLibrary library) async {
final menuItems = widget.getLibraryMenuItems(library);
final selected = await showModalBottomSheet<String>(
context: outerContext,
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Text(library.title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
),
...menuItems.indexed.map(
(entry) => ListTile(
leading: AppIcon(entry.$2.icon, fill: 1),
title: Text(entry.$2.label),
onTap: () => Navigator.pop(context, entry.$2.value),
final controller = OverlaySheetController.maybeOf(outerContext);
final String? selected;
if (controller != null) {
selected = await controller.push<String>(
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Text(library.title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
),
),
],
...menuItems.indexed.map(
(entry) => ListTile(
leading: AppIcon(entry.$2.icon, fill: 1),
title: Text(entry.$2.label),
onTap: () => controller.pop(entry.$2.value),
),
),
],
),
),
),
);
);
} else {
selected = await showModalBottomSheet<String>(
context: outerContext,
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Text(library.title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
),
...menuItems.indexed.map(
(entry) => ListTile(
leading: AppIcon(entry.$2.icon, fill: 1),
title: Text(entry.$2.label),
onTap: () => Navigator.pop(context, entry.$2.value),
),
),
],
),
),
);
}
if (selected != null && mounted) {
// Find the selected item to check if confirmation is needed
+67 -78
View File
@@ -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<SortBottomSheet> {
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<SortBottomSheet> {
_currentDescending = descending;
});
widget.onSortChanged(sort, descending);
Navigator.pop(context);
OverlaySheetController.of(context).close();
}
void _handleClear() {
@@ -77,89 +82,73 @@ class _SortBottomSheetState extends State<SortBottomSheet> {
_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<PlexSort>(
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<bool>(
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<bool> 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<PlexSort>(
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<bool>(
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<bool> newSelection) {
_handleDirectionChange(sort, newSelection.first);
},
)
: null,
),
),
],
);
},
),
);
},
),
),
],
);
}
}
+119 -115
View File
@@ -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<PlexMetadata, LibraryBr
void _showGroupingBottomSheet() {
SelectKeyUpSuppressor.suppressSelectUntilKeyUp();
var pendingGrouping = _selectedGrouping;
showModalBottomSheet(
context: context,
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<String>(
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<String>(
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<PlexMetadata, LibraryBr
PlexSort? pendingSort = _selectedSort;
bool pendingDescending = _isSortDescending;
bool pendingCleared = false;
showModalBottomSheet(
context: context,
isScrollControlled: true,
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;
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<PlexMetadata, LibraryBr
// For folders mode, use FolderTreeView instead of grid/list
if (_selectedGrouping == 'folders') {
return Column(
children: [
_buildChipsBar(),
Expanded(
child: FolderTreeView(
libraryKey: widget.library.key,
serverId: widget.library.serverId,
onRefresh: updateItem,
return OverlaySheetHost(
child: Column(
children: [
_buildChipsBar(),
Expanded(
child: FolderTreeView(
libraryKey: widget.library.key,
serverId: widget.library.serverId,
onRefresh: updateItem,
),
),
),
],
],
),
);
}
// For list/grid modes, use Stack with chips layered on top of grid.
// This allows the grid to use Clip.none for focus decorations while
// the chips bar (with background) covers any overflow at the top.
return Stack(
children: [
// Grid fills the entire area, with top padding for chips bar
Positioned.fill(child: _buildScrollableContent()),
// Chips bar on top with solid background
Positioned(top: 0, left: 0, right: 0, child: _buildChipsBar()),
// Alpha jump bar / scroll handle on the right edge
if (_shouldShowAlphaJumpBar)
Positioned(
top: _chipsBarHeight,
right: 0,
bottom: 0,
child: _isPhone(context)
? AlphaScrollHandle(
firstCharacters: _firstCharacters,
onJump: _jumpToIndex,
currentFirstVisibleIndex: _currentFirstVisibleIndex,
isScrolling: _isScrollActive,
)
: AlphaJumpBar(
firstCharacters: _firstCharacters,
onJump: _jumpToIndex,
currentFirstVisibleIndex: _currentFirstVisibleIndex,
focusNode: _alphaJumpBarFocusNode,
onNavigateLeft: _navigateToGridNearScroll,
onBack: _navigateToGridNearScroll,
),
),
],
return OverlaySheetHost(
child: Stack(
children: [
// Grid fills the entire area, with top padding for chips bar
Positioned.fill(child: _buildScrollableContent()),
// Chips bar on top with solid background
Positioned(top: 0, left: 0, right: 0, child: _buildChipsBar()),
// Alpha jump bar / scroll handle on the right edge
if (_shouldShowAlphaJumpBar)
Positioned(
top: _chipsBarHeight,
right: 0,
bottom: 0,
child: _isPhone(context)
? AlphaScrollHandle(
firstCharacters: _firstCharacters,
onJump: _jumpToIndex,
currentFirstVisibleIndex: _currentFirstVisibleIndex,
isScrolling: _isScrollActive,
)
: AlphaJumpBar(
firstCharacters: _firstCharacters,
onJump: _jumpToIndex,
currentFirstVisibleIndex: _currentFirstVisibleIndex,
focusNode: _alphaJumpBarFocusNode,
onNavigateLeft: _navigateToGridNearScroll,
onBack: _navigateToGridNearScroll,
),
),
],
),
);
}
@@ -12,6 +12,7 @@ import '../../utils/live_tv_player_navigation.dart';
import '../../utils/plex_image_helper.dart';
import '../../widgets/app_icon.dart';
import '../../widgets/focused_scroll_scaffold.dart';
import '../../widgets/overlay_sheet.dart';
import 'program_details_sheet.dart';
/// Shows all upcoming airings of a show, matching the Plex "upcoming episodes" view.
@@ -126,39 +127,41 @@ class _LiveTvShowScheduleScreenState extends State<LiveTvShowScheduleScreen> {
@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),
),
],
),
);
}
}
+108 -90
View File
@@ -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<FocusNode> _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 = <Widget>[];
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),
],
),
);
}
+8 -5
View File
@@ -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<GuideTab> {
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),
),
);
}
+17 -14
View File
@@ -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<WhatsOnTab> {
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,
);
},
),
);
}
}
@@ -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,
+73 -90
View File
@@ -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<FileInfoBottomSheet> {
@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,
),
],
),
),
),
],
);
}
-77
View File
@@ -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<FocusableBottomSheet> createState() => _FocusableBottomSheetState();
}
class _FocusableBottomSheetState extends State<FocusableBottomSheet> {
@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,
);
}
}
+142 -91
View File
@@ -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<MediaContextMenu> {
_openedFromKeyboard = false;
if (useBottomSheet) {
// Show bottom sheet on mobile
selected = await showModalBottomSheet<String>(
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<String>(
builder: (context) => _FocusableContextMenuSheet(
title: widget.item.title,
actions: menuActions,
focusFirstItem: openedFromKeyboard,
),
);
} else {
selected = await showModalBottomSheet<String>(
context: context,
builder: (context) => _FocusableContextMenuSheet(
title: widget.item.title,
actions: menuActions,
focusFirstItem: openedFromKeyboard,
),
);
}
} 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<MediaContextMenu> {
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<MediaContextMenu> {
_MenuAction(value: 'collection', icon: Symbols.collections_rounded, label: t.collections.collection),
];
final selected = useBottomSheet
? await showModalBottomSheet<String>(
context: context,
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Text(t.common.addTo, style: Theme.of(context).textTheme.titleMedium),
),
...submenuActions.map((action) {
return ListTile(
leading: AppIcon(action.icon, fill: 1),
title: Text(action.label),
onTap: () => Navigator.pop(context, action.value),
);
}),
const SizedBox(height: 8),
],
),
),
)
: await showMenu<String>(
context: context,
position: RelativeRect.fromLTRB(
_tapPosition?.dx ?? 0,
_tapPosition?.dy ?? 0,
_tapPosition?.dx ?? 0,
_tapPosition?.dy ?? 0,
),
items: submenuActions.map((action) {
return PopupMenuItem<String>(
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<String>(
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Text(t.common.addTo, style: Theme.of(context).textTheme.titleMedium),
),
);
}).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<String>(
context: context,
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Text(t.common.addTo, style: Theme.of(context).textTheme.titleMedium),
),
...submenuActions.map((action) {
return ListTile(
leading: AppIcon(action.icon, fill: 1),
title: Text(action.label),
onTap: () => Navigator.pop(context, action.value),
);
}),
const SizedBox(height: 8),
],
),
),
);
}
} else {
selected = await showMenu<String>(
context: context,
position: RelativeRect.fromLTRB(
_tapPosition?.dx ?? 0,
_tapPosition?.dy ?? 0,
_tapPosition?.dx ?? 0,
_tapPosition?.dy ?? 0,
),
items: submenuActions.map((action) {
return PopupMenuItem<String>(
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,
);
}),
],
),
),
),
],
),
),
],
),
);
}
+58 -37
View File
@@ -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<dynamic> 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<T>(
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<T?> push<T>({required WidgetBuilder builder, FocusNode? initialFocusNode}) {
return _state._push<T>(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<OverlaySheetHost> createState() => _OverlaySheetHostState();
}
class _OverlaySheetHostState extends State<OverlaySheetHost>
with SingleTickerProviderStateMixin {
class _OverlaySheetHostState extends State<OverlaySheetHost> with SingleTickerProviderStateMixin {
late final AnimationController _animationController;
late final Animation<Offset> _slideAnimation;
late final Animation<double> _barrierAnimation;
@@ -127,23 +130,16 @@ class _OverlaySheetHostState extends State<OverlaySheetHost>
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<Offset>(begin: const Offset(0, 1), end: Offset.zero).animate(
CurvedAnimation(parent: _animationController, curve: Curves.easeOutCubic, reverseCurve: Curves.easeInCubic),
);
_slideAnimation = Tween<Offset>(
begin: const Offset(0, 1),
end: Offset.zero,
).animate(CurvedAnimation(
parent: _animationController,
curve: Curves.easeOutCubic,
reverseCurve: Curves.easeInCubic,
));
_barrierAnimation = Tween<double>(begin: 0, end: 0.5).animate(
CurvedAnimation(parent: _animationController, curve: Curves.easeOutCubic),
);
_barrierAnimation = Tween<double>(
begin: 0,
end: 0.5,
).animate(CurvedAnimation(parent: _animationController, curve: Curves.easeOutCubic));
}
@override
@@ -163,6 +159,7 @@ class _OverlaySheetHostState extends State<OverlaySheetHost>
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<OverlaySheetHost>
}
final completer = Completer<T?>();
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<OverlaySheetHost>
return completer.future;
}
void _push({required WidgetBuilder builder}) {
if (!_isOpen || _isClosing) return;
Future<T?> _push<T>({required WidgetBuilder builder, FocusNode? initialFocusNode}) {
if (!_isOpen || _isClosing) {
return Future.value(null);
}
final completer = Completer<dynamic>();
final entry = _OverlaySheetEntry(builder: builder, completer: completer);
final completer = Completer<T?>();
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<OverlaySheetHost>
_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<OverlaySheetHost>
_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<OverlaySheetHost>
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<OverlaySheetHost>
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<OverlaySheetHost>
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(),
),
),
),
@@ -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<T?> showSheet<T>({
required BuildContext context,
required WidgetBuilder builder,
VoidCallback? onOpen,
VoidCallback? onClose,
}) {
onOpen?.call();
return showModalBottomSheet<T>(
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),
);
}
}
@@ -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<T?> show<T>({
required BuildContext context,
required WidgetBuilder builder,
VoidCallback? onOpen,
VoidCallback? onClose,
}) {
return BaseVideoControlSheet.showSheet<T>(context: context, onOpen: onOpen, onClose: onClose, builder: builder);
}
}