From 5c225bb89e64ed4da179986a494959d1280e9556 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Thu, 5 Feb 2026 10:15:59 +0100 Subject: [PATCH] feat: alpha jump bar close #349 --- lib/models/plex_first_character.dart | 11 + lib/models/plex_metadata.dart | 4 + lib/models/plex_metadata.g.dart | 2 + lib/screens/libraries/libraries_screen.dart | 241 +++++++++--------- .../libraries/tabs/library_browse_tab.dart | 211 ++++++++++++++- lib/services/plex_client.dart | 15 ++ lib/widgets/alpha_jump_bar.dart | 192 ++++++++++++++ lib/widgets/alpha_jump_helper.dart | 108 ++++++++ lib/widgets/alpha_scroll_handle.dart | 228 +++++++++++++++++ lib/widgets/collapsible_text.dart | 10 +- lib/widgets/focusable_media_card.dart | 6 + 11 files changed, 896 insertions(+), 132 deletions(-) create mode 100644 lib/models/plex_first_character.dart create mode 100644 lib/widgets/alpha_jump_bar.dart create mode 100644 lib/widgets/alpha_jump_helper.dart create mode 100644 lib/widgets/alpha_scroll_handle.dart diff --git a/lib/models/plex_first_character.dart b/lib/models/plex_first_character.dart new file mode 100644 index 00000000..2a23e287 --- /dev/null +++ b/lib/models/plex_first_character.dart @@ -0,0 +1,11 @@ +class PlexFirstCharacter { + final String key; + final String title; + final int size; + + PlexFirstCharacter({required this.key, required this.title, required this.size}); + + factory PlexFirstCharacter.fromJson(Map json) { + return PlexFirstCharacter(key: json['key'] ?? '', title: json['title'] ?? '', size: json['size'] ?? 0); + } +} diff --git a/lib/models/plex_metadata.dart b/lib/models/plex_metadata.dart index 492e1b16..c2365ac3 100644 --- a/lib/models/plex_metadata.dart +++ b/lib/models/plex_metadata.dart @@ -42,6 +42,7 @@ class PlexMetadata with MultiServerFields { final String? studio; final String type; final String title; + final String? titleSort; final String? contentRating; final String? summary; final double? rating; @@ -116,6 +117,7 @@ class PlexMetadata with MultiServerFields { this.studio, required this.type, required this.title, + this.titleSort, this.contentRating, this.summary, this.rating, @@ -162,6 +164,7 @@ class PlexMetadata with MultiServerFields { String? studio, String? type, String? title, + String? titleSort, String? contentRating, String? summary, double? rating, @@ -206,6 +209,7 @@ class PlexMetadata with MultiServerFields { studio: studio ?? this.studio, type: type ?? this.type, title: title ?? this.title, + titleSort: titleSort ?? this.titleSort, contentRating: contentRating ?? this.contentRating, summary: summary ?? this.summary, rating: rating ?? this.rating, diff --git a/lib/models/plex_metadata.g.dart b/lib/models/plex_metadata.g.dart index 19372516..e6f54d75 100644 --- a/lib/models/plex_metadata.g.dart +++ b/lib/models/plex_metadata.g.dart @@ -13,6 +13,7 @@ PlexMetadata _$PlexMetadataFromJson(Map json) => PlexMetadata( studio: json['studio'] as String?, type: json['type'] as String, title: json['title'] as String, + titleSort: json['titleSort'] as String?, contentRating: json['contentRating'] as String?, summary: json['summary'] as String?, rating: (json['rating'] as num?)?.toDouble(), @@ -56,6 +57,7 @@ Map _$PlexMetadataToJson(PlexMetadata instance) => final visibleLibraries = allLibraries.where((lib) => !hiddenKeys.contains(lib.globalKey)).toList(); return Scaffold( - body: 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) + 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: BorderRadius.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: BorderRadius.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: BorderRadius.circular(20), - ), - child: IconButton( - icon: const AppIcon(Symbols.refresh_rounded, fill: 1), - tooltip: t.common.refresh, - onPressed: _refreshCurrentTab, - ), - ), - ), - ], - ), - if (isLoadingLibraries) - const SliverFillRemaining(child: Center(child: CircularProgressIndicator())) - else if (_errorMessage != null && visibleLibraries.isEmpty) - SliverFillRemaining( - child: ErrorStateWidget( - message: _errorMessage!, - icon: Symbols.error_outline_rounded, - onRetry: () { - final librariesProvider = context.read(); - librariesProvider.refresh(); - }, - ), - ) - else if (visibleLibraries.isEmpty) - SliverFillRemaining( - child: EmptyStateWidget(message: t.libraries.noLibrariesFound, icon: Symbols.video_library_rounded), - ) - else ...[ - // Tab selector chips (only on mobile - desktop has them in app bar) - if (_selectedLibraryGlobalKey != null && !PlatformDetector.shouldUseSideNavigation(context)) - SliverToBoxAdapter( - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: [ - _buildTabChip(t.libraries.tabs.recommended, 0), - const SizedBox(width: 8), - _buildTabChip(t.libraries.tabs.browse, 1), - const SizedBox(width: 8), - _buildTabChip(t.libraries.tabs.collections, 2), - const SizedBox(width: 8), - _buildTabChip(t.libraries.tabs.playlists, 3), - ], - ), - ), - ), - ), - - // Tab content - if (_selectedLibraryGlobalKey != null) + ], + ), + if (isLoadingLibraries) + const SliverFillRemaining(child: Center(child: CircularProgressIndicator())) + else if (_errorMessage != null && visibleLibraries.isEmpty) SliverFillRemaining( - child: TabBarView( - key: ValueKey(_selectedLibraryGlobalKey), - controller: tabController, - // Disable swipe on desktop - trackpad scrolling triggers accidental tab switches - // See: https://github.com/flutter/flutter/issues/11132 - physics: PlatformDetector.isDesktop(context) ? const NeverScrollableScrollPhysics() : null, - children: [ - LibraryRecommendedTab( - key: _recommendedTabKey, - library: allLibraries.firstWhere((lib) => lib.globalKey == _selectedLibraryGlobalKey), - isActive: tabController.index == 0, - suppressAutoFocus: suppressAutoFocus, - onDataLoaded: () => _handleTabDataLoaded(0), - onBack: focusTabBar, - ), - LibraryBrowseTab( - key: _browseTabKey, - library: allLibraries.firstWhere((lib) => lib.globalKey == _selectedLibraryGlobalKey), - isActive: tabController.index == 1, - suppressAutoFocus: suppressAutoFocus, - onDataLoaded: () => _handleTabDataLoaded(1), - onBack: focusTabBar, - ), - LibraryCollectionsTab( - key: _collectionsTabKey, - library: allLibraries.firstWhere((lib) => lib.globalKey == _selectedLibraryGlobalKey), - isActive: tabController.index == 2, - suppressAutoFocus: suppressAutoFocus, - onDataLoaded: () => _handleTabDataLoaded(2), - onBack: focusTabBar, - ), - LibraryPlaylistsTab( - key: _playlistsTabKey, - library: allLibraries.firstWhere((lib) => lib.globalKey == _selectedLibraryGlobalKey), - isActive: tabController.index == 3, - suppressAutoFocus: suppressAutoFocus, - onDataLoaded: () => _handleTabDataLoaded(3), - onBack: focusTabBar, - ), - ], + child: ErrorStateWidget( + message: _errorMessage!, + icon: Symbols.error_outline_rounded, + onRetry: () { + final librariesProvider = context.read(); + librariesProvider.refresh(); + }, ), - ), + ) + else if (visibleLibraries.isEmpty) + SliverFillRemaining( + child: EmptyStateWidget(message: t.libraries.noLibrariesFound, icon: Symbols.video_library_rounded), + ) + else ...[ + // Tab selector chips (only on mobile - desktop has them in app bar) + if (_selectedLibraryGlobalKey != null && !PlatformDetector.shouldUseSideNavigation(context)) + SliverToBoxAdapter( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + _buildTabChip(t.libraries.tabs.recommended, 0), + const SizedBox(width: 8), + _buildTabChip(t.libraries.tabs.browse, 1), + const SizedBox(width: 8), + _buildTabChip(t.libraries.tabs.collections, 2), + const SizedBox(width: 8), + _buildTabChip(t.libraries.tabs.playlists, 3), + ], + ), + ), + ), + ), + + // Tab content + if (_selectedLibraryGlobalKey != null) + SliverFillRemaining( + child: TabBarView( + key: ValueKey(_selectedLibraryGlobalKey), + controller: tabController, + // Disable swipe on desktop - trackpad scrolling triggers accidental tab switches + // See: https://github.com/flutter/flutter/issues/11132 + physics: PlatformDetector.isDesktop(context) ? const NeverScrollableScrollPhysics() : null, + children: [ + LibraryRecommendedTab( + key: _recommendedTabKey, + library: allLibraries.firstWhere((lib) => lib.globalKey == _selectedLibraryGlobalKey), + isActive: tabController.index == 0, + suppressAutoFocus: suppressAutoFocus, + onDataLoaded: () => _handleTabDataLoaded(0), + onBack: focusTabBar, + ), + LibraryBrowseTab( + key: _browseTabKey, + library: allLibraries.firstWhere((lib) => lib.globalKey == _selectedLibraryGlobalKey), + isActive: tabController.index == 1, + suppressAutoFocus: suppressAutoFocus, + onDataLoaded: () => _handleTabDataLoaded(1), + onBack: focusTabBar, + ), + LibraryCollectionsTab( + key: _collectionsTabKey, + library: allLibraries.firstWhere((lib) => lib.globalKey == _selectedLibraryGlobalKey), + isActive: tabController.index == 2, + suppressAutoFocus: suppressAutoFocus, + onDataLoaded: () => _handleTabDataLoaded(2), + onBack: focusTabBar, + ), + LibraryPlaylistsTab( + key: _playlistsTabKey, + library: allLibraries.firstWhere((lib) => lib.globalKey == _selectedLibraryGlobalKey), + isActive: tabController.index == 3, + suppressAutoFocus: suppressAutoFocus, + onDataLoaded: () => _handleTabDataLoaded(3), + onBack: focusTabBar, + ), + ], + ), + ), + ], ], - ], + ), ), ); } diff --git a/lib/screens/libraries/tabs/library_browse_tab.dart b/lib/screens/libraries/tabs/library_browse_tab.dart index 8ededceb..feba390a 100644 --- a/lib/screens/libraries/tabs/library_browse_tab.dart +++ b/lib/screens/libraries/tabs/library_browse_tab.dart @@ -6,10 +6,13 @@ import '../../../focus/dpad_navigator.dart'; import '../../../../services/plex_client.dart'; import '../../../models/plex_metadata.dart'; import '../../../models/plex_filter.dart'; +import '../../../models/plex_first_character.dart'; import '../../../models/plex_sort.dart'; import '../../../providers/settings_provider.dart'; import '../../../utils/error_message_utils.dart'; import '../../../utils/grid_size_calculator.dart'; +import '../../../utils/layout_constants.dart'; +import '../../../widgets/alpha_jump_bar.dart'; import '../../../widgets/focusable_media_card.dart'; import '../../../widgets/focusable_filter_chip.dart'; import '../../../widgets/media_grid_delegate.dart'; @@ -73,6 +76,21 @@ class _LibraryBrowseTabState extends BaseLibraryTabState _firstCharacters = []; + int _currentFirstVisibleIndex = 0; + int _currentColumnCount = 1; + double _lastCrossAxisExtent = 0; + final FocusNode _alphaJumpBarFocusNode = FocusNode(debugLabel: 'alpha_jump_bar'); + // When the user taps a letter, pin that index so scroll-based recalculation + // doesn't immediately override it (e.g. when the letter has fewer items than a full row). + int? _pinnedJumpIndex; + // True while a jump-triggered animateTo is in progress — suppresses all + // scroll-based letter recalculation to prevent flashing. + bool _isJumpScrolling = false; + // Incremented on each jump so that overlapping animations don't clobber each other. + int _jumpScrollGeneration = 0; + // Pagination state int _currentPage = 0; bool _hasMoreItems = true; @@ -88,13 +106,21 @@ class _LibraryBrowseTabState extends BaseLibraryTabState _loadFirstCharacters() async { + final client = getClientForLibrary(); + final filterParams = Map.from(_selectedFilters); + final typeId = _getGroupingTypeId(); + + try { + final chars = await client.getFirstCharacters( + widget.library.key, + type: typeId.isNotEmpty ? int.tryParse(typeId) : null, + filters: filterParams.isNotEmpty ? filterParams : null, + ); + if (mounted) { + setState(() => _firstCharacters = chars); + } + } catch (_) { + // Non-critical — hide the bar on failure + if (mounted) { + setState(() => _firstCharacters = []); + } + } + } + + /// Track scroll position to highlight the current letter in the jump bar + void _onScrollChanged() { + if (!_shouldShowAlphaJumpBar || _currentColumnCount < 1) return; + + // During a jump animation, skip all processing to avoid flashing. + if (_isJumpScrolling) return; + + // If pinned from a completed jump, the next scroll event must be + // user-initiated (touch drag, mouse wheel, etc.) — clear the pin + // and resume normal tracking. + if (_pinnedJumpIndex != null) { + _pinnedJumpIndex = null; + } + + _updateVisibleIndex(); + } + + /// Recompute the first-visible-index from the current scroll offset. + void _updateVisibleIndex() { + final offset = _scrollController.offset; + final firstInRow = _itemIndexFromScrollOffset(offset); + // Use the last item in the first visible row so the highlighted letter + // updates as soon as items with a new letter appear in that row. + final lastInRow = (firstInRow + _currentColumnCount - 1).clamp(0, items.length - 1); + if (lastInRow != _currentFirstVisibleIndex) { + setState(() => _currentFirstVisibleIndex = lastInRow); + } + } + + /// Compute the first visible item index from a scroll offset. + /// The visible area starts below the chips bar, so we offset accordingly. + int _itemIndexFromScrollOffset(double offset) { + if (_lastCrossAxisExtent <= 0 || _currentColumnCount < 1) return 0; + + final itemWidth = _lastCrossAxisExtent / _currentColumnCount; + final itemHeight = itemWidth / GridLayoutConstants.posterAspectRatio; + final rowHeight = itemHeight + GridLayoutConstants.mainAxisSpacing; + if (rowHeight <= 0) return 0; + + // The visible area starts at _chipsBarHeight from the viewport top. + // Grid content starts at _gridTopPadding in scroll coordinates. + // First visible row = (offset + chipsBarHeight - gridTopPadding) / rowHeight + final contentOffset = (offset + _chipsBarHeight - _gridTopPadding).clamp(0.0, double.infinity); + final row = (contentOffset / rowHeight).floor(); + return (row * _currentColumnCount).clamp(0, items.length - 1); + } + + /// Scroll to the item at [targetIndex], loading more pages if necessary. + /// Pins the index so scroll events during the animation don't override + /// the highlighted letter. The pin is cleared on the next user scroll. + void _jumpToIndex(int targetIndex) { + _jumpScrollGeneration++; + _isJumpScrolling = true; + _pinnedJumpIndex = targetIndex; + setState(() => _currentFirstVisibleIndex = targetIndex); + + if (targetIndex < items.length) { + _scrollToItemIndex(targetIndex); + } else { + _loadUntilIndex(targetIndex); + } + } + + /// Scroll the grid so that [index] is visible just below the chips bar + void _scrollToItemIndex(int index) { + if (_currentColumnCount < 1 || _lastCrossAxisExtent <= 0 || !_scrollController.hasClients) { + _isJumpScrolling = false; + return; + } + + final itemWidth = _lastCrossAxisExtent / _currentColumnCount; + final itemHeight = itemWidth / GridLayoutConstants.posterAspectRatio; + final rowHeight = itemHeight + GridLayoutConstants.mainAxisSpacing; + final targetRow = index ~/ _currentColumnCount; + // Position the target row right below the chips bar + final offset = _gridTopPadding + targetRow * rowHeight - _chipsBarHeight; + + final gen = _jumpScrollGeneration; + _scrollController + .animateTo( + offset.clamp(0.0, _scrollController.position.maxScrollExtent), + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ) + .then((_) { + // Only clear the flag if no newer jump has started. + if (mounted && gen == _jumpScrollGeneration) { + _isJumpScrolling = false; + } + }); + } + + /// Load pages until [targetIndex] is loaded, then scroll to it + Future _loadUntilIndex(int targetIndex) async { + while (items.length <= targetIndex && _hasMoreItems) { + await _loadItems(loadMore: true); + } + if (mounted) { + _scrollToItemIndex(targetIndex.clamp(0, items.length - 1)); + } + } + @override Widget build(BuildContext context) { super.build(context); // Required for AutomaticKeepAliveClientMixin @@ -548,6 +720,21 @@ class _LibraryBrowseTabState extends BaseLibraryTabState _buildMediaCardItem( @@ -701,10 +892,13 @@ class _LibraryBrowseTabState extends BaseLibraryTabState= items.length) { return const Padding( padding: EdgeInsets.all(16.0), @@ -744,6 +944,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState trackGridItemFocus(index, hasFocus), onListRefresh: _loadItems, diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index 72e2281b..a307fdbf 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -6,6 +6,7 @@ import '../models/plex_config.dart'; import '../models/play_queue_response.dart'; import '../models/plex_file_info.dart'; import '../models/plex_filter.dart'; +import '../models/plex_first_character.dart'; import '../models/plex_hub.dart'; import '../models/plex_library.dart'; import '../models/plex_media_info.dart'; @@ -1124,6 +1125,20 @@ class PlexClient { return _extractDirectoryList(response, PlexFilter.fromJson); } + /// Get first characters (alphabet index) for a library section + Future> getFirstCharacters( + String sectionId, { + int? type, + Map? filters, + }) async { + final queryParams = {}; + if (type != null) queryParams['type'] = type; + if (filters != null) queryParams.addAll(filters); + + final response = await _dio.get('/library/sections/$sectionId/firstCharacter', queryParameters: queryParams); + return _extractDirectoryList(response, PlexFirstCharacter.fromJson); + } + /// Get filter values (e.g., list of genres, years, etc.) Future> getFilterValues(String filterKey) async { final response = await _dio.get(filterKey); diff --git a/lib/widgets/alpha_jump_bar.dart b/lib/widgets/alpha_jump_bar.dart new file mode 100644 index 00000000..bd353509 --- /dev/null +++ b/lib/widgets/alpha_jump_bar.dart @@ -0,0 +1,192 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../models/plex_first_character.dart'; +import 'alpha_jump_helper.dart'; + +/// Vertical strip of letters (#, A–Z) for jumping through sorted library items. +/// +/// Pre-computes a cumulative index map from [firstCharacters] data so that +/// tapping a letter triggers [onJump] with the item index where that letter +/// begins. Supports both touch (tap/drag) and D-pad (up/down/select) input. +class AlphaJumpBar extends StatefulWidget { + final List firstCharacters; + final void Function(int targetIndex) onJump; + final int currentFirstVisibleIndex; + final FocusNode? focusNode; + final VoidCallback? onNavigateLeft; + final VoidCallback? onBack; + + const AlphaJumpBar({ + super.key, + required this.firstCharacters, + required this.onJump, + required this.currentFirstVisibleIndex, + this.focusNode, + this.onNavigateLeft, + this.onBack, + }); + + @override + State createState() => _AlphaJumpBarState(); +} + +class _AlphaJumpBarState extends State { + late AlphaJumpHelper _helper; + + /// Currently highlighted letter index (for D-pad navigation). + int _highlightedIndex = 0; + + /// Whether this bar currently has focus (for D-pad mode). + bool _hasFocus = false; + + @override + void initState() { + super.initState(); + _helper = AlphaJumpHelper(widget.firstCharacters); + } + + @override + void didUpdateWidget(AlphaJumpBar oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.firstCharacters != widget.firstCharacters) { + _helper = AlphaJumpHelper(widget.firstCharacters); + } + } + + void _jumpToLetter(String letter) { + final index = _helper.indexForLetter(letter); + if (index != null) { + widget.onJump(index); + } + } + + /// Resolves a vertical drag position to a letter index. + int _letterIndexFromDy(double dy, double totalHeight) { + final index = (dy / totalHeight * AlphaJumpHelper.allLetters.length).floor(); + return index.clamp(0, AlphaJumpHelper.allLetters.length - 1); + } + + KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { + if (event is! KeyDownEvent && event is! KeyRepeatEvent) { + return KeyEventResult.ignored; + } + + if (event.logicalKey == LogicalKeyboardKey.arrowUp) { + if (_highlightedIndex > 0) { + setState(() => _highlightedIndex--); + } + return KeyEventResult.handled; + } + if (event.logicalKey == LogicalKeyboardKey.arrowDown) { + if (_highlightedIndex < AlphaJumpHelper.allLetters.length - 1) { + setState(() => _highlightedIndex++); + } + return KeyEventResult.handled; + } + if (event.logicalKey == LogicalKeyboardKey.select || + event.logicalKey == LogicalKeyboardKey.enter || + event.logicalKey == LogicalKeyboardKey.gameButtonA) { + _jumpToLetter(AlphaJumpHelper.allLetters[_highlightedIndex]); + return KeyEventResult.handled; + } + if (event.logicalKey == LogicalKeyboardKey.arrowLeft) { + widget.onNavigateLeft?.call(); + return KeyEventResult.handled; + } + if (event.logicalKey == LogicalKeyboardKey.escape || + event.logicalKey == LogicalKeyboardKey.goBack || + event.logicalKey == LogicalKeyboardKey.gameButtonB) { + widget.onBack?.call(); + return KeyEventResult.handled; + } + + return KeyEventResult.ignored; + } + + @override + Widget build(BuildContext context) { + final currentLetter = _helper.currentLetter(widget.currentFirstVisibleIndex); + final colorScheme = Theme.of(context).colorScheme; + + return Focus( + focusNode: widget.focusNode, + onKeyEvent: _handleKeyEvent, + onFocusChange: (hasFocus) { + setState(() { + _hasFocus = hasFocus; + if (hasFocus) { + // Start highlight at the current letter when gaining focus + final idx = AlphaJumpHelper.allLetters.indexOf(currentLetter); + if (idx >= 0) _highlightedIndex = idx; + } + }); + }, + child: LayoutBuilder( + builder: (context, constraints) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTapDown: (details) { + final idx = _letterIndexFromDy(details.localPosition.dy, constraints.maxHeight); + setState(() => _highlightedIndex = idx); + _jumpToLetter(AlphaJumpHelper.allLetters[idx]); + }, + onVerticalDragUpdate: (details) { + final idx = _letterIndexFromDy(details.localPosition.dy, constraints.maxHeight); + if (idx != _highlightedIndex) { + setState(() => _highlightedIndex = idx); + _jumpToLetter(AlphaJumpHelper.allLetters[idx]); + } + }, + child: Container( + width: 28, + decoration: BoxDecoration( + color: colorScheme.surface.withValues(alpha: 0.7), + borderRadius: BorderRadius.circular(14), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: List.generate(AlphaJumpHelper.allLetters.length, (i) { + final letter = AlphaJumpHelper.allLetters[i]; + final isActive = _helper.activeLetters.contains(letter); + final isCurrent = letter == currentLetter && !_hasFocus; + final isHighlighted = _hasFocus && i == _highlightedIndex; + + return SizedBox( + height: constraints.maxHeight / AlphaJumpHelper.allLetters.length, + child: Center( + child: Container( + width: 22, + height: 22, + decoration: isHighlighted + ? BoxDecoration(color: colorScheme.primary, shape: BoxShape.circle) + : isCurrent + ? BoxDecoration(color: colorScheme.primary.withValues(alpha: 0.3), shape: BoxShape.circle) + : null, + alignment: Alignment.center, + child: Text( + letter, + style: TextStyle( + fontSize: 10, + fontWeight: (isCurrent || isHighlighted) ? FontWeight.bold : FontWeight.normal, + color: isHighlighted + ? colorScheme.onPrimary + : isCurrent + ? colorScheme.primary + : isActive + ? colorScheme.onSurface + : colorScheme.onSurface.withValues(alpha: 0.25), + ), + ), + ), + ), + ); + }), + ), + ), + ); + }, + ), + ); + } +} diff --git a/lib/widgets/alpha_jump_helper.dart b/lib/widgets/alpha_jump_helper.dart new file mode 100644 index 00000000..53debda6 --- /dev/null +++ b/lib/widgets/alpha_jump_helper.dart @@ -0,0 +1,108 @@ +import '../models/plex_first_character.dart'; + +/// Shared letter-index mapping logic used by both [AlphaJumpBar] (desktop/tablet/TV) +/// and [AlphaScrollHandle] (phone). +/// +/// Builds a cumulative index map from [PlexFirstCharacter] data and provides +/// fraction-based mapping for proportional scroll handle positioning. +class AlphaJumpHelper { + static const List allLetters = [ + '#', + 'A', + 'B', + 'C', + 'D', + 'E', + 'F', + 'G', + 'H', + 'I', + 'J', + 'K', + 'L', + 'M', + 'N', + 'O', + 'P', + 'Q', + 'R', + 'S', + 'T', + 'U', + 'V', + 'W', + 'X', + 'Y', + 'Z', + ]; + + /// Maps each letter to its cumulative start index in the full item list. + final Map letterToIndex; + + /// Letters that have at least one item in the data. + final Set activeLetters; + + /// Total number of items across all letters. + final int totalItemCount; + + AlphaJumpHelper._(this.letterToIndex, this.activeLetters, this.totalItemCount); + + factory AlphaJumpHelper(List firstCharacters) { + // Build a lookup from the API data. Note: the firstCharacters API may + // count by display title (e.g. "The Simpsons" → T) rather than titleSort + // ("Simpsons" → S), so cumulative indices are approximate. The browse tab + // corrects for this when jumping by searching loaded items' titleSort. + final sizeByLetter = {}; + for (final fc in firstCharacters) { + sizeByLetter[fc.title.toUpperCase()] = fc.size; + } + + // Compute cumulative indices in allLetters order (#, A, B, …, Z). + final letterToIndex = {}; + final activeLetters = {}; + int cumulative = 0; + + for (final letter in allLetters) { + final size = sizeByLetter[letter]; + if (size != null && size > 0) { + activeLetters.add(letter); + letterToIndex[letter] = cumulative; + cumulative += size; + } + } + + return AlphaJumpHelper._(letterToIndex, activeLetters, cumulative); + } + + /// Returns the letter that the given item index falls within. + String currentLetter(int itemIndex) { + String current = allLetters.first; + for (final letter in allLetters) { + final startIndex = letterToIndex[letter]; + if (startIndex != null && startIndex <= itemIndex) { + current = letter; + } + } + return current; + } + + /// Returns the cumulative start index for a letter, or null if not present. + int? indexForLetter(String letter) => letterToIndex[letter]; + + /// Returns the fractional position (0.0–1.0) for a letter, proportional to + /// item count. Letters with more items occupy a larger segment. + double fractionForLetter(String letter) { + if (totalItemCount == 0) return 0.0; + final index = letterToIndex[letter]; + if (index == null) return 0.0; + return index / totalItemCount; + } + + /// Returns the letter at a given fractional position (0.0–1.0), proportional + /// to item count. + String letterAtFraction(double fraction) { + if (totalItemCount == 0) return allLetters.first; + final targetIndex = (fraction * totalItemCount).round().clamp(0, totalItemCount - 1); + return currentLetter(targetIndex); + } +} diff --git a/lib/widgets/alpha_scroll_handle.dart b/lib/widgets/alpha_scroll_handle.dart new file mode 100644 index 00000000..66ef5147 --- /dev/null +++ b/lib/widgets/alpha_scroll_handle.dart @@ -0,0 +1,228 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../models/plex_first_character.dart'; +import 'alpha_jump_helper.dart'; + +/// Phone-optimized draggable scroll handle that appears on scroll and shows +/// a letter bubble when dragged. Designed to match the Plex app's scroll +/// indicator behavior. +/// +/// Desktop/tablet/TV should use [AlphaJumpBar] instead. +class AlphaScrollHandle extends StatefulWidget { + final List firstCharacters; + final void Function(int targetIndex) onJump; + final int currentFirstVisibleIndex; + + /// Whether the parent scroll view is currently scrolling. + final bool isScrolling; + + const AlphaScrollHandle({ + super.key, + required this.firstCharacters, + required this.onJump, + required this.currentFirstVisibleIndex, + required this.isScrolling, + }); + + @override + State createState() => _AlphaScrollHandleState(); +} + +class _AlphaScrollHandleState extends State with SingleTickerProviderStateMixin { + late AlphaJumpHelper _helper; + late AnimationController _opacityController; + Timer? _hideTimer; + bool _isDragging = false; + String? _dragLetter; + + /// Accumulated fraction (0.0–1.0) during drag, driven by delta movement. + double? _dragFraction; + + /// Cached track height from the last layout pass, used in drag callbacks. + double _trackHeight = 0; + + static const _showDuration = Duration(milliseconds: 200); + static const _hideDuration = Duration(milliseconds: 200); + static const _autoHideDelay = Duration(seconds: 2); + + // Handle dimensions + static const double _handleWidth = 6.0; + static const double _handleHeight = 44.0; + static const double _touchTargetWidth = 44.0; + static const double _touchTargetVerticalPadding = 20.0; + static const double _handleRadius = 3.0; + + // Bubble dimensions + static const double _bubbleSize = 56.0; + static const double _bubbleFontSize = 24.0; + static const double _bubbleMarginRight = 8.0; + + @override + void initState() { + super.initState(); + _helper = AlphaJumpHelper(widget.firstCharacters); + _opacityController = AnimationController(vsync: this, duration: _showDuration, reverseDuration: _hideDuration); + } + + @override + void didUpdateWidget(AlphaScrollHandle oldWidget) { + super.didUpdateWidget(oldWidget); + + if (oldWidget.firstCharacters != widget.firstCharacters) { + _helper = AlphaJumpHelper(widget.firstCharacters); + } + + if (widget.isScrolling && !oldWidget.isScrolling) { + _show(); + } else if (!widget.isScrolling && oldWidget.isScrolling) { + _scheduleHide(); + } + } + + @override + void dispose() { + _hideTimer?.cancel(); + _opacityController.dispose(); + super.dispose(); + } + + void _show() { + _hideTimer?.cancel(); + _opacityController.forward(); + } + + void _scheduleHide() { + if (_isDragging) return; + _hideTimer?.cancel(); + _hideTimer = Timer(_autoHideDelay, () { + if (mounted && !_isDragging) { + _opacityController.reverse(); + } + }); + } + + void _onDragStart(DragStartDetails details) { + final currentLetter = _helper.currentLetter(widget.currentFirstVisibleIndex); + setState(() { + _isDragging = true; + _dragFraction = _helper.fractionForLetter(currentLetter); + _dragLetter = currentLetter; + }); + _hideTimer?.cancel(); + _show(); + } + + void _onDragUpdate(DragUpdateDetails details) { + final usableHeight = _trackHeight - _handleHeight; + if (usableHeight <= 0 || _dragFraction == null) return; + + final newFraction = (_dragFraction! + details.delta.dy / usableHeight).clamp(0.0, 1.0); + _dragFraction = newFraction; + + final letter = _helper.letterAtFraction(newFraction); + + setState(() => _dragLetter = letter); + widget.onJump(_helper.indexForLetter(letter) ?? 0); + } + + void _onDragEnd(DragEndDetails details) { + setState(() { + _isDragging = false; + _dragLetter = null; + _dragFraction = null; + }); + _scheduleHide(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _opacityController, + builder: (context, child) { + final opacity = _opacityController.value; + // Prevent stealing taps when fully hidden + if (opacity == 0.0) return const SizedBox.shrink(); + + return Opacity(opacity: opacity, child: child); + }, + child: LayoutBuilder( + builder: (context, constraints) { + final trackHeight = constraints.maxHeight; + _trackHeight = trackHeight; + + final currentLetter = _helper.currentLetter(widget.currentFirstVisibleIndex); + final fraction = _isDragging && _dragFraction != null + ? _dragFraction! + : _helper.fractionForLetter(currentLetter); + final usableHeight = trackHeight - _handleHeight; + final handleTop = usableHeight > 0 ? (fraction * usableHeight) : 0.0; + + final colorScheme = Theme.of(context).colorScheme; + + return SizedBox( + width: _touchTargetWidth + _bubbleSize + _bubbleMarginRight, + height: trackHeight, + child: Stack( + clipBehavior: Clip.none, + children: [ + // Handle pill with touch target — only around the pill's position, + // not the full track, so it doesn't steal scroll gestures. + // Extra vertical padding makes it easier to grab. + Positioned( + right: 0, + top: handleTop - _touchTargetVerticalPadding, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onVerticalDragStart: _onDragStart, + onVerticalDragUpdate: _onDragUpdate, + onVerticalDragEnd: _onDragEnd, + child: SizedBox( + width: _touchTargetWidth, + height: _handleHeight + _touchTargetVerticalPadding * 2, + child: Align( + alignment: Alignment.centerRight, + child: Padding( + padding: const EdgeInsets.only(right: 2), + child: Container( + width: _handleWidth, + height: _handleHeight, + decoration: BoxDecoration( + color: colorScheme.onSurface.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(_handleRadius), + ), + ), + ), + ), + ), + ), + ), + // Letter bubble (only while dragging) + if (_isDragging && _dragLetter != null) + Positioned( + right: _touchTargetWidth + _bubbleMarginRight, + top: handleTop + (_handleHeight - _bubbleSize) / 2, + child: Container( + width: _bubbleSize, + height: _bubbleSize, + decoration: BoxDecoration(color: colorScheme.primary, shape: BoxShape.circle), + alignment: Alignment.center, + child: Text( + _dragLetter!, + style: TextStyle( + color: colorScheme.onPrimary, + fontSize: _bubbleFontSize, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ], + ), + ); + }, + ), + ); + } +} diff --git a/lib/widgets/collapsible_text.dart b/lib/widgets/collapsible_text.dart index 8b6974e7..0fcf400a 100644 --- a/lib/widgets/collapsible_text.dart +++ b/lib/widgets/collapsible_text.dart @@ -36,9 +36,7 @@ class _CollapsibleTextState extends State { String displayText = widget.text; if (!_expanded) { // Find where to truncate to leave room for the badge on the last line - final cutPoint = textPainter.getPositionForOffset( - Offset(constraints.maxWidth - 54, textPainter.height - 1), - ); + final cutPoint = textPainter.getPositionForOffset(Offset(constraints.maxWidth - 54, textPainter.height - 1)); displayText = widget.text.substring(0, cutPoint.offset).trimRight(); } textPainter.dispose(); @@ -49,11 +47,7 @@ class _CollapsibleTextState extends State { TextSpan( children: [ TextSpan(text: displayText, style: style), - if (!_expanded) - WidgetSpan( - alignment: PlaceholderAlignment.middle, - child: _buildBadge(context), - ), + if (!_expanded) WidgetSpan(alignment: PlaceholderAlignment.middle, child: _buildBadge(context)), ], ), ), diff --git a/lib/widgets/focusable_media_card.dart b/lib/widgets/focusable_media_card.dart index 65443cf0..17328742 100644 --- a/lib/widgets/focusable_media_card.dart +++ b/lib/widgets/focusable_media_card.dart @@ -36,6 +36,10 @@ class FocusableMediaCard extends StatefulWidget { /// Used to navigate from the first column to the sidebar. final VoidCallback? onNavigateLeft; + /// Called when the user presses RIGHT and there's no focusable item to the right. + /// Used to navigate from the last column to the alpha jump bar. + final VoidCallback? onNavigateRight; + /// Called when the user presses BACK. /// Used to navigate from tab content to tab bar. final VoidCallback? onBack; @@ -59,6 +63,7 @@ class FocusableMediaCard extends StatefulWidget { this.focusNode, this.onNavigateUp, this.onNavigateLeft, + this.onNavigateRight, this.onBack, this.onFocusChange, }); @@ -79,6 +84,7 @@ class _FocusableMediaCardState extends State { onLongPress: () => _mediaCardKey.currentState?.showContextMenu(), onNavigateUp: widget.onNavigateUp, onNavigateLeft: widget.onNavigateLeft, + onNavigateRight: widget.onNavigateRight, onBack: widget.onBack, onFocusChange: widget.onFocusChange, enableLongPress: true,