With the rail collapsed and Libraries expanded, DOWN from the header landed on a mounted but focus-excluded library node and was swallowed, trapping traversal; the focus order now matches the render exclusion and traversal scans past non-focusable candidates. An action bar rebuilding on count change disposed every node, silently dropping focus and never notifying the TV host; bindings are now reused by stable identity (external focus node, else debugLabel) and the transition is reported. Positional reuse remains the only option for an unlabeled action, but it applies only while the action-list shape is provably unchanged - catalog detail enrichment inserting Request before a focused Trailer used to hand Trailer's focused binding, focus ring and next Select included, to Request. Those actions also carry stable debugLabels so their focus survives enrichment. The text field consumed back on KeyDown outside the shared handler, letting one press act twice. It now marks the back coordinator like handleBackKeyAction does and keeps the down-only shape solely on TV, where the closing IME session swallows the matching KeyUp. Because onBack can move focus (empty search field -> sidebar), the orphaned KeyUp would otherwise drive app-level back on the new focus chain, so the field arms BackKeyUpSuppressor; the suppressor gained a hardware-level KeyUp observer that clears the armed state in a microtask after the press ends, removing the long-documented pinning hazard where an unconsumed KeyUp left it swallowing the next press.
1430 lines
56 KiB
Dart
1430 lines
56 KiB
Dart
import 'dart:async';
|
|
import 'dart:ui' show lerpDouble;
|
|
import '../media/ids.dart';
|
|
import 'dart:io' show Platform;
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:plezy/widgets/app_icon.dart';
|
|
import 'package:material_symbols_icons/symbols.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:provider/provider.dart';
|
|
|
|
import '../focus/dpad_navigator.dart';
|
|
import '../focus/focus_memory_tracker.dart';
|
|
import '../focus/input_mode_tracker.dart';
|
|
import '../media/media_item.dart';
|
|
import '../media/media_library.dart';
|
|
import '../mixins/mounted_set_state_mixin.dart';
|
|
import '../navigation/navigation_tabs.dart';
|
|
import '../providers/catalog_sources_provider.dart';
|
|
import '../providers/hidden_libraries_provider.dart';
|
|
import '../providers/libraries_provider.dart';
|
|
import '../services/music/music_playback_service.dart';
|
|
import '../services/settings_service.dart';
|
|
import '../utils/music_navigation.dart';
|
|
import '../utils/content_utils.dart';
|
|
import '../utils/platform_detector.dart';
|
|
import '../utils/scroll_utils.dart';
|
|
import '../utils/library_grouping.dart';
|
|
import 'music/equalizer_icon.dart';
|
|
import '../providers/multi_server_provider.dart';
|
|
import '../services/fullscreen_state_manager.dart';
|
|
import '../theme/mono_tokens.dart';
|
|
import '../widgets/backend_badge.dart';
|
|
import '../i18n/strings.g.dart';
|
|
|
|
enum _LibraryNavSection { visible, hidden }
|
|
|
|
sealed class _LibraryNavRow {
|
|
final _LibraryNavSection section;
|
|
|
|
const _LibraryNavRow({required this.section});
|
|
}
|
|
|
|
final class _LibraryServerHeaderRow extends _LibraryNavRow {
|
|
final String serverId;
|
|
final String serverName;
|
|
|
|
const _LibraryServerHeaderRow({required super.section, required this.serverId, required this.serverName});
|
|
}
|
|
|
|
final class _LibraryItemRow extends _LibraryNavRow {
|
|
final MediaLibrary library;
|
|
final bool showServerName;
|
|
|
|
const _LibraryItemRow({required super.section, required this.library, this.showServerName = false});
|
|
}
|
|
|
|
/// SELECT activates the rail row, RIGHT hands off to the content area.
|
|
KeyEventResult _handleRailItemKey(KeyEvent event, {required VoidCallback onSelect, VoidCallback? onNavigateRight}) {
|
|
if (event is! KeyDownEvent) return KeyEventResult.ignored;
|
|
if (event.logicalKey.isSelectKey) {
|
|
onSelect();
|
|
return KeyEventResult.handled;
|
|
}
|
|
if (event.logicalKey == LogicalKeyboardKey.arrowRight && onNavigateRight != null) {
|
|
onNavigateRight();
|
|
return KeyEventResult.handled;
|
|
}
|
|
return KeyEventResult.ignored;
|
|
}
|
|
|
|
/// Reusable navigation rail item widget that handles focus, selection, and interaction.
|
|
///
|
|
/// Renders the two M3 Expressive destination layouts and morphs between them:
|
|
/// collapsed is an icon-only 56x32 pill indicator centered in the strip
|
|
/// (announced via [collapsedLabel]); expanded is a full-width stadium row
|
|
/// with leading icon and [label]. Items that never render collapsed
|
|
/// (library rows) leave [collapsedLabel] null and always use the expanded
|
|
/// layout.
|
|
class NavigationRailItem extends StatelessWidget {
|
|
/// M3E collapsed-rail active indicator geometry.
|
|
static const double collapsedIndicatorWidth = 56;
|
|
static const double collapsedIndicatorHeight = 32;
|
|
|
|
/// Fixed collapsed item height so the collapse morph interpolates between
|
|
/// known extents (32px pill + breathing room).
|
|
static const double collapsedItemHeight = 40;
|
|
|
|
final IconData icon;
|
|
final IconData? selectedIcon;
|
|
|
|
/// Custom leading widget rendered instead of the [icon] (e.g. the Now
|
|
/// Playing item's equalizer). Should be at most [iconSize] tall/wide.
|
|
final Widget? iconWidget;
|
|
final Widget label;
|
|
|
|
/// Semantic label for the icon-only collapsed pill. Null means the item
|
|
/// has no collapsed representation and always lays out expanded.
|
|
final String? collapsedLabel;
|
|
|
|
/// Widget rendered after the [label] (e.g. a section header's chevron).
|
|
/// Expanded layout only.
|
|
final Widget? trailing;
|
|
final bool isSelected;
|
|
final bool isCollapsed;
|
|
final VoidCallback onTap;
|
|
final FocusNode focusNode;
|
|
final bool autofocus;
|
|
final double iconSize;
|
|
|
|
/// Horizontal content padding of the expanded row.
|
|
final double horizontalPadding;
|
|
|
|
/// Fixed content width of the expanded layout, so the collapse morph and
|
|
/// the rail width animation stay in lockstep.
|
|
final double expandedContentWidth;
|
|
|
|
/// Expanded row height: fixed for collapsible items, a minimum for
|
|
/// expanded-only items (whose labels may add a second line).
|
|
final double expandedHeight;
|
|
final bool suppressSelectedBackground;
|
|
|
|
/// Background tint while keyboard-focused, and its stronger variant used
|
|
/// when the item also shows its selected background.
|
|
final double focusAlpha;
|
|
final double selectedFocusAlpha;
|
|
|
|
/// Called when RIGHT arrow is pressed to navigate to content area.
|
|
final VoidCallback? onNavigateRight;
|
|
|
|
const NavigationRailItem({
|
|
super.key,
|
|
required this.icon,
|
|
this.selectedIcon,
|
|
this.iconWidget,
|
|
required this.label,
|
|
this.collapsedLabel,
|
|
this.trailing,
|
|
required this.isSelected,
|
|
this.isCollapsed = false,
|
|
required this.onTap,
|
|
required this.focusNode,
|
|
this.autofocus = false,
|
|
this.iconSize = 22,
|
|
this.horizontalPadding = 17,
|
|
this.expandedContentWidth = SideNavigationRailState.expandedWidth - 24,
|
|
this.expandedHeight = 48,
|
|
this.suppressSelectedBackground = false,
|
|
this.focusAlpha = 0.12,
|
|
this.selectedFocusAlpha = 0.15,
|
|
this.onNavigateRight,
|
|
});
|
|
|
|
Color? _indicatorColor(MonoTokens t, {required bool focused, required bool collapsedLayout}) {
|
|
// The collapsed TV rail is a transparent overlay strip; a persistent
|
|
// active pill over artwork is noise there, so it shows focus only.
|
|
final showSelected = isSelected && !suppressSelectedBackground && !(collapsedLayout && PlatformDetector.isTV());
|
|
if (focused) return t.text.withValues(alpha: showSelected ? selectedFocusAlpha : focusAlpha);
|
|
if (showSelected) return t.text.withValues(alpha: 0.1);
|
|
return null;
|
|
}
|
|
|
|
Widget _leadingIcon(MonoTokens t) =>
|
|
iconWidget ??
|
|
AppIcon(
|
|
isSelected && selectedIcon != null ? selectedIcon! : icon,
|
|
fill: 1,
|
|
size: iconSize,
|
|
color: isSelected ? t.text : t.textMuted,
|
|
);
|
|
|
|
static double _collapsedItemWidth(BuildContext context) =>
|
|
SideNavigationRailState.collapsedWidthForContext(context) -
|
|
2 * SideNavigationRailState.horizontalPaddingForContext(context, isCollapsed: true);
|
|
|
|
/// M3E collapsed destination: an icon-only pill indicator.
|
|
Widget _buildCollapsedLayout(BuildContext context, MonoTokens t, {required bool focused}) {
|
|
final width = _collapsedItemWidth(context);
|
|
return SizedBox(
|
|
width: width,
|
|
height: collapsedItemHeight,
|
|
child: Center(
|
|
child: Semantics(
|
|
label: collapsedLabel,
|
|
child: Container(
|
|
width: width < collapsedIndicatorWidth ? width : collapsedIndicatorWidth,
|
|
height: collapsedIndicatorHeight,
|
|
alignment: .center,
|
|
decoration: BoxDecoration(
|
|
color: _indicatorColor(t, focused: focused, collapsedLayout: true),
|
|
borderRadius: BorderRadius.circular(MonoTokens.radiusFull),
|
|
),
|
|
child: _leadingIcon(t),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// M3E expanded destination: full-width stadium indicator behind the row.
|
|
Widget _buildExpandedLayout(MonoTokens t, {required bool focused, required bool fixedHeight}) {
|
|
return Container(
|
|
constraints: fixedHeight ? null : BoxConstraints(minHeight: expandedHeight),
|
|
alignment: .centerLeft,
|
|
padding: EdgeInsets.symmetric(horizontal: horizontalPadding, vertical: fixedHeight ? 0 : 8),
|
|
decoration: BoxDecoration(
|
|
color: _indicatorColor(t, focused: focused, collapsedLayout: false),
|
|
borderRadius: BorderRadius.circular(MonoTokens.radiusFull),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
_leadingIcon(t),
|
|
const SizedBox(width: 11),
|
|
Expanded(child: label),
|
|
?trailing,
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
/// At the endpoints only the active layout exists in the tree; mid-morph
|
|
/// both are stacked and crossfaded inside a box that lerps between their
|
|
/// fixed extents, tracking the rail's own width animation tick for tick.
|
|
Widget _buildMorphingLayouts(BuildContext context, MonoTokens t, {required bool focused, required double expansion}) {
|
|
if (expansion <= 0) return _buildCollapsedLayout(context, t, focused: focused);
|
|
if (expansion >= 1) {
|
|
return SizedBox(
|
|
width: expandedContentWidth,
|
|
height: expandedHeight,
|
|
child: _buildExpandedLayout(t, focused: focused, fixedHeight: true),
|
|
);
|
|
}
|
|
final collapsedWidth = _collapsedItemWidth(context);
|
|
const collapsedHeight = collapsedItemHeight;
|
|
// Both children keep their natural fixed extents and are only clipped by
|
|
// the lerping box, so neither layout is ever squeezed mid-morph.
|
|
return SizedBox(
|
|
width: lerpDouble(collapsedWidth, expandedContentWidth, expansion),
|
|
height: lerpDouble(collapsedHeight, expandedHeight, expansion),
|
|
child: Stack(
|
|
children: [
|
|
Positioned(
|
|
left: 0,
|
|
top: 0,
|
|
child: Opacity(
|
|
opacity: 1 - expansion,
|
|
child: _buildCollapsedLayout(context, t, focused: focused),
|
|
),
|
|
),
|
|
Positioned(
|
|
left: 0,
|
|
top: 0,
|
|
child: Opacity(
|
|
opacity: expansion,
|
|
child: SizedBox(
|
|
width: expandedContentWidth,
|
|
height: expandedHeight,
|
|
child: _buildExpandedLayout(t, focused: focused, fixedHeight: true),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final t = tokens(context);
|
|
|
|
return ListenableBuilder(
|
|
listenable: focusNode,
|
|
builder: (context, _) {
|
|
final focused = focusNode.hasFocus && InputModeTracker.isKeyboardMode(context);
|
|
final content = collapsedLabel == null
|
|
? SizedBox(
|
|
width: expandedContentWidth,
|
|
child: _buildExpandedLayout(t, focused: focused, fixedHeight: false),
|
|
)
|
|
: TweenAnimationBuilder<double>(
|
|
tween: Tween(end: isCollapsed ? 0.0 : 1.0),
|
|
duration: SideNavigationRailState.expandDuration,
|
|
curve: SideNavigationRailState.expandCurve,
|
|
builder: (context, expansion, _) =>
|
|
_buildMorphingLayouts(context, t, focused: focused, expansion: expansion),
|
|
);
|
|
return Focus(
|
|
focusNode: focusNode,
|
|
autofocus: autofocus,
|
|
onKeyEvent: (node, event) => _handleRailItemKey(event, onSelect: onTap, onNavigateRight: onNavigateRight),
|
|
child: Material(
|
|
color: Colors.transparent,
|
|
child: InkWell(
|
|
canRequestFocus: false,
|
|
onTap: onTap,
|
|
borderRadius: BorderRadius.circular(MonoTokens.radiusFull),
|
|
child: UnconstrainedBox(
|
|
alignment: .centerLeft,
|
|
constrainedAxis: Axis.vertical,
|
|
clipBehavior: Clip.hardEdge,
|
|
child: content,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Side navigation rail for Desktop and Android TV platforms
|
|
class SideNavigationRail extends StatefulWidget {
|
|
final NavigationTabId selectedTab;
|
|
final String? selectedLibraryKey;
|
|
final bool isOfflineMode;
|
|
final bool isSidebarFocused;
|
|
final bool alwaysExpanded;
|
|
final bool isReconnecting;
|
|
final ValueChanged<NavigationTabId> onDestinationSelected;
|
|
final ValueChanged<String> onLibrarySelected;
|
|
|
|
/// Called when RIGHT arrow is pressed to navigate to content without selecting.
|
|
final VoidCallback? onNavigateToContent;
|
|
|
|
/// Called when the user taps the reconnect button in offline mode.
|
|
final VoidCallback? onReconnect;
|
|
|
|
/// Called when hover/touch expansion changes, so the shell can scrim the
|
|
/// content the modal rail overlays.
|
|
final ValueChanged<bool>? onInteractionExpandedChanged;
|
|
|
|
const SideNavigationRail({
|
|
super.key,
|
|
required this.selectedTab,
|
|
this.selectedLibraryKey,
|
|
this.isOfflineMode = false,
|
|
this.isSidebarFocused = false,
|
|
this.alwaysExpanded = false,
|
|
this.isReconnecting = false,
|
|
required this.onDestinationSelected,
|
|
required this.onLibrarySelected,
|
|
this.onNavigateToContent,
|
|
this.onInteractionExpandedChanged,
|
|
this.onReconnect,
|
|
});
|
|
|
|
@override
|
|
State<SideNavigationRail> createState() => SideNavigationRailState();
|
|
}
|
|
|
|
class SideNavigationRailState extends State<SideNavigationRail> with MountedSetStateMixin {
|
|
/// Libraries section expansion, held in settings rather than widget state so
|
|
/// it survives relaunch, remount and layout switches (#1896). The rail's
|
|
/// [ListenableBuilder] below listens to the pref, so the toggle just writes.
|
|
bool get _librariesExpanded => SettingsService.instance.read(SettingsService.librariesSectionExpanded);
|
|
|
|
bool _isHovered = false;
|
|
bool _isTouchExpanded = false;
|
|
bool _lastReportedInteractionExpanded = false;
|
|
Timer? _collapseTimer;
|
|
static const double collapsedWidth = 80.0;
|
|
static const double tvCollapsedWidth = 48.0;
|
|
static const double expandedWidth = 220.0;
|
|
static const double _horizontalPadding = 12.0;
|
|
static const double _collapsedHorizontalPadding = 4.0;
|
|
static const double _itemGap = 4.0;
|
|
static const Duration _collapseDelay = Duration(milliseconds: 150);
|
|
|
|
/// Trailing corner radius of the floating (hover/touch) overlay panel; the
|
|
/// docked open rail keeps the regular radiusLg card rounding.
|
|
static const double overlayCornerRadius = 32.0;
|
|
|
|
/// Collapse/expand morph timing, shared by the rail width, every item's
|
|
/// layout morph, and the shell's content translate + bleed counter
|
|
/// animations (MainScreen and SideNavigationBleedBuilder) so they all
|
|
/// track tick for tick.
|
|
static const Duration expandDuration = Duration(milliseconds: 250);
|
|
static const Curve expandCurve = Curves.easeInOutCubicEmphasized;
|
|
|
|
static double collapsedWidthForContext(BuildContext _) => PlatformDetector.isTV() ? tvCollapsedWidth : collapsedWidth;
|
|
|
|
/// Edge padding around the item column; collapsed rails center their items
|
|
/// (72px in the 80px desktop strip, 40px in the 48px TV strip).
|
|
static double horizontalPaddingForContext(BuildContext _, {required bool isCollapsed}) {
|
|
return isCollapsed ? _collapsedHorizontalPadding : _horizontalPadding;
|
|
}
|
|
|
|
static const _kHome = 'home';
|
|
static const _kExplore = 'explore';
|
|
static const _kNowPlaying = 'nowPlaying';
|
|
static const _kLibraries = 'libraries';
|
|
static const _kSearch = 'search';
|
|
static const _kDownloads = 'downloads';
|
|
static const _kSettings = 'settings';
|
|
static const _kReconnect = 'reconnect';
|
|
static const _kFullscreen = 'fullscreen';
|
|
static const _kHiddenLibraries = 'hiddenLibraries';
|
|
static const _kServerHeaderPrefix = 'serverHeader';
|
|
static const _kLibraryItemPrefix = 'library';
|
|
|
|
bool _hiddenLibrariesExpanded = false;
|
|
final Set<String> _collapsedServerGroupKeys = {};
|
|
|
|
// Unified focus state tracker for all nav items (main + libraries)
|
|
late final FocusMemoryTracker _focusTracker;
|
|
|
|
/// Whether the sidebar should be expanded (always, hover, or focus)
|
|
bool get _shouldExpand => widget.alwaysExpanded || _isHovered || _isTouchExpanded || widget.isSidebarFocused;
|
|
|
|
bool get _interactionExpanded => _isHovered || _isTouchExpanded;
|
|
|
|
bool get _showDownloads => !PlatformDetector.isAppleTV();
|
|
|
|
/// macOS has the system green button; mobile/TV have no OS fullscreen toggle.
|
|
bool get _showFullscreenToggle => Platform.isWindows || Platform.isLinux;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_focusTracker = FocusMemoryTracker(debugLabelPrefix: 'nav');
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_collapseTimer?.cancel();
|
|
_focusTracker.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(covariant SideNavigationRail oldWidget) {
|
|
super.didUpdateWidget(oldWidget);
|
|
// Auto-collapse after navigation (selection changed)
|
|
if (oldWidget.selectedTab != widget.selectedTab || oldWidget.selectedLibraryKey != widget.selectedLibraryKey) {
|
|
final wasInteractionExpanded = _interactionExpanded;
|
|
_isTouchExpanded = false;
|
|
if (wasInteractionExpanded != _interactionExpanded) {
|
|
_scheduleInteractionExpandedNotification();
|
|
}
|
|
}
|
|
}
|
|
|
|
/// didUpdateWidget runs during build, where notifying the parent would
|
|
/// setState mid-build; defer that report to the next frame.
|
|
void _scheduleInteractionExpandedNotification() {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (!mounted) return;
|
|
_notifyInteractionExpandedIfNeeded();
|
|
});
|
|
}
|
|
|
|
void _notifyInteractionExpandedIfNeeded() {
|
|
final expanded = _interactionExpanded;
|
|
if (_lastReportedInteractionExpanded == expanded) return;
|
|
_lastReportedInteractionExpanded = expanded;
|
|
widget.onInteractionExpandedChanged?.call(expanded);
|
|
}
|
|
|
|
void _onHoverEnter() {
|
|
_collapseTimer?.cancel();
|
|
if (_isHovered && !_isTouchExpanded) return;
|
|
setState(() {
|
|
_isTouchExpanded = false; // Mouse takes over
|
|
_isHovered = true;
|
|
});
|
|
_notifyInteractionExpandedIfNeeded();
|
|
}
|
|
|
|
void _onHoverExit() {
|
|
_collapseTimer?.cancel();
|
|
_collapseTimer = Timer(_collapseDelay, () {
|
|
if (mounted && _isHovered) {
|
|
setState(() => _isHovered = false);
|
|
_notifyInteractionExpandedIfNeeded();
|
|
}
|
|
});
|
|
}
|
|
|
|
void _expandForTouch() {
|
|
if (_isTouchExpanded) return;
|
|
setState(() => _isTouchExpanded = true);
|
|
_notifyInteractionExpandedIfNeeded();
|
|
}
|
|
|
|
/// The key of the last focused sidebar item (for pre-capture before focus shifts).
|
|
String? get lastFocusedKey => _focusTracker.lastFocusedKey;
|
|
|
|
/// Focus the last focused nav item, or Home as fallback.
|
|
/// If [targetKey] is provided, try it first (used when the caller captured
|
|
/// the intended target before a focus-scope switch overwrote it).
|
|
void focusActiveItem({String? targetKey}) {
|
|
final node = _resolveFocusNode(targetKey) ?? _mountedFocusNodeFor(_kHome);
|
|
if (node == null) return;
|
|
_requestFocusAndReveal(node);
|
|
}
|
|
|
|
/// Focus the Home nav item (Back returning to the home tab).
|
|
void focusHomeItem() {
|
|
final node = _mountedFocusNodeFor(_kHome);
|
|
if (node == null) return;
|
|
_requestFocusAndReveal(node);
|
|
}
|
|
|
|
/// Resolve the best mounted focus node in priority order:
|
|
/// 1. Explicit [targetKey] (captured before scope switch)
|
|
/// 2. Last focused key still in the tracker
|
|
/// 3. Currently selected navigation item (tab / library)
|
|
/// 4. Home fallback
|
|
FocusNode? _resolveFocusNode(String? targetKey) {
|
|
return _mountedFocusNodeFor(targetKey) ??
|
|
_mountedFocusNodeFor(_focusTracker.lastFocusedKey) ??
|
|
_mountedFocusNodeFor(_resolveSelectedFocusKey());
|
|
}
|
|
|
|
FocusNode? _mountedFocusNodeFor(String? key) {
|
|
if (key == null) return null;
|
|
final node = _focusTracker.nodeFor(key);
|
|
return node?.context == null ? null : node;
|
|
}
|
|
|
|
/// Derive a focus key from the current selection state (tab + library).
|
|
/// Returns null if no meaningful selected item exists.
|
|
String? _resolveSelectedFocusKey() {
|
|
switch (widget.selectedTab) {
|
|
case NavigationTabId.discover:
|
|
return _kHome;
|
|
case NavigationTabId.explore:
|
|
return _kExplore;
|
|
case NavigationTabId.libraries:
|
|
final libKey = widget.selectedLibraryKey;
|
|
if (libKey != null && _librariesExpanded) {
|
|
final visibleKey = '$_kLibraryItemPrefix:${_LibraryNavSection.visible.name}:$libKey';
|
|
if (_mountedFocusNodeFor(visibleKey) != null) return visibleKey;
|
|
if (_hiddenLibrariesExpanded) {
|
|
final hiddenKey = '$_kLibraryItemPrefix:${_LibraryNavSection.hidden.name}:$libKey';
|
|
if (_mountedFocusNodeFor(hiddenKey) != null) return hiddenKey;
|
|
}
|
|
}
|
|
return _kLibraries;
|
|
case NavigationTabId.search:
|
|
return _kSearch;
|
|
case NavigationTabId.downloads:
|
|
return _showDownloads ? _kDownloads : null;
|
|
case NavigationTabId.settings:
|
|
return _kSettings;
|
|
case NavigationTabId.liveTv:
|
|
return 'liveTv';
|
|
}
|
|
}
|
|
|
|
/// Request focus on [node] and scroll it into view after the next frame.
|
|
void _requestFocusAndReveal(FocusNode node) {
|
|
node.requestFocus();
|
|
scrollContextToCenter(node.context);
|
|
}
|
|
|
|
String _serverHeaderFocusKey(_LibraryNavSection section, ServerId serverId) =>
|
|
'$_kServerHeaderPrefix:${section.name}:$serverId';
|
|
|
|
String _libraryItemFocusKey(_LibraryNavSection section, MediaLibrary library) =>
|
|
'$_kLibraryItemPrefix:${section.name}:${library.globalKey}';
|
|
|
|
String _serverGroupStateKey(_LibraryNavSection section, ServerId serverId) => '${section.name}:$serverId';
|
|
|
|
String _focusKeyForLibraryRow(_LibraryNavRow row) => switch (row) {
|
|
_LibraryServerHeaderRow(:final section, :final serverId) => _serverHeaderFocusKey(section, ServerId(serverId)),
|
|
_LibraryItemRow(:final section, :final library) => _libraryItemFocusKey(section, library),
|
|
};
|
|
|
|
Iterable<String> _focusKeysForLibraryRows(List<_LibraryNavRow> rows) => rows.map(_focusKeyForLibraryRow);
|
|
|
|
/// Build the set of valid focus keys (main nav + currently rendered library rows).
|
|
Set<String> _buildValidFocusKeys({
|
|
required List<_LibraryNavRow> visibleRows,
|
|
required List<_LibraryNavRow> hiddenRows,
|
|
required bool hasHiddenLibraries,
|
|
required bool hasLiveTv,
|
|
required bool hasNowPlaying,
|
|
required bool hasExplore,
|
|
}) {
|
|
return {
|
|
_kHome,
|
|
if (hasNowPlaying) _kNowPlaying,
|
|
_kLibraries,
|
|
if (hasExplore) _kExplore,
|
|
_kSearch,
|
|
if (_showDownloads) _kDownloads,
|
|
_kSettings,
|
|
_kReconnect,
|
|
if (hasHiddenLibraries) _kHiddenLibraries,
|
|
if (_showFullscreenToggle) _kFullscreen,
|
|
if (hasLiveTv) 'liveTv',
|
|
..._focusKeysForLibraryRows(visibleRows),
|
|
if (_hiddenLibrariesExpanded) ..._focusKeysForLibraryRows(hiddenRows),
|
|
};
|
|
}
|
|
|
|
/// Build rendered rows inside one library section. This is the single source
|
|
/// of truth for both widget rendering and D-pad focus ordering.
|
|
List<_LibraryNavRow> _buildLibraryRows(
|
|
List<MediaLibrary> libs, {
|
|
required _LibraryNavSection section,
|
|
required bool showServerHeaders,
|
|
}) {
|
|
if (!showServerHeaders) {
|
|
final nonUniqueNames = _getNonUniqueLibraryNames(libs);
|
|
return libs.map((lib) {
|
|
return _LibraryItemRow(
|
|
section: section,
|
|
library: lib,
|
|
showServerName: nonUniqueNames.contains(lib.title) && lib.serverName != null,
|
|
);
|
|
}).toList();
|
|
}
|
|
final grouped = groupLibrariesByFirstAppearance(libs);
|
|
final result = <_LibraryNavRow>[];
|
|
for (final serverKey in grouped.serverOrder) {
|
|
final bucket = grouped.byServer[serverKey]!;
|
|
if (serverKey.isNotEmpty) {
|
|
result.add(
|
|
_LibraryServerHeaderRow(
|
|
section: section,
|
|
serverId: serverKey,
|
|
serverName: bucket.first.serverName ?? serverKey,
|
|
),
|
|
);
|
|
}
|
|
if (serverKey.isEmpty ||
|
|
!_collapsedServerGroupKeys.contains(_serverGroupStateKey(section, ServerId(serverKey)))) {
|
|
for (final lib in bucket) {
|
|
result.add(_LibraryItemRow(section: section, library: lib));
|
|
}
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
Set<String> _buildServerGroupStateKeys(
|
|
List<MediaLibrary> visibleLibraries,
|
|
List<MediaLibrary> hiddenLibraries, {
|
|
required bool showServerHeaders,
|
|
}) {
|
|
if (!showServerHeaders) return {};
|
|
|
|
return {
|
|
for (final lib in visibleLibraries)
|
|
if (lib.serverId != null) _serverGroupStateKey(_LibraryNavSection.visible, ServerId(lib.serverId!)),
|
|
for (final lib in hiddenLibraries)
|
|
if (lib.serverId != null) _serverGroupStateKey(_LibraryNavSection.hidden, ServerId(lib.serverId!)),
|
|
};
|
|
}
|
|
|
|
/// Ordered list of focusable keys matching visual top-to-bottom order.
|
|
List<String> _buildFocusOrder(
|
|
List<_LibraryNavRow> visibleRows,
|
|
List<_LibraryNavRow> hiddenRows, {
|
|
required bool hasHiddenLibraries,
|
|
required bool hasLiveTv,
|
|
required bool hasNowPlaying,
|
|
required bool hasExplore,
|
|
required bool isCollapsed,
|
|
}) {
|
|
return [
|
|
if (widget.isOfflineMode && widget.onReconnect != null) _kReconnect,
|
|
if (!widget.isOfflineMode) ...[
|
|
_kHome,
|
|
if (hasNowPlaying) _kNowPlaying,
|
|
_kLibraries,
|
|
// Library rows render inside ExcludeFocus(excluding: !_librariesExpanded
|
|
// || isCollapsed); keep the D-pad order in lockstep with that render
|
|
// condition so a collapsed rail never targets a focus-excluded row.
|
|
if (_librariesExpanded && !isCollapsed) ...[
|
|
..._focusKeysForLibraryRows(visibleRows),
|
|
if (hasHiddenLibraries) ...[
|
|
_kHiddenLibraries,
|
|
if (_hiddenLibrariesExpanded) ..._focusKeysForLibraryRows(hiddenRows),
|
|
],
|
|
],
|
|
if (hasLiveTv) 'liveTv',
|
|
if (hasExplore) _kExplore,
|
|
_kSearch,
|
|
],
|
|
if (_showDownloads) _kDownloads,
|
|
_kSettings,
|
|
if (_showFullscreenToggle) _kFullscreen,
|
|
];
|
|
}
|
|
|
|
void _debugAssertUniqueFocusOrder(List<String> focusOrder) {
|
|
assert(() {
|
|
final seen = <String>{};
|
|
for (final key in focusOrder) {
|
|
if (!seen.add(key)) {
|
|
throw FlutterError('SideNavigationRail focus order contains duplicate key: $key');
|
|
}
|
|
}
|
|
return true;
|
|
}());
|
|
}
|
|
|
|
/// Handle D-pad UP/DOWN by explicitly moving focus to the next/previous item.
|
|
KeyEventResult _handleVerticalNavigation(FocusNode _, KeyEvent event, List<String> focusOrder) {
|
|
if (event is! KeyDownEvent) return KeyEventResult.ignored;
|
|
|
|
final isDown = event.logicalKey == LogicalKeyboardKey.arrowDown;
|
|
final isUp = event.logicalKey == LogicalKeyboardKey.arrowUp;
|
|
if (!isDown && !isUp) return KeyEventResult.ignored;
|
|
|
|
final currentKey = _focusTracker.lastFocusedKey;
|
|
if (currentKey == null) return KeyEventResult.ignored;
|
|
|
|
final currentIndex = focusOrder.indexOf(currentKey);
|
|
if (currentIndex == -1) return KeyEventResult.ignored;
|
|
|
|
// Scan onward past keys whose node is unmounted or focus-excluded (a row
|
|
// can outlive its focusability, e.g. under an ExcludeFocus ancestor).
|
|
// Returning handled without moving focus would swallow the key and also
|
|
// suppress the framework's own directional traversal, so yield ignored
|
|
// when no viable candidate remains in the requested direction.
|
|
final step = isDown ? 1 : -1;
|
|
for (var index = currentIndex + step; index >= 0 && index < focusOrder.length; index += step) {
|
|
final node = _focusTracker.nodeFor(focusOrder[index]);
|
|
if (node == null || node.context == null || !node.canRequestFocus) continue;
|
|
_requestFocusAndReveal(node);
|
|
return KeyEventResult.handled;
|
|
}
|
|
return KeyEventResult.ignored;
|
|
}
|
|
|
|
/// Collapse the sidebar (resets touch-expand state).
|
|
void collapse() {
|
|
if (_isTouchExpanded) {
|
|
setState(() => _isTouchExpanded = false);
|
|
_notifyInteractionExpandedIfNeeded();
|
|
}
|
|
}
|
|
|
|
/// Reload libraries (called when servers change or profile switches)
|
|
void reloadLibraries() {
|
|
final librariesProvider = context.read<LibrariesProvider>();
|
|
librariesProvider.refresh();
|
|
}
|
|
|
|
/// Calculate top padding for macOS traffic lights
|
|
double _getTopPadding(BuildContext context) {
|
|
double basePadding = MediaQuery.paddingOf(context).top + 16;
|
|
|
|
// On macOS, add extra padding for traffic lights (when not fullscreen)
|
|
if (Platform.isMacOS) {
|
|
final isFullscreen = FullscreenStateManager().isFullscreen;
|
|
if (!isFullscreen) {
|
|
// Traffic lights area is approximately 52 pixels high
|
|
basePadding = basePadding < 52 ? 52 : basePadding;
|
|
}
|
|
}
|
|
|
|
return basePadding;
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final t = tokens(context);
|
|
final librariesProvider = context.watch<LibrariesProvider>();
|
|
final hiddenLibrariesProvider = context.watch<HiddenLibrariesProvider>();
|
|
final hiddenKeys = hiddenLibrariesProvider.hiddenLibraryKeys;
|
|
|
|
final allLibraries = librariesProvider.libraries;
|
|
final visibleLibraries = <MediaLibrary>[];
|
|
final hiddenLibraries = <MediaLibrary>[];
|
|
final serverIds = <String>{};
|
|
for (final lib in allLibraries) {
|
|
if (lib.serverId != null) serverIds.add(lib.serverId!);
|
|
if (hiddenKeys.contains(lib.globalKey)) {
|
|
hiddenLibraries.add(lib);
|
|
} else {
|
|
visibleLibraries.add(lib);
|
|
}
|
|
}
|
|
|
|
final isCollapsed = !_shouldExpand;
|
|
final effectiveCollapsedWidth = collapsedWidthForContext(context);
|
|
final horizontalPadding = horizontalPaddingForContext(context, isCollapsed: isCollapsed);
|
|
final hasLiveTv = context.watch<MultiServerProvider>().hasLiveTv;
|
|
// Nullable watch: rail tests (and any host without the profile session
|
|
// scope) simply never show the Explore item.
|
|
final hasExploreSource = context.watch<CatalogSourcesProvider?>()?.hasAnySource ?? false;
|
|
// Nullable watch: rail tests (and any host without the profile session
|
|
// scope) simply never show the Now Playing item. TV-only — it is the
|
|
// way back into the now-playing screen there; desktop already has the
|
|
// mini-player for that.
|
|
final musicService = context.watch<MusicPlaybackService?>();
|
|
final nowPlayingTrack = widget.isOfflineMode || !PlatformDetector.isTV() ? null : musicService?.currentTrack;
|
|
|
|
// Listen to fullscreen + the groupLibrariesByServer / showExploreTab
|
|
// settings so the rail rebuilds when they are toggled in Appearance, and to
|
|
// librariesSectionExpanded so the Libraries header toggle repaints.
|
|
return ListenableBuilder(
|
|
listenable: Listenable.merge([
|
|
FullscreenStateManager(),
|
|
SettingsService.instance.listenable(SettingsService.groupLibrariesByServer),
|
|
SettingsService.instance.listenable(SettingsService.showExploreTab),
|
|
SettingsService.instance.listenable(SettingsService.librariesSectionExpanded),
|
|
]),
|
|
builder: (context, _) {
|
|
final hasExplore = hasExploreSource && SettingsService.instance.read(SettingsService.showExploreTab);
|
|
// Server grouping: only when multi-server AND the user-facing toggle is on.
|
|
final groupByServerSetting = SettingsService.instance.read(SettingsService.groupLibrariesByServer);
|
|
final showServerHeaders = serverIds.length > 1 && groupByServerSetting;
|
|
_collapsedServerGroupKeys.retainAll(
|
|
_buildServerGroupStateKeys(visibleLibraries, hiddenLibraries, showServerHeaders: showServerHeaders),
|
|
);
|
|
final visibleRows = _buildLibraryRows(
|
|
visibleLibraries,
|
|
section: _LibraryNavSection.visible,
|
|
showServerHeaders: showServerHeaders,
|
|
);
|
|
final hiddenRows = _buildLibraryRows(
|
|
hiddenLibraries,
|
|
section: _LibraryNavSection.hidden,
|
|
showServerHeaders: showServerHeaders,
|
|
);
|
|
_focusTracker.pruneExcept(
|
|
_buildValidFocusKeys(
|
|
visibleRows: visibleRows,
|
|
hiddenRows: hiddenRows,
|
|
hasHiddenLibraries: hiddenLibraries.isNotEmpty,
|
|
hasLiveTv: hasLiveTv,
|
|
hasNowPlaying: nowPlayingTrack != null,
|
|
hasExplore: hasExplore,
|
|
),
|
|
);
|
|
final focusOrder = _buildFocusOrder(
|
|
visibleRows,
|
|
hiddenRows,
|
|
hasHiddenLibraries: hiddenLibraries.isNotEmpty,
|
|
hasLiveTv: hasLiveTv,
|
|
hasNowPlaying: nowPlayingTrack != null,
|
|
hasExplore: hasExplore,
|
|
isCollapsed: isCollapsed,
|
|
);
|
|
_debugAssertUniqueFocusOrder(focusOrder);
|
|
return TapRegion(
|
|
onTapOutside: (_) {
|
|
if (_isTouchExpanded) {
|
|
setState(() => _isTouchExpanded = false);
|
|
_notifyInteractionExpandedIfNeeded();
|
|
}
|
|
},
|
|
child: MouseRegion(
|
|
cursor: isCollapsed ? SystemMouseCursors.click : MouseCursor.defer,
|
|
onEnter: (_) => _onHoverEnter(),
|
|
onExit: (_) => _onHoverExit(),
|
|
child: GestureDetector(
|
|
behavior: HitTestBehavior.opaque,
|
|
onTap: isCollapsed ? _expandForTouch : null,
|
|
child: AnimatedContainer(
|
|
duration: expandDuration,
|
|
curve: expandCurve,
|
|
width: isCollapsed ? effectiveCollapsedWidth : expandedWidth,
|
|
clipBehavior: Clip.hardEdge,
|
|
decoration: const BoxDecoration(),
|
|
child: Stack(
|
|
children: [
|
|
// Desktop surface. Hover/touch expansion is an M3E
|
|
// modal rail: it overlays the content (the shell keeps
|
|
// its collapsed offset), so it casts an edge shadow.
|
|
Positioned.fill(
|
|
child: AnimatedOpacity(
|
|
opacity: PlatformDetector.isTV() ? 0.0 : 1.0,
|
|
duration: t.normal,
|
|
curve: Curves.easeOutCubic,
|
|
child: AnimatedContainer(
|
|
duration: expandDuration,
|
|
curve: expandCurve,
|
|
decoration: BoxDecoration(
|
|
color: t.surface,
|
|
// Open rails read as an M3E panel: rounded
|
|
// trailing corners, extra-rounded plus an edge
|
|
// shadow while floating over content (modal)
|
|
// instead of pushing it.
|
|
borderRadius: isCollapsed
|
|
? BorderRadius.zero
|
|
: BorderRadius.horizontal(
|
|
right: Radius.circular(
|
|
_interactionExpanded && !widget.alwaysExpanded ? overlayCornerRadius : t.radiusLg,
|
|
),
|
|
),
|
|
boxShadow: _interactionExpanded && !widget.alwaysExpanded
|
|
? [BoxShadow(color: Colors.black.withValues(alpha: 0.35), blurRadius: 24)]
|
|
: const [],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
IgnorePointer(
|
|
ignoring: isCollapsed,
|
|
child: Focus(
|
|
canRequestFocus: false,
|
|
skipTraversal: true,
|
|
onKeyEvent: (node, event) => _handleVerticalNavigation(node, event, focusOrder),
|
|
child: Column(
|
|
children: [
|
|
SizedBox(height: _getTopPadding(context)),
|
|
Expanded(
|
|
child: AnimatedPadding(
|
|
padding: .symmetric(horizontal: horizontalPadding),
|
|
duration: expandDuration,
|
|
curve: expandCurve,
|
|
child: ListView(
|
|
padding: EdgeInsets.zero,
|
|
clipBehavior: Clip.hardEdge,
|
|
children: [
|
|
if (widget.isOfflineMode && widget.onReconnect != null) ...[
|
|
_buildReconnectItem(isCollapsed: isCollapsed),
|
|
const SizedBox(height: _itemGap),
|
|
],
|
|
if (!widget.isOfflineMode) ...[
|
|
_buildNavItem(
|
|
icon: Symbols.home_rounded,
|
|
selectedIcon: Symbols.home_rounded,
|
|
label: Translations.of(context).common.home,
|
|
isSelected: widget.selectedTab == NavigationTabId.discover,
|
|
onTap: () => widget.onDestinationSelected(NavigationTabId.discover),
|
|
focusNode: _focusTracker.get(_kHome),
|
|
isCollapsed: isCollapsed,
|
|
),
|
|
const SizedBox(height: _itemGap),
|
|
// Now Playing — only while a music session is live.
|
|
if (nowPlayingTrack != null && musicService != null) ...[
|
|
_buildNowPlayingItem(nowPlayingTrack, musicService, isCollapsed: isCollapsed),
|
|
const SizedBox(height: _itemGap),
|
|
],
|
|
_buildLibrariesSection(
|
|
visibleRows,
|
|
hiddenRows,
|
|
hiddenLibraries.length,
|
|
t,
|
|
isCollapsed: isCollapsed,
|
|
),
|
|
const SizedBox(height: _itemGap),
|
|
if (context.watch<MultiServerProvider>().hasLiveTv) ...[
|
|
_buildNavItem(
|
|
icon: Symbols.live_tv_rounded,
|
|
selectedIcon: Symbols.live_tv_rounded,
|
|
label: Translations.of(context).navigation.liveTv,
|
|
isSelected: widget.selectedTab == NavigationTabId.liveTv,
|
|
onTap: () => widget.onDestinationSelected(NavigationTabId.liveTv),
|
|
focusNode: _focusTracker.get('liveTv'),
|
|
isCollapsed: isCollapsed,
|
|
),
|
|
const SizedBox(height: _itemGap),
|
|
],
|
|
if (hasExplore) ...[
|
|
_buildNavItem(
|
|
icon: Symbols.explore_rounded,
|
|
selectedIcon: Symbols.explore_rounded,
|
|
label: Translations.of(context).navigation.explore,
|
|
isSelected: widget.selectedTab == NavigationTabId.explore,
|
|
onTap: () => widget.onDestinationSelected(NavigationTabId.explore),
|
|
focusNode: _focusTracker.get(_kExplore),
|
|
isCollapsed: isCollapsed,
|
|
),
|
|
const SizedBox(height: _itemGap),
|
|
],
|
|
_buildNavItem(
|
|
icon: Symbols.search_rounded,
|
|
selectedIcon: Symbols.search_rounded,
|
|
label: Translations.of(context).common.search,
|
|
isSelected: widget.selectedTab == NavigationTabId.search,
|
|
onTap: () => widget.onDestinationSelected(NavigationTabId.search),
|
|
focusNode: _focusTracker.get(_kSearch),
|
|
isCollapsed: isCollapsed,
|
|
),
|
|
const SizedBox(height: _itemGap),
|
|
],
|
|
// Downloads (hidden on Apple TV — no user
|
|
// file storage)
|
|
if (_showDownloads) ...[
|
|
_buildNavItem(
|
|
icon: Symbols.download_rounded,
|
|
selectedIcon: Symbols.download_rounded,
|
|
label: Translations.of(context).navigation.downloads,
|
|
isSelected: widget.selectedTab == NavigationTabId.downloads,
|
|
onTap: () => widget.onDestinationSelected(NavigationTabId.downloads),
|
|
focusNode: _focusTracker.get(_kDownloads),
|
|
isCollapsed: isCollapsed,
|
|
),
|
|
const SizedBox(height: _itemGap),
|
|
],
|
|
_buildNavItem(
|
|
icon: Symbols.settings_rounded,
|
|
selectedIcon: Symbols.settings_rounded,
|
|
label: Translations.of(context).common.settings,
|
|
isSelected: widget.selectedTab == NavigationTabId.settings,
|
|
onTap: () => widget.onDestinationSelected(NavigationTabId.settings),
|
|
focusNode: _focusTracker.get(_kSettings),
|
|
isCollapsed: isCollapsed,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
if (_showFullscreenToggle)
|
|
AnimatedPadding(
|
|
padding: .fromLTRB(horizontalPadding, 0, horizontalPadding, 12),
|
|
duration: expandDuration,
|
|
curve: expandCurve,
|
|
child: _buildFullscreenItem(isCollapsed: isCollapsed),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildNavItem({
|
|
required IconData icon,
|
|
required IconData selectedIcon,
|
|
required String label,
|
|
required bool isSelected,
|
|
required VoidCallback onTap,
|
|
required FocusNode focusNode,
|
|
required bool isCollapsed,
|
|
bool autofocus = false,
|
|
}) {
|
|
final t = tokens(context);
|
|
|
|
return NavigationRailItem(
|
|
icon: icon,
|
|
selectedIcon: selectedIcon,
|
|
label: Text(
|
|
label,
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400,
|
|
color: isSelected ? t.text : t.textMuted,
|
|
),
|
|
overflow: .ellipsis,
|
|
maxLines: 1,
|
|
),
|
|
collapsedLabel: label,
|
|
isSelected: isSelected,
|
|
isCollapsed: isCollapsed,
|
|
onTap: onTap,
|
|
focusNode: focusNode,
|
|
autofocus: autofocus,
|
|
suppressSelectedBackground: widget.isSidebarFocused,
|
|
onNavigateRight: widget.onNavigateToContent,
|
|
);
|
|
}
|
|
|
|
/// "Now Playing" rail item — the shared equalizer as its icon (animating
|
|
/// while audio plays), the current track's title as its label. SELECT/tap
|
|
/// opens the now-playing screen.
|
|
Widget _buildNowPlayingItem(MediaItem track, MusicPlaybackService musicService, {required bool isCollapsed}) {
|
|
final t = tokens(context);
|
|
|
|
return NavigationRailItem(
|
|
icon: Symbols.music_note_rounded,
|
|
iconWidget: SizedBox(
|
|
width: 22,
|
|
child: Center(
|
|
child: EqualizerIcon(animate: musicService.isPlaying, color: t.text),
|
|
),
|
|
),
|
|
label: Column(
|
|
crossAxisAlignment: .start,
|
|
mainAxisSize: .min,
|
|
children: [
|
|
Text(
|
|
Translations.of(context).music.nowPlaying,
|
|
style: TextStyle(fontSize: 14, fontWeight: .w600, color: t.text),
|
|
overflow: .ellipsis,
|
|
maxLines: 1,
|
|
),
|
|
Text(
|
|
track.title ?? '',
|
|
style: TextStyle(fontSize: 11, color: t.textMuted),
|
|
overflow: .ellipsis,
|
|
maxLines: 1,
|
|
),
|
|
],
|
|
),
|
|
collapsedLabel: Translations.of(context).music.nowPlaying,
|
|
isSelected: false,
|
|
isCollapsed: isCollapsed,
|
|
onTap: () => unawaited(openNowPlaying(context)),
|
|
focusNode: _focusTracker.get(_kNowPlaying),
|
|
onNavigateRight: widget.onNavigateToContent,
|
|
);
|
|
}
|
|
|
|
Widget _buildReconnectItem({required bool isCollapsed}) {
|
|
final t = tokens(context);
|
|
|
|
return NavigationRailItem(
|
|
icon: widget.isReconnecting ? Symbols.sync_rounded : Symbols.wifi_rounded,
|
|
label: widget.isReconnecting
|
|
? SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2, color: t.text))
|
|
: Text(
|
|
Translations.of(context).common.reconnect,
|
|
style: TextStyle(fontSize: 14, fontWeight: .w400, color: t.textMuted),
|
|
overflow: .ellipsis,
|
|
maxLines: 1,
|
|
),
|
|
collapsedLabel: Translations.of(context).common.reconnect,
|
|
isSelected: false,
|
|
isCollapsed: isCollapsed,
|
|
// ignore: no-empty-block - no-op tap handler while reconnecting
|
|
onTap: widget.isReconnecting ? () {} : () => widget.onReconnect?.call(),
|
|
focusNode: _focusTracker.get(_kReconnect),
|
|
onNavigateRight: widget.onNavigateToContent,
|
|
);
|
|
}
|
|
|
|
Widget _buildFullscreenItem({required bool isCollapsed}) {
|
|
final t = tokens(context);
|
|
final isFullscreen = FullscreenStateManager().isFullscreen;
|
|
final label = isFullscreen
|
|
? Translations.of(context).common.exitFullscreen
|
|
: Translations.of(context).common.fullscreen;
|
|
|
|
return NavigationRailItem(
|
|
icon: isFullscreen ? Symbols.fullscreen_exit_rounded : Symbols.fullscreen_rounded,
|
|
label: Text(
|
|
label,
|
|
style: TextStyle(fontSize: 14, fontWeight: .w400, color: t.textMuted),
|
|
overflow: .ellipsis,
|
|
maxLines: 1,
|
|
),
|
|
collapsedLabel: label,
|
|
isSelected: false,
|
|
isCollapsed: isCollapsed,
|
|
onTap: () => unawaited(FullscreenStateManager().toggleFullscreen()),
|
|
focusNode: _focusTracker.get(_kFullscreen),
|
|
onNavigateRight: widget.onNavigateToContent,
|
|
);
|
|
}
|
|
|
|
Widget _buildLibrariesSection(
|
|
List<_LibraryNavRow> visibleRows,
|
|
List<_LibraryNavRow> hiddenRows,
|
|
int hiddenLibraryCount,
|
|
dynamic t, {
|
|
bool isCollapsed = false,
|
|
}) {
|
|
final librariesProvider = context.watch<LibrariesProvider>();
|
|
final isLoading = librariesProvider.isLoading;
|
|
final isLibrariesTabSelected = widget.selectedTab == NavigationTabId.libraries;
|
|
final allEmpty = visibleRows.isEmpty && hiddenLibraryCount == 0;
|
|
|
|
return Column(
|
|
crossAxisAlignment: .start,
|
|
children: [
|
|
// A destination-sized row like Home/Search — same height, icon and
|
|
// label metrics — with the chevron as the expand affordance. The
|
|
// smaller header type is reserved for the nested sub-headers.
|
|
NavigationRailItem(
|
|
icon: Symbols.video_library_rounded,
|
|
label: Text(
|
|
Translations.of(context).navigation.libraries,
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
fontWeight: isLibrariesTabSelected ? FontWeight.w600 : FontWeight.w400,
|
|
color: isLibrariesTabSelected ? t.text : t.textMuted,
|
|
),
|
|
overflow: .ellipsis,
|
|
maxLines: 1,
|
|
),
|
|
collapsedLabel: Translations.of(context).navigation.libraries,
|
|
trailing: AppIcon(
|
|
_librariesExpanded ? Symbols.expand_less_rounded : Symbols.expand_more_rounded,
|
|
fill: 1,
|
|
size: 18,
|
|
color: t.textMuted,
|
|
),
|
|
isSelected: isLibrariesTabSelected,
|
|
isCollapsed: isCollapsed,
|
|
onTap: () =>
|
|
unawaited(SettingsService.instance.write(SettingsService.librariesSectionExpanded, !_librariesExpanded)),
|
|
focusNode: _focusTracker.get(_kLibraries),
|
|
// A selected library owns the highlight; the header only shows it
|
|
// for the bare Libraries tab.
|
|
suppressSelectedBackground: widget.isSidebarFocused || widget.selectedLibraryKey != null,
|
|
focusAlpha: 0.08,
|
|
selectedFocusAlpha: 0.1,
|
|
onNavigateRight: widget.onNavigateToContent,
|
|
),
|
|
|
|
TweenAnimationBuilder<double>(
|
|
tween: Tween(end: (_librariesExpanded && !isCollapsed) ? 1.0 : 0.0),
|
|
duration: expandDuration,
|
|
curve: expandCurve,
|
|
builder: (context, value, child) {
|
|
return ClipRect(
|
|
child: Align(alignment: .topCenter, heightFactor: value, child: child),
|
|
);
|
|
},
|
|
child: ExcludeFocus(
|
|
excluding: !_librariesExpanded || isCollapsed,
|
|
child: Column(
|
|
mainAxisSize: .min,
|
|
crossAxisAlignment: .start,
|
|
children: [
|
|
const SizedBox(height: 4),
|
|
if (isLoading)
|
|
Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Center(
|
|
child: SizedBox(
|
|
width: 20,
|
|
height: 20,
|
|
child: CircularProgressIndicator(strokeWidth: 2, color: t.textMuted),
|
|
),
|
|
),
|
|
)
|
|
else if (allEmpty)
|
|
Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Text(
|
|
Translations.of(context).libraries.noLibrariesFound,
|
|
style: TextStyle(fontSize: 12, color: t.textMuted),
|
|
),
|
|
)
|
|
else ...[
|
|
if (visibleRows.isNotEmpty) _buildLibraryGroupedColumn(visibleRows, t),
|
|
if (hiddenLibraryCount > 0) ...[
|
|
_buildHiddenLibrariesHeader(hiddenLibraryCount, t),
|
|
if (_hiddenLibrariesExpanded) _buildLibraryGroupedColumn(hiddenRows, t),
|
|
],
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
/// Get set of library names that appear more than once (not globally unique)
|
|
Set<String> _getNonUniqueLibraryNames(List<MediaLibrary> libraries) {
|
|
final nameCounts = <String, int>{};
|
|
for (final lib in libraries) {
|
|
nameCounts[lib.title] = (nameCounts[lib.title] ?? 0) + 1;
|
|
}
|
|
return nameCounts.entries.where((e) => e.value > 1).map((e) => e.key).toSet();
|
|
}
|
|
|
|
Widget _buildLibraryGroupedColumn(List<_LibraryNavRow> rows, dynamic t) {
|
|
return Column(
|
|
crossAxisAlignment: .start,
|
|
children: rows.map((row) {
|
|
return switch (row) {
|
|
_LibraryServerHeaderRow(:final section, :final serverId, :final serverName) => _buildServerHeader(
|
|
section,
|
|
ServerId(serverId),
|
|
serverName,
|
|
t,
|
|
),
|
|
_LibraryItemRow(:final section, :final library, :final showServerName) => _buildLibraryItem(
|
|
section,
|
|
library,
|
|
t,
|
|
showServerName: showServerName,
|
|
),
|
|
};
|
|
}).toList(),
|
|
);
|
|
}
|
|
|
|
Widget _buildServerHeader(_LibraryNavSection section, ServerId serverId, String serverName, dynamic t) {
|
|
// Resolve backend per server so the badge matches the brand. Falls back
|
|
// to the generic `dns` icon if the client isn't registered yet (rare —
|
|
// can happen during a profile switch before the manager rehydrates).
|
|
final backend = context.read<MultiServerProvider>().serverManager.getClient(serverId)?.backend;
|
|
return _buildCollapsibleHeader(
|
|
focusKey: _serverHeaderFocusKey(section, serverId),
|
|
icon: Symbols.dns_rounded,
|
|
iconSize: 14,
|
|
leading: backend == null ? null : BackendBadge(backend: backend, size: 14, color: t.textMuted),
|
|
label: serverName,
|
|
labelStyle: TextStyle(fontSize: 11, fontWeight: .w600, letterSpacing: 0.4, color: t.textMuted),
|
|
verticalPadding: 6,
|
|
isExpanded: !_collapsedServerGroupKeys.contains(_serverGroupStateKey(section, serverId)),
|
|
onToggle: () => _toggleServerCollapse(section, serverId),
|
|
t: t,
|
|
);
|
|
}
|
|
|
|
void _toggleServerCollapse(_LibraryNavSection section, ServerId serverId) {
|
|
final groupKey = _serverGroupStateKey(section, serverId);
|
|
setState(() {
|
|
if (!_collapsedServerGroupKeys.add(groupKey)) {
|
|
_collapsedServerGroupKeys.remove(groupKey);
|
|
}
|
|
});
|
|
}
|
|
|
|
Widget _buildHiddenLibrariesHeader(int count, dynamic t) {
|
|
return _buildCollapsibleHeader(
|
|
focusKey: _kHiddenLibraries,
|
|
icon: Symbols.visibility_off_rounded,
|
|
iconSize: 16,
|
|
label: Translations.of(context).libraries.hiddenLibrariesCount(count: count),
|
|
labelStyle: TextStyle(fontSize: 12, fontWeight: .w500, color: t.textMuted),
|
|
verticalPadding: 8,
|
|
isExpanded: _hiddenLibrariesExpanded,
|
|
onToggle: () => setState(() => _hiddenLibrariesExpanded = !_hiddenLibrariesExpanded),
|
|
t: t,
|
|
);
|
|
}
|
|
|
|
Widget _buildCollapsibleHeader({
|
|
required String focusKey,
|
|
required IconData icon,
|
|
required double iconSize,
|
|
Widget? leading,
|
|
required String label,
|
|
required TextStyle labelStyle,
|
|
required double verticalPadding,
|
|
required bool isExpanded,
|
|
required VoidCallback onToggle,
|
|
required dynamic t,
|
|
}) {
|
|
final focusNode = _focusTracker.get(focusKey);
|
|
final radius = BorderRadius.circular(MonoTokens.radiusFull);
|
|
// Sub-section header at full rail width; the smaller type alone carries
|
|
// the hierarchy, matching M3E expanded-rail sections.
|
|
return ListenableBuilder(
|
|
listenable: focusNode,
|
|
builder: (context, _) => Focus(
|
|
focusNode: focusNode,
|
|
onKeyEvent: (node, event) =>
|
|
_handleRailItemKey(event, onSelect: onToggle, onNavigateRight: widget.onNavigateToContent),
|
|
child: Material(
|
|
color: Colors.transparent,
|
|
child: InkWell(
|
|
canRequestFocus: false,
|
|
onTap: onToggle,
|
|
borderRadius: radius,
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
color: focusNode.hasFocus && InputModeTracker.isKeyboardMode(context)
|
|
? t.text.withValues(alpha: 0.08)
|
|
: null,
|
|
borderRadius: radius,
|
|
),
|
|
clipBehavior: Clip.hardEdge,
|
|
child: UnconstrainedBox(
|
|
alignment: .centerLeft,
|
|
constrainedAxis: Axis.vertical,
|
|
clipBehavior: Clip.hardEdge,
|
|
child: SizedBox(
|
|
width: expandedWidth - 24,
|
|
child: Padding(
|
|
padding: .symmetric(vertical: verticalPadding, horizontal: 17),
|
|
child: Row(
|
|
children: [
|
|
leading ?? AppIcon(icon, fill: 1, size: iconSize, color: t.textMuted),
|
|
const SizedBox(width: 11),
|
|
Expanded(
|
|
child: Text(label, style: labelStyle, overflow: .ellipsis),
|
|
),
|
|
AppIcon(
|
|
isExpanded ? Symbols.expand_less_rounded : Symbols.expand_more_rounded,
|
|
fill: 1,
|
|
size: 16,
|
|
color: t.textMuted,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildLibraryItem(_LibraryNavSection section, MediaLibrary library, dynamic t, {bool showServerName = false}) {
|
|
final isSelected =
|
|
widget.selectedTab == NavigationTabId.libraries && widget.selectedLibraryKey == library.globalKey;
|
|
final focusKey = _libraryItemFocusKey(section, library);
|
|
final focusNode = _focusTracker.get(focusKey);
|
|
|
|
return NavigationRailItem(
|
|
icon: ContentTypeHelper.getLibraryIcon(library.kind.id),
|
|
selectedIcon: ContentTypeHelper.getLibraryIcon(library.kind.id),
|
|
label: Column(
|
|
crossAxisAlignment: .start,
|
|
mainAxisSize: .min,
|
|
children: [
|
|
Text(
|
|
library.title,
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400,
|
|
color: isSelected ? t.text : t.textMuted,
|
|
),
|
|
overflow: .ellipsis,
|
|
),
|
|
if (showServerName)
|
|
Text(
|
|
library.serverName!,
|
|
style: TextStyle(fontSize: 9, color: t.textMuted.withValues(alpha: 0.4)),
|
|
overflow: .ellipsis,
|
|
),
|
|
],
|
|
),
|
|
isSelected: isSelected,
|
|
onTap: () => widget.onLibrarySelected(library.globalKey),
|
|
focusNode: focusNode,
|
|
iconSize: 18,
|
|
expandedHeight: 40,
|
|
suppressSelectedBackground: widget.isSidebarFocused,
|
|
onNavigateRight: widget.onNavigateToContent,
|
|
);
|
|
}
|
|
}
|