The custom sidebar read as an ad-hoc widget: collapsed rows were the expanded layout clipped to the strip, the libraries tree used a staircase of indents and mixed row heights, and hover expansion pushed the whole content area sideways. Rebuild the rail on M3 Expressive geometry: collapsed destinations are icon-only 56x32 pills centered in an 80px strip (48px icon-only on TV), expanded rows are fixed-height destinations with a full-width stadium indicator, and the two layouts crossfade/lerp during the width morph instead of clipping. The libraries section is flattened into full-width destination rows under a chevron header at standard destination metrics, hover/touch expansion overlays content as a rounded, shadowed panel over a dimmed scrim instead of pushing it, and rail width, item morphs, content translate, and bleed all share one duration/curve.
1420 lines
56 KiB
Dart
1420 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,
|
|
}) {
|
|
return [
|
|
if (widget.isOfflineMode && widget.onReconnect != null) _kReconnect,
|
|
if (!widget.isOfflineMode) ...[
|
|
_kHome,
|
|
if (hasNowPlaying) _kNowPlaying,
|
|
_kLibraries,
|
|
if (_librariesExpanded) ...[
|
|
..._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;
|
|
|
|
final nextIndex = isDown ? currentIndex + 1 : currentIndex - 1;
|
|
if (nextIndex < 0 || nextIndex >= focusOrder.length) return KeyEventResult.handled;
|
|
|
|
final nextNode = _focusTracker.nodeFor(focusOrder[nextIndex]);
|
|
if (nextNode == null) return KeyEventResult.ignored;
|
|
|
|
_requestFocusAndReveal(nextNode);
|
|
return KeyEventResult.handled;
|
|
}
|
|
|
|
/// 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,
|
|
);
|
|
_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,
|
|
);
|
|
}
|
|
}
|