fix: overlay sheet position, scroll-to-selected in bottom sheets

This commit is contained in:
edde746
2026-03-21 11:49:08 +01:00
parent 1980ce92d5
commit 0539c1d869
9 changed files with 198 additions and 63 deletions
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:plezy/widgets/app_icon.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../models/plex_filter.dart';
import '../../utils/scroll_utils.dart';
import '../../widgets/app_bar_back_button.dart';
import '../../widgets/bottom_sheet_header.dart';
import '../../widgets/focusable_list_tile.dart';
@@ -38,6 +39,8 @@ class _FiltersBottomSheetState extends State<FiltersBottomSheet> {
static const int _maxCachedDisplayNames = 1000;
late List<PlexFilter> _sortedFilters;
late final FocusNode _initialFocusNode;
final _valuesFirstItemKey = GlobalKey();
final _valuesScrollController = ScrollController();
String _cacheKey(String filter, String value) => '${widget.serverId}:${widget.libraryKey}:$filter:$value';
@@ -51,6 +54,7 @@ class _FiltersBottomSheetState extends State<FiltersBottomSheet> {
@override
void dispose() {
_valuesScrollController.dispose();
_initialFocusNode.dispose();
super.dispose();
}
@@ -83,6 +87,15 @@ class _FiltersBottomSheetState extends State<FiltersBottomSheet> {
_filterValues = values;
_isLoadingValues = false;
});
// Scroll to selected value if any
final selectedValue = _tempSelectedFilters[filter.filter];
if (selectedValue != null) {
// +1 because index 0 is the "All" row
final idx = values.indexWhere((v) => _extractFilterValue(v.key, filter.filter) == selectedValue) + 1;
if (idx > 0) {
scrollToCurrentItem(_valuesScrollController, _valuesFirstItemKey, idx);
}
}
} catch (e) {
if (!mounted) return;
setState(() {
@@ -134,12 +147,14 @@ class _FiltersBottomSheetState extends State<FiltersBottomSheet> {
else
Expanded(
child: ListView.builder(
controller: _valuesScrollController,
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: _filterValues.length + 1,
itemBuilder: (context, index) {
if (index == 0) {
final isSelected = !_tempSelectedFilters.containsKey(_currentFilter!.filter);
return FocusableListTile(
key: _valuesFirstItemKey,
focusNode: _initialFocusNode,
title: Text(t.libraries.all),
selected: isSelected,
@@ -4,6 +4,7 @@ import 'package:material_symbols_icons/symbols.dart';
import '../../focus/dpad_navigator.dart';
import '../../focus/input_mode_tracker.dart';
import '../../models/plex_sort.dart';
import '../../utils/scroll_utils.dart';
import '../../widgets/bottom_sheet_header.dart';
import '../../widgets/focusable_list_tile.dart';
import '../../widgets/overlay_sheet.dart';
@@ -33,6 +34,8 @@ class _SortBottomSheetState extends State<SortBottomSheet> {
late PlexSort? _currentSort;
late bool _currentDescending;
late final FocusNode _initialFocusNode;
final _firstItemKey = GlobalKey();
final _scrollController = ScrollController();
@override
void initState() {
@@ -40,6 +43,15 @@ class _SortBottomSheetState extends State<SortBottomSheet> {
_currentSort = widget.selectedSort;
_currentDescending = widget.isSortDescending;
_initialFocusNode = FocusNode(debugLabel: 'SortBottomSheetInitialFocus');
// Scroll to selected item, then handle focus
final selectedIndex = widget.selectedSort != null
? widget.sortOptions.indexWhere((s) => s.key == widget.selectedSort!.key)
: -1;
if (selectedIndex > 0) {
scrollToCurrentItem(_scrollController, _firstItemKey, selectedIndex);
}
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
if (!InputModeTracker.isKeyboardMode(context)) return;
@@ -57,6 +69,7 @@ class _SortBottomSheetState extends State<SortBottomSheet> {
@override
void dispose() {
_scrollController.dispose();
_initialFocusNode.dispose();
super.dispose();
}
@@ -102,6 +115,7 @@ class _SortBottomSheetState extends State<SortBottomSheet> {
if (value != null) _handleSortSelect(value);
},
child: ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: widget.sortOptions.length,
itemBuilder: (context, index) {
@@ -109,6 +123,7 @@ class _SortBottomSheetState extends State<SortBottomSheet> {
final isSelected = _currentSort?.key == sort.key;
return Focus(
key: index == 0 ? _firstItemKey : null,
canRequestFocus: false,
skipTraversal: true,
onKeyEvent: (node, event) {
+16
View File
@@ -12,6 +12,22 @@ void scrollContextToCenter(BuildContext? context) {
});
}
/// Jump a vertical [ListView] so that [currentIndex] is visible.
///
/// Measures the first item (via [firstItemKey]) to get the real item height,
/// then scrolls to `currentIndex * itemHeight`, clamped to max extent.
/// Call once after the first build; the callback is a no-op if the key or
/// controller aren't ready yet.
void scrollToCurrentItem(ScrollController controller, GlobalKey firstItemKey, int currentIndex) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!controller.hasClients) return;
final itemHeight = (firstItemKey.currentContext?.findRenderObject() as RenderBox?)?.size.height;
if (itemHeight == null) return;
final target = (currentIndex * itemHeight).clamp(0.0, controller.position.maxScrollExtent);
controller.jumpTo(target);
});
}
/// Scroll a horizontal list to center the item at the given index.
///
/// Assumes items are laid out with [leadingPadding] before the first item,
+11 -11
View File
@@ -582,17 +582,17 @@ class _OverlaySheetHostState extends State<OverlaySheetHost> with SingleTickerPr
canRequestFocus: false,
skipTraversal: true,
onKeyEvent: _handleKeyEvent,
child: AnimatedBuilder(
animation: _slideCurve,
builder: (context, child) {
final slideOffset = Offset.lerp(slideBegin, Offset.zero, _slideCurve.value)!;
return FractionalTranslation(
translation: slideOffset,
child: child,
);
},
child: Align(
alignment: _alignment,
child: Align(
alignment: _alignment,
child: AnimatedBuilder(
animation: _slideCurve,
builder: (context, child) {
final slideOffset = Offset.lerp(slideBegin, Offset.zero, _slideCurve.value)!;
return FractionalTranslation(
translation: slideOffset,
child: child,
);
},
child: Transform.translate(
offset: Offset(0, _dragOffset.clamp(0, double.infinity)),
child: SafeArea(
@@ -43,6 +43,7 @@ class TrackSelectionHelper {
required BuildContext context,
required bool isSelected,
required VoidCallback onTap,
Key? key,
FocusNode? focusNode,
VoidCallback? onLongPress,
VoidCallback? onSecondaryTap,
@@ -50,6 +51,7 @@ class TrackSelectionHelper {
}) {
return _buildSelectableTile(
context: context,
key: key,
label: 'Off',
isSelected: isSelected,
onTap: onTap,
@@ -66,6 +68,7 @@ class TrackSelectionHelper {
required String label,
required bool isSelected,
required VoidCallback onTap,
Key? key,
FocusNode? focusNode,
VoidCallback? onLongPress,
VoidCallback? onSecondaryTap,
@@ -73,6 +76,7 @@ class TrackSelectionHelper {
}) {
return _buildSelectableTile(
context: context,
key: key,
label: label,
isSelected: isSelected,
onTap: onTap,
@@ -103,6 +107,7 @@ class TrackSelectionHelper {
required String label,
required bool isSelected,
required VoidCallback onTap,
Key? key,
FocusNode? focusNode,
VoidCallback? onLongPress,
VoidCallback? onSecondaryTap,
@@ -117,6 +122,7 @@ class TrackSelectionHelper {
}
Widget tile = FocusableListTile(
key: key,
focusNode: focusNode,
title: Text(label, style: TextStyle(color: isSelected ? primaryColor : null)),
trailing: trailing,
@@ -47,6 +47,6 @@ class BaseVideoControlSheet extends StatelessWidget {
);
}
return SizedBox(height: MediaQuery.of(context).size.height * 0.75, child: content);
return content;
}
}
@@ -13,6 +13,7 @@ import '../../../theme/mono_tokens.dart';
import '../../../utils/formatters.dart';
import '../../../utils/player_utils.dart';
import '../../../utils/provider_extensions.dart';
import '../../../utils/scroll_utils.dart';
import '../../../widgets/focusable_list_tile.dart';
import '../../../widgets/overlay_sheet.dart';
import 'base_video_control_sheet.dart';
@@ -40,6 +41,16 @@ class ChapterSheet extends StatefulWidget {
}
class _ChapterSheetState extends State<ChapterSheet> {
final _firstItemKey = GlobalKey();
final _scrollController = ScrollController();
bool _didInitialScroll = false;
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
Future<void> _handleChapterTap(Duration position) async {
final clamped = clampSeekPosition(widget.player, position);
await widget.player.seek(clamped);
@@ -86,7 +97,13 @@ class _ChapterSheetState extends State<ChapterSheet> {
child: Text(t.videoControls.noChaptersAvailable, style: TextStyle(color: tokens(context).textMuted)),
);
} else {
if (!_didInitialScroll && currentChapterIndex != null && currentChapterIndex > 0) {
_didInitialScroll = true;
scrollToCurrentItem(_scrollController, _firstItemKey, currentChapterIndex);
}
content = ListView.builder(
controller: _scrollController,
itemCount: widget.chapters.length,
itemBuilder: (context, index) {
final chapter = widget.chapters[index];
@@ -98,6 +115,7 @@ class _ChapterSheetState extends State<ChapterSheet> {
: null;
return FocusableListTile(
key: index == 0 ? _firstItemKey : null,
leading: chapter.thumb != null
? SizedBox(
width: 60,
@@ -8,21 +8,36 @@ import '../../../models/plex_metadata.dart';
import '../../../providers/playback_state_provider.dart';
import '../../../theme/mono_tokens.dart';
import '../../../utils/provider_extensions.dart';
import '../../../utils/scroll_utils.dart';
import '../../../widgets/focusable_list_tile.dart';
import '../../../widgets/overlay_sheet.dart';
import 'base_video_control_sheet.dart';
import '../../plex_optimized_image.dart';
const _kEstimatedItemHeight = 72.0;
const _kThumbWidth = 60.0;
const _kThumbHeight = 34.0;
/// Bottom sheet for viewing and navigating the play queue
class QueueSheet extends StatelessWidget {
class QueueSheet extends StatefulWidget {
final Function(PlexMetadata) onItemSelected;
const QueueSheet({super.key, required this.onItemSelected});
@override
State<QueueSheet> createState() => _QueueSheetState();
}
class _QueueSheetState extends State<QueueSheet> {
final _firstItemKey = GlobalKey();
final _scrollController = ScrollController();
bool _didInitialScroll = false;
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Consumer<PlaybackStateProvider>(
@@ -36,13 +51,14 @@ class QueueSheet extends StatelessWidget {
child: Text(t.videoControls.noQueueItems, style: TextStyle(color: tokens(context).textMuted)),
);
} else {
// Find current index for initial scroll
final currentIndex = items.indexWhere((item) => item.playQueueItemID == currentItemID);
if (!_didInitialScroll && currentIndex > 0) {
_didInitialScroll = true;
scrollToCurrentItem(_scrollController, _firstItemKey, currentIndex);
}
content = ListView.builder(
controller: currentIndex > 0
? ScrollController(initialScrollOffset: currentIndex * _kEstimatedItemHeight)
: null,
controller: _scrollController,
itemCount: items.length,
itemBuilder: (context, index) {
final item = items[index];
@@ -50,6 +66,7 @@ class QueueSheet extends StatelessWidget {
final primaryColor = Theme.of(context).colorScheme.primary;
return FocusableListTile(
key: index == 0 ? _firstItemKey : null,
leading: _buildThumbnail(context, item, isCurrent),
title: Text(
item.title!,
@@ -71,7 +88,7 @@ class QueueSheet extends StatelessWidget {
),
trailing: isCurrent ? AppIcon(Symbols.play_circle_rounded, fill: 1, color: primaryColor) : null,
onTap: () {
onItemSelected(item);
widget.onItemSelected(item);
OverlaySheetController.of(context).close();
},
);
@@ -88,7 +105,7 @@ class QueueSheet extends StatelessWidget {
if (item.thumb == null) return null;
// Try to get client for thumbnails, may fail in offline mode
final client = _tryGetClient(context, item);
final client = context.tryGetClientForServer(item.serverId);
return SizedBox(
width: _kThumbWidth,
@@ -133,8 +150,4 @@ class QueueSheet extends StatelessWidget {
}
return item.mediaType.name;
}
static dynamic _tryGetClient(BuildContext context, PlexMetadata item) {
return context.tryGetClientForServer(item.serverId);
}
}
@@ -3,6 +3,7 @@ import 'package:material_symbols_icons/symbols.dart';
import '../../../mpv/mpv.dart';
import '../../../i18n/strings.g.dart';
import '../../../utils/scroll_utils.dart';
import '../../../utils/track_label_builder.dart';
import '../../../widgets/overlay_sheet.dart';
import 'base_video_control_sheet.dart';
@@ -125,7 +126,7 @@ class TrackSheet extends StatelessWidget {
}
}
class _AudioColumn extends StatelessWidget {
class _AudioColumn extends StatefulWidget {
final List<AudioTrack> tracks;
final TrackSelection selection;
final Player player;
@@ -140,18 +141,42 @@ class _AudioColumn extends StatelessWidget {
required this.showHeader,
});
@override
State<_AudioColumn> createState() => _AudioColumnState();
}
class _AudioColumnState extends State<_AudioColumn> {
final _firstItemKey = GlobalKey();
final _scrollController = ScrollController();
bool _didInitialScroll = false;
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final selectedId = selection.audio?.id ?? '';
final selectedId = widget.selection.audio?.id ?? '';
if (!_didInitialScroll) {
final selectedIndex = widget.tracks.indexWhere((t) => t.id == selectedId);
if (selectedIndex > 0) {
_didInitialScroll = true;
scrollToCurrentItem(_scrollController, _firstItemKey, selectedIndex);
}
}
return Column(
children: [
if (showHeader) _ColumnHeader(label: t.videoControls.audioLabel),
if (widget.showHeader) _ColumnHeader(label: t.videoControls.audioLabel),
Expanded(
child: ListView.builder(
itemCount: tracks.length,
controller: _scrollController,
itemCount: widget.tracks.length,
itemBuilder: (context, index) {
final track = tracks[index];
final track = widget.tracks[index];
final label = TrackLabelBuilder.buildAudioLabel(
title: track.title,
language: track.language,
@@ -161,11 +186,12 @@ class _AudioColumn extends StatelessWidget {
);
return TrackSelectionHelper.buildTrackTile<AudioTrack>(
context: context,
key: index == 0 ? _firstItemKey : null,
label: label,
isSelected: track.id == selectedId,
onTap: () {
player.selectAudioTrack(track);
onTrackChanged?.call(track);
widget.player.selectAudioTrack(track);
widget.onTrackChanged?.call(track);
OverlaySheetController.of(context).close();
},
);
@@ -177,7 +203,7 @@ class _AudioColumn extends StatelessWidget {
}
}
class _SubtitleColumn extends StatelessWidget {
class _SubtitleColumn extends StatefulWidget {
final List<SubtitleTrack> tracks;
final TrackSelection selection;
final Player player;
@@ -196,54 +222,80 @@ class _SubtitleColumn extends StatelessWidget {
required this.showHeader,
});
@override
State<_SubtitleColumn> createState() => _SubtitleColumnState();
}
class _SubtitleColumnState extends State<_SubtitleColumn> {
final _firstItemKey = GlobalKey();
final _scrollController = ScrollController();
bool _didInitialScroll = false;
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final selectedSub = selection.subtitle;
final secondarySub = selection.secondarySubtitle;
final selectedSub = widget.selection.subtitle;
final secondarySub = widget.selection.secondarySubtitle;
final isOffSelected = selectedSub == null || selectedSub.id == 'no';
final hasSecondary = supportsSecondary && secondarySub != null;
final hasSecondary = widget.supportsSecondary && secondarySub != null;
// +1 for "Off" row
final itemCount = tracks.length + 1;
final itemCount = widget.tracks.length + 1;
if (!_didInitialScroll && !isOffSelected) {
// +1 because index 0 is the "Off" row
final selectedIndex = widget.tracks.indexWhere((t) => t.id == selectedSub.id) + 1;
if (selectedIndex > 0) {
_didInitialScroll = true;
scrollToCurrentItem(_scrollController, _firstItemKey, selectedIndex);
}
}
return Column(
children: [
if (showHeader) _ColumnHeader(label: t.videoControls.subtitlesLabel),
if (widget.showHeader) _ColumnHeader(label: t.videoControls.subtitlesLabel),
Expanded(
child: ListView.builder(
controller: _scrollController,
itemCount: itemCount,
itemBuilder: (context, index) {
// "Off" row
if (index == 0) {
return TrackSelectionHelper.buildOffTile<SubtitleTrack>(
context: context,
key: _firstItemKey,
isSelected: isOffSelected,
onTap: () {
// Turning off primary also clears secondary
if (hasSecondary) {
player.selectSecondarySubtitleTrack(SubtitleTrack.off);
onSecondaryTrackChanged?.call(SubtitleTrack.off);
widget.player.selectSecondarySubtitleTrack(SubtitleTrack.off);
widget.onSecondaryTrackChanged?.call(SubtitleTrack.off);
}
player.selectSubtitleTrack(SubtitleTrack.off);
onTrackChanged?.call(SubtitleTrack.off);
widget.player.selectSubtitleTrack(SubtitleTrack.off);
widget.onTrackChanged?.call(SubtitleTrack.off);
OverlaySheetController.of(context).close();
},
onLongPress: supportsSecondary && hasSecondary
onLongPress: widget.supportsSecondary && hasSecondary
? () {
player.selectSecondarySubtitleTrack(SubtitleTrack.off);
onSecondaryTrackChanged?.call(SubtitleTrack.off);
widget.player.selectSecondarySubtitleTrack(SubtitleTrack.off);
widget.onSecondaryTrackChanged?.call(SubtitleTrack.off);
}
: null,
onSecondaryTap: supportsSecondary && hasSecondary
onSecondaryTap: widget.supportsSecondary && hasSecondary
? () {
player.selectSecondarySubtitleTrack(SubtitleTrack.off);
onSecondaryTrackChanged?.call(SubtitleTrack.off);
widget.player.selectSecondarySubtitleTrack(SubtitleTrack.off);
widget.onSecondaryTrackChanged?.call(SubtitleTrack.off);
}
: null,
);
}
final track = tracks[index - 1];
final track = widget.tracks[index - 1];
final isPrimary = !isOffSelected && track.id == selectedSub.id;
final isSecondary = hasSecondary && track.id == secondarySub.id;
final label = TrackLabelBuilder.buildSubtitleLabel(
@@ -255,7 +307,7 @@ class _SubtitleColumn extends StatelessWidget {
// Determine badge
Widget? badge;
if (supportsSecondary && hasSecondary) {
if (widget.supportsSecondary && hasSecondary) {
if (isPrimary) {
badge = TrackSelectionHelper.buildTrackBadge(context, 1);
} else if (isSecondary) {
@@ -271,34 +323,34 @@ class _SubtitleColumn extends StatelessWidget {
onTap: () {
// If tapping a track that is currently the secondary, clear secondary first
if (isSecondary) {
player.selectSecondarySubtitleTrack(SubtitleTrack.off);
onSecondaryTrackChanged?.call(SubtitleTrack.off);
widget.player.selectSecondarySubtitleTrack(SubtitleTrack.off);
widget.onSecondaryTrackChanged?.call(SubtitleTrack.off);
}
player.selectSubtitleTrack(track);
onTrackChanged?.call(track);
widget.player.selectSubtitleTrack(track);
widget.onTrackChanged?.call(track);
OverlaySheetController.of(context).close();
},
onLongPress: supportsSecondary
onLongPress: widget.supportsSecondary
? () {
if (isSecondary) {
// Already secondary — clear it
player.selectSecondarySubtitleTrack(SubtitleTrack.off);
onSecondaryTrackChanged?.call(SubtitleTrack.off);
widget.player.selectSecondarySubtitleTrack(SubtitleTrack.off);
widget.onSecondaryTrackChanged?.call(SubtitleTrack.off);
} else if (!isPrimary) {
// Set as secondary (don't close sheet so user sees badge update)
player.selectSecondarySubtitleTrack(track);
onSecondaryTrackChanged?.call(track);
widget.player.selectSecondarySubtitleTrack(track);
widget.onSecondaryTrackChanged?.call(track);
}
}
: null,
onSecondaryTap: supportsSecondary
onSecondaryTap: widget.supportsSecondary
? () {
if (isSecondary) {
player.selectSecondarySubtitleTrack(SubtitleTrack.off);
onSecondaryTrackChanged?.call(SubtitleTrack.off);
widget.player.selectSecondarySubtitleTrack(SubtitleTrack.off);
widget.onSecondaryTrackChanged?.call(SubtitleTrack.off);
} else if (!isPrimary) {
player.selectSecondarySubtitleTrack(track);
onSecondaryTrackChanged?.call(track);
widget.player.selectSecondarySubtitleTrack(track);
widget.onSecondaryTrackChanged?.call(track);
}
}
: null,