refactor: migrate bottom sheets to overlay system
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user