@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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.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);
|
||||
}
|
||||
}
|
||||
@@ -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.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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user