From 0539c1d869e7dfed32a8cb615243a58bef37eba0 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sat, 21 Mar 2026 11:49:08 +0100 Subject: [PATCH] fix: overlay sheet position, scroll-to-selected in bottom sheets --- .../libraries/filters_bottom_sheet.dart | 15 ++ lib/screens/libraries/sort_bottom_sheet.dart | 15 ++ lib/utils/scroll_utils.dart | 16 +++ lib/widgets/overlay_sheet.dart | 22 +-- .../helpers/track_selection_helper.dart | 6 + .../sheets/base_video_control_sheet.dart | 2 +- .../video_controls/sheets/chapter_sheet.dart | 18 +++ .../video_controls/sheets/queue_sheet.dart | 37 +++-- .../video_controls/sheets/track_sheet.dart | 130 ++++++++++++------ 9 files changed, 198 insertions(+), 63 deletions(-) diff --git a/lib/screens/libraries/filters_bottom_sheet.dart b/lib/screens/libraries/filters_bottom_sheet.dart index 822c3abb..c373ca2b 100644 --- a/lib/screens/libraries/filters_bottom_sheet.dart +++ b/lib/screens/libraries/filters_bottom_sheet.dart @@ -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 { static const int _maxCachedDisplayNames = 1000; late List _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 { @override void dispose() { + _valuesScrollController.dispose(); _initialFocusNode.dispose(); super.dispose(); } @@ -83,6 +87,15 @@ class _FiltersBottomSheetState extends State { _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 { 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, diff --git a/lib/screens/libraries/sort_bottom_sheet.dart b/lib/screens/libraries/sort_bottom_sheet.dart index 8905713c..05ead5f7 100644 --- a/lib/screens/libraries/sort_bottom_sheet.dart +++ b/lib/screens/libraries/sort_bottom_sheet.dart @@ -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 { 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 { _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 { @override void dispose() { + _scrollController.dispose(); _initialFocusNode.dispose(); super.dispose(); } @@ -102,6 +115,7 @@ class _SortBottomSheetState extends State { 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 { final isSelected = _currentSort?.key == sort.key; return Focus( + key: index == 0 ? _firstItemKey : null, canRequestFocus: false, skipTraversal: true, onKeyEvent: (node, event) { diff --git a/lib/utils/scroll_utils.dart b/lib/utils/scroll_utils.dart index 467ffac1..968d29b7 100644 --- a/lib/utils/scroll_utils.dart +++ b/lib/utils/scroll_utils.dart @@ -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, diff --git a/lib/widgets/overlay_sheet.dart b/lib/widgets/overlay_sheet.dart index ebafb086..0116337e 100644 --- a/lib/widgets/overlay_sheet.dart +++ b/lib/widgets/overlay_sheet.dart @@ -582,17 +582,17 @@ class _OverlaySheetHostState extends State 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( diff --git a/lib/widgets/video_controls/helpers/track_selection_helper.dart b/lib/widgets/video_controls/helpers/track_selection_helper.dart index 8d258719..5af59ad9 100644 --- a/lib/widgets/video_controls/helpers/track_selection_helper.dart +++ b/lib/widgets/video_controls/helpers/track_selection_helper.dart @@ -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, diff --git a/lib/widgets/video_controls/sheets/base_video_control_sheet.dart b/lib/widgets/video_controls/sheets/base_video_control_sheet.dart index adf14995..c579847d 100644 --- a/lib/widgets/video_controls/sheets/base_video_control_sheet.dart +++ b/lib/widgets/video_controls/sheets/base_video_control_sheet.dart @@ -47,6 +47,6 @@ class BaseVideoControlSheet extends StatelessWidget { ); } - return SizedBox(height: MediaQuery.of(context).size.height * 0.75, child: content); + return content; } } diff --git a/lib/widgets/video_controls/sheets/chapter_sheet.dart b/lib/widgets/video_controls/sheets/chapter_sheet.dart index 5f35f2b9..8eb52abf 100644 --- a/lib/widgets/video_controls/sheets/chapter_sheet.dart +++ b/lib/widgets/video_controls/sheets/chapter_sheet.dart @@ -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 { + final _firstItemKey = GlobalKey(); + final _scrollController = ScrollController(); + bool _didInitialScroll = false; + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + Future _handleChapterTap(Duration position) async { final clamped = clampSeekPosition(widget.player, position); await widget.player.seek(clamped); @@ -86,7 +97,13 @@ class _ChapterSheetState extends State { 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 { : null; return FocusableListTile( + key: index == 0 ? _firstItemKey : null, leading: chapter.thumb != null ? SizedBox( width: 60, diff --git a/lib/widgets/video_controls/sheets/queue_sheet.dart b/lib/widgets/video_controls/sheets/queue_sheet.dart index 67e4176f..df019a06 100644 --- a/lib/widgets/video_controls/sheets/queue_sheet.dart +++ b/lib/widgets/video_controls/sheets/queue_sheet.dart @@ -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 createState() => _QueueSheetState(); +} + +class _QueueSheetState extends State { + final _firstItemKey = GlobalKey(); + final _scrollController = ScrollController(); + bool _didInitialScroll = false; + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { return Consumer( @@ -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); - } } diff --git a/lib/widgets/video_controls/sheets/track_sheet.dart b/lib/widgets/video_controls/sheets/track_sheet.dart index 2e83b19b..4e4f45e0 100644 --- a/lib/widgets/video_controls/sheets/track_sheet.dart +++ b/lib/widgets/video_controls/sheets/track_sheet.dart @@ -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 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( 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 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( 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,