import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import '../../focus/key_event_utils.dart'; import '../../media/library_first_character.dart'; import '../../widgets/clickable_cursor.dart'; import 'alpha_jump_helper.dart'; /// Vertical strip of letters 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. When more letters exist than fit vertically, the bar keeps the /// highest-count letters (by item size) and drops the rest. /// 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 bool descending; /// The letter currently visible at the top of the grid, derived from the /// actual item's sort title by the parent widget. final String currentLetter; final FocusNode? focusNode; final VoidCallback? onNavigateLeft; final VoidCallback? onBack; const AlphaJumpBar({ super.key, required this.firstCharacters, required this.onJump, required this.currentLetter, this.descending = false, this.focusNode, this.onNavigateLeft, this.onBack, }); @override State createState() => _AlphaJumpBarState(); } class _AlphaJumpBarState extends State { late AlphaJumpHelper _helper; /// Subset of letters actually rendered, filtered by available height. List _displayed = const []; /// Cached max-letter count from the last layout pass. int _lastMaxLetters = -1; /// Currently highlighted letter index (for D-pad navigation). int _highlightedIndex = 0; /// Whether this bar currently has focus (for D-pad mode). bool _hasFocus = false; /// Debounce timer for keyboard-driven jumps. Timer? _debounce; /// Minimum vertical space per letter slot. static const double _minLetterHeight = 20.0; @override void initState() { super.initState(); _helper = AlphaJumpHelper(widget.firstCharacters, descending: widget.descending); _displayed = _helper.letters; } @override void didUpdateWidget(AlphaJumpBar oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.firstCharacters != widget.firstCharacters || oldWidget.descending != widget.descending) { _helper = AlphaJumpHelper(widget.firstCharacters, descending: widget.descending); _lastMaxLetters = -1; // force recompute in next layout _displayed = _helper.letters; _clampHighlight(); } } @override void dispose() { _debounce?.cancel(); super.dispose(); } void _clampHighlight() { if (_displayed.isNotEmpty) { _highlightedIndex = _highlightedIndex.clamp(0, _displayed.length - 1); } else { _highlightedIndex = 0; } } /// Recompute [_displayed] if the available letter count changed. void _updateDisplayed(double availableHeight) { final maxLetters = (availableHeight / _minLetterHeight).floor(); if (maxLetters == _lastMaxLetters) return; _lastMaxLetters = maxLetters; _displayed = _helper.displayLetters(maxLetters); _clampHighlight(); } /// Find the nearest displayed letter at or before [letter] in the full list. /// Returns an empty string when [letter] is empty so callers can render an /// "unhighlighted" state — important for the Jellyfin filter UX where no /// letter is selected by default. String _nearestDisplayed(String letter) { if (letter.isEmpty) return ''; if (_displayed.isEmpty) return letter; if (_displayed.contains(letter)) return letter; final pos = _helper.letters.indexOf(letter); if (pos < 0) return _displayed.first; String result = _displayed.first; for (final dl in _displayed) { final dlPos = _helper.letters.indexOf(dl); if (dlPos <= pos) result = dl; } return result; } void _jumpToLetter(String letter) { final index = _helper.indexForLetter(letter); if (index != null) { widget.onJump(index); } } /// Schedule a debounced jump to the currently highlighted letter. void _debouncedJump() { _debounce?.cancel(); _debounce = Timer(const Duration(milliseconds: 150), () { if (_highlightedIndex < _displayed.length) { _jumpToLetter(_displayed[_highlightedIndex]); } }); } /// Resolves a vertical drag position to a displayed-letter index. int _letterIndexFromDy(double dy, double totalHeight) { if (_displayed.isEmpty) return 0; final index = (dy / totalHeight * _displayed.length).floor(); return index.clamp(0, _displayed.length - 1); } KeyEventResult _handleKeyEvent(FocusNode _, KeyEvent event) { final selectResult = handleOneShotSelect(event, () { if (_highlightedIndex < _displayed.length) { _jumpToLetter(_displayed[_highlightedIndex]); } }); if (selectResult != KeyEventResult.ignored) return selectResult; if (event is! KeyDownEvent && event is! KeyRepeatEvent) { return KeyEventResult.ignored; } if (event.logicalKey == LogicalKeyboardKey.arrowUp) { if (_highlightedIndex > 0) { setState(() => _highlightedIndex--); _debouncedJump(); } return KeyEventResult.handled; } if (event.logicalKey == LogicalKeyboardKey.arrowDown) { if (_highlightedIndex < _displayed.length - 1) { setState(() => _highlightedIndex++); _debouncedJump(); } 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 colorScheme = Theme.of(context).colorScheme; return Focus( focusNode: widget.focusNode, onKeyEvent: _handleKeyEvent, onFocusChange: (hasFocus) { setState(() { _hasFocus = hasFocus; if (hasFocus) { final displayed = _nearestDisplayed(widget.currentLetter); final idx = _displayed.indexOf(displayed); if (idx >= 0) _highlightedIndex = idx; } }); }, child: LayoutBuilder( builder: (context, constraints) { _updateDisplayed(constraints.maxHeight); if (_displayed.isEmpty) return const SizedBox.shrink(); final currentLetter = _nearestDisplayed(widget.currentLetter); return ClickableCursor( child: GestureDetector( behavior: HitTestBehavior.opaque, onTapDown: (details) { final idx = _letterIndexFromDy(details.localPosition.dy, constraints.maxHeight); setState(() => _highlightedIndex = idx); _jumpToLetter(_displayed[idx]); }, onVerticalDragUpdate: (details) { final idx = _letterIndexFromDy(details.localPosition.dy, constraints.maxHeight); if (idx != _highlightedIndex) { setState(() => _highlightedIndex = idx); _jumpToLetter(_displayed[idx]); } }, child: Container( width: 28, decoration: BoxDecoration( color: colorScheme.surface.withValues(alpha: 0.7), borderRadius: const BorderRadius.all(Radius.circular(14)), ), child: Column( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: List.generate(_displayed.length, (i) { final letter = _displayed[i]; final isCurrent = letter == currentLetter && !_hasFocus; final isHighlighted = _hasFocus && i == _highlightedIndex; BoxDecoration? decoration; if (isHighlighted) { decoration = BoxDecoration(color: colorScheme.primary, shape: BoxShape.circle); } else if (isCurrent) { decoration = BoxDecoration( color: colorScheme.primary.withValues(alpha: 0.3), shape: BoxShape.circle, ); } Color letterColor; if (isHighlighted) { letterColor = colorScheme.onPrimary; } else if (isCurrent) { letterColor = colorScheme.primary; } else { letterColor = colorScheme.onSurface; } return SizedBox( height: constraints.maxHeight / _displayed.length, child: Center( child: Container( width: 22, height: 22, decoration: decoration, alignment: Alignment.center, child: Text( letter, style: TextStyle( fontSize: 10, fontWeight: (isCurrent || isHighlighted) ? FontWeight.bold : FontWeight.normal, color: letterColor, ), ), ), ), ); }), ), ), ), ); }, ), ); } }