feat: alpha jump bar

close #349
This commit is contained in:
edde746
2026-02-05 10:15:59 +01:00
parent 2b5ccc28cb
commit 5c225bb89e
11 changed files with 896 additions and 132 deletions
+11
View File
@@ -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<String, dynamic> json) {
return PlexFirstCharacter(key: json['key'] ?? '', title: json['title'] ?? '', size: json['size'] ?? 0);
}
}
+4
View File
@@ -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,
+2
View File
@@ -13,6 +13,7 @@ PlexMetadata _$PlexMetadataFromJson(Map<String, dynamic> 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<String, dynamic> _$PlexMetadataToJson(PlexMetadata instance) => <String, dyn
'studio': instance.studio,
'type': instance.type,
'title': instance.title,
'titleSort': instance.titleSort,
'contentRating': instance.contentRating,
'summary': instance.summary,
'rating': instance.rating,
+122 -119
View File
@@ -1090,137 +1090,140 @@ class _LibrariesScreenState extends State<LibrariesScreen>
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>();
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,
),
],
),
),
],
],
],
),
),
);
}
@@ -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<PlexMetadata, LibraryBr
bool _isSortDescending = false;
String _selectedGrouping = 'all'; // all, seasons, episodes, folders
// Alpha jump bar state
List<PlexFirstCharacter> _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<PlexMetadata, LibraryBr
// Scroll controller for the CustomScrollView
final ScrollController _scrollController = ScrollController();
@override
void initState() {
super.initState();
_scrollController.addListener(_onScrollChanged);
}
@override
void dispose() {
_cancelToken?.cancel();
_scrollController.removeListener(_onScrollChanged);
_scrollController.dispose();
_groupingChipFocusNode.dispose();
_filtersChipFocusNode.dispose();
_sortChipFocusNode.dispose();
_alphaJumpBarFocusNode.dispose();
disposeGridFocusNodes();
super.dispose();
}
@@ -183,6 +209,8 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
_selectedSort = null;
_isSortDescending = false;
_selectedGrouping = _getDefaultGrouping();
_firstCharacters = [];
_currentFirstVisibleIndex = 0;
});
try {
@@ -219,8 +247,8 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
}
});
// Load items
await _loadItems();
// Load items and first characters in parallel
await Future.wait([_loadItems(), _loadFirstCharacters()]);
} catch (e) {
_handleLoadError(e, currentRequestId);
}
@@ -419,6 +447,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
storage.saveLibraryGrouping(widget.library.globalKey, pendingGrouping);
});
_loadItems();
_loadFirstCharacters();
});
}
@@ -442,6 +471,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
await storage.saveLibraryFilters(filters, sectionId: widget.library.globalKey);
_loadItems();
_loadFirstCharacters();
},
),
);
@@ -480,6 +510,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
_isSortDescending = false;
});
_loadItems();
_loadFirstCharacters();
} else if (pendingSort != null &&
(pendingSort!.key != _selectedSort?.key || pendingDescending != _isSortDescending)) {
setState(() {
@@ -490,6 +521,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
storage.saveLibrarySort(widget.library.globalKey, pendingSort!.key, descending: pendingDescending);
});
_loadItems();
_loadFirstCharacters();
}
});
}
@@ -519,6 +551,146 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
MainScreenFocusScope.of(context)?.focusSidebar();
}
/// Navigate focus to the alpha jump bar
void _navigateToAlphaJumpBar() {
_alphaJumpBarFocusNode.requestFocus();
}
/// Whether the alpha jump bar should be shown.
/// Only shown when sorting by title (titleSort) and not in folders mode.
bool get _shouldShowAlphaJumpBar {
if (_selectedGrouping == 'folders') return false;
if (_firstCharacters.isEmpty) return false;
// Show when no sort is selected (default is titleSort) or when explicitly sorting by title
final sortKey = _selectedSort?.key ?? '';
return sortKey.isEmpty || sortKey.startsWith('titleSort');
}
/// Fetch first characters for the current library/filter state
Future<void> _loadFirstCharacters() async {
final client = getClientForLibrary();
final filterParams = Map<String, String>.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<void> _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<PlexMetadata, LibraryBr
Positioned.fill(child: _buildScrollableContent()),
// Chips bar on top with solid background
Positioned(top: 0, left: 0, right: 0, child: _buildChipsBar()),
// Alpha jump bar on the right edge
if (_shouldShowAlphaJumpBar)
Positioned(
top: _chipsBarHeight,
right: 0,
bottom: 0,
child: AlphaJumpBar(
firstCharacters: _firstCharacters,
onJump: _jumpToIndex,
currentFirstVisibleIndex: _currentFirstVisibleIndex,
focusNode: _alphaJumpBarFocusNode,
onNavigateLeft: _navigateToGrid,
onBack: widget.onBack,
),
),
],
);
}
@@ -677,14 +864,18 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
// Chips bar is ~48px, focus ring extends ~6px beyond item bounds
static const double _gridTopPadding = _chipsBarHeight + 12.0;
/// Width of the alpha jump bar widget
static const double _alphaJumpBarWidth = 28.0;
/// Builds either a sliver list or sliver grid based on the view mode
Widget _buildItemsSliver(BuildContext context, SettingsProvider settingsProvider) {
final itemCount = items.length + (_hasMoreItems && isLoading ? 1 : 0);
final rightPadding = _shouldShowAlphaJumpBar ? _alphaJumpBarWidth : 8.0;
if (settingsProvider.viewMode == ViewMode.list) {
// In list view, all items are in a single column (first column)
return SliverPadding(
padding: const EdgeInsets.fromLTRB(8, _gridTopPadding, 8, 8),
padding: EdgeInsets.fromLTRB(8, _gridTopPadding, rightPadding, 8),
sliver: SliverList.builder(
itemCount: itemCount,
itemBuilder: (context, index) => _buildMediaCardItem(
@@ -701,10 +892,13 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
_selectedGrouping == 'episodes' && settingsProvider.episodePosterMode == EpisodePosterMode.episodeThumbnail;
final maxExtent = GridSizeCalculator.getMaxCrossAxisExtent(context, settingsProvider.libraryDensity);
return SliverPadding(
padding: const EdgeInsets.fromLTRB(8, _gridTopPadding, 8, 8),
padding: EdgeInsets.fromLTRB(8, _gridTopPadding, rightPadding, 8),
sliver: SliverLayoutBuilder(
builder: (context, constraints) {
final columnCount = GridSizeCalculator.getColumnCount(constraints.crossAxisExtent, maxExtent);
// Cache grid metrics for alpha jump bar scroll calculations
_lastCrossAxisExtent = constraints.crossAxisExtent;
_currentColumnCount = columnCount;
return SliverGrid.builder(
gridDelegate: MediaGridDelegate.createDelegate(
context: context,
@@ -716,6 +910,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
index,
isFirstRow: GridSizeCalculator.isFirstRow(index, columnCount),
isFirstColumn: GridSizeCalculator.isFirstColumn(index, columnCount),
isLastColumn: (index % columnCount) == (columnCount - 1),
),
);
},
@@ -724,7 +919,12 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
}
}
Widget _buildMediaCardItem(int index, {required bool isFirstRow, required bool isFirstColumn}) {
Widget _buildMediaCardItem(
int index, {
required bool isFirstRow,
required bool isFirstColumn,
bool isLastColumn = false,
}) {
if (index >= items.length) {
return const Padding(
padding: EdgeInsets.all(16.0),
@@ -744,6 +944,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
onRefresh: updateItem,
onNavigateUp: isFirstRow ? _navigateToChips : null,
onNavigateLeft: isFirstColumn ? _navigateToSidebar : null,
onNavigateRight: isLastColumn && _shouldShowAlphaJumpBar ? _navigateToAlphaJumpBar : null,
onBack: widget.onBack,
onFocusChange: (hasFocus) => trackGridItemFocus(index, hasFocus),
onListRefresh: _loadItems,
+15
View File
@@ -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<List<PlexFirstCharacter>> getFirstCharacters(
String sectionId, {
int? type,
Map<String, String>? filters,
}) async {
final queryParams = <String, dynamic>{};
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<List<PlexFilterValue>> getFilterValues(String filterKey) async {
final response = await _dio.get(filterKey);
+192
View File
@@ -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 (#, AZ) 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<PlexFirstCharacter> 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<AlphaJumpBar> createState() => _AlphaJumpBarState();
}
class _AlphaJumpBarState extends State<AlphaJumpBar> {
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),
),
),
),
),
);
}),
),
),
);
},
),
);
}
}
+108
View File
@@ -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<String> 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<String, int> letterToIndex;
/// Letters that have at least one item in the data.
final Set<String> activeLetters;
/// Total number of items across all letters.
final int totalItemCount;
AlphaJumpHelper._(this.letterToIndex, this.activeLetters, this.totalItemCount);
factory AlphaJumpHelper(List<PlexFirstCharacter> 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 = <String, int>{};
for (final fc in firstCharacters) {
sizeByLetter[fc.title.toUpperCase()] = fc.size;
}
// Compute cumulative indices in allLetters order (#, A, B, …, Z).
final letterToIndex = <String, int>{};
final activeLetters = <String>{};
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.01.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.01.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);
}
}
+228
View File
@@ -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<PlexFirstCharacter> 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<AlphaScrollHandle> createState() => _AlphaScrollHandleState();
}
class _AlphaScrollHandleState extends State<AlphaScrollHandle> with SingleTickerProviderStateMixin {
late AlphaJumpHelper _helper;
late AnimationController _opacityController;
Timer? _hideTimer;
bool _isDragging = false;
String? _dragLetter;
/// Accumulated fraction (0.01.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,
),
),
),
),
],
),
);
},
),
);
}
}
+2 -8
View File
@@ -36,9 +36,7 @@ class _CollapsibleTextState extends State<CollapsibleText> {
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<CollapsibleText> {
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)),
],
),
),
+6
View File
@@ -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<FocusableMediaCard> {
onLongPress: () => _mediaCardKey.currentState?.showContextMenu(),
onNavigateUp: widget.onNavigateUp,
onNavigateLeft: widget.onNavigateLeft,
onNavigateRight: widget.onNavigateRight,
onBack: widget.onBack,
onFocusChange: widget.onFocusChange,
enableLongPress: true,