Focus chrome was implemented twice, once in the focusable wrapper and once in the focus builders; both now go through FocusChrome. TvColorPicker's channel row was a copy of TvNumberSpinner and is now that widget in compact density. Also trims unused helpers and fields and simplifies the Jellyfin browse paths.
1273 lines
49 KiB
Dart
1273 lines
49 KiB
Dart
import 'dart:async';
|
|
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
|
|
class NavigationRailItem extends StatelessWidget {
|
|
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;
|
|
|
|
/// Widget rendered after the [label] (e.g. a section header's chevron).
|
|
final Widget? trailing;
|
|
final bool isSelected;
|
|
final bool isCollapsed;
|
|
final bool useSimpleLayout;
|
|
final VoidCallback onTap;
|
|
final FocusNode focusNode;
|
|
final bool autofocus;
|
|
final BorderRadius borderRadius;
|
|
final double iconSize;
|
|
final double horizontalPadding;
|
|
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.trailing,
|
|
required this.isSelected,
|
|
this.isCollapsed = false,
|
|
this.useSimpleLayout = false,
|
|
required this.onTap,
|
|
required this.focusNode,
|
|
this.autofocus = false,
|
|
this.borderRadius = const BorderRadius.all(Radius.circular(12)),
|
|
this.iconSize = 22,
|
|
this.horizontalPadding = 17,
|
|
this.suppressSelectedBackground = false,
|
|
this.focusAlpha = 0.12,
|
|
this.selectedFocusAlpha = 0.15,
|
|
this.onNavigateRight,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final t = tokens(context);
|
|
final showSelectedBackground = isSelected && !suppressSelectedBackground;
|
|
|
|
return ListenableBuilder(
|
|
listenable: focusNode,
|
|
builder: (context, _) {
|
|
final focused = focusNode.hasFocus && InputModeTracker.isKeyboardMode(context);
|
|
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,
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
color: () {
|
|
if (isCollapsed) return focused ? t.text.withValues(alpha: focusAlpha) : null;
|
|
if (focused) {
|
|
return t.text.withValues(alpha: showSelectedBackground ? selectedFocusAlpha : focusAlpha);
|
|
}
|
|
if (showSelectedBackground) return t.text.withValues(alpha: 0.1);
|
|
return null;
|
|
}(),
|
|
borderRadius: borderRadius,
|
|
),
|
|
clipBehavior: Clip.hardEdge,
|
|
child: UnconstrainedBox(
|
|
alignment: .centerLeft,
|
|
constrainedAxis: Axis.vertical,
|
|
clipBehavior: Clip.hardEdge,
|
|
child: SizedBox(
|
|
width: SideNavigationRailState.expandedWidth - 24,
|
|
child: Padding(
|
|
padding: .symmetric(vertical: 12, horizontal: horizontalPadding),
|
|
child: Row(
|
|
children: [
|
|
iconWidget ??
|
|
AppIcon(
|
|
isSelected && selectedIcon != null ? selectedIcon! : icon,
|
|
fill: 1,
|
|
size: iconSize,
|
|
color: isSelected ? t.text : t.textMuted,
|
|
),
|
|
const SizedBox(width: 11),
|
|
Expanded(
|
|
child: () {
|
|
if (useSimpleLayout) return label;
|
|
final opacity = isCollapsed ? 0.0 : 1.0;
|
|
return AnimatedOpacity(opacity: opacity, duration: t.fast, child: label);
|
|
}(),
|
|
),
|
|
?trailing,
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 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 hover/touch expansion changes, so the shell can reserve width.
|
|
final ValueChanged<bool>? onInteractionExpandedChanged;
|
|
|
|
/// Called when the user taps the reconnect button in offline mode.
|
|
final VoidCallback? onReconnect;
|
|
|
|
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 {
|
|
bool _librariesExpanded = true;
|
|
|
|
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 _itemHorizontalPadding = 17.0;
|
|
static const double _defaultIconSize = 22.0;
|
|
static const Duration _collapseDelay = Duration(milliseconds: 150);
|
|
|
|
static double collapsedWidthForContext(BuildContext _) => PlatformDetector.isTV() ? tvCollapsedWidth : collapsedWidth;
|
|
|
|
static double itemHorizontalPaddingForContext(BuildContext context, {required bool isCollapsed}) {
|
|
if (isCollapsed && PlatformDetector.isTV()) {
|
|
return ((tvCollapsedWidth - _defaultIconSize) / 2).clamp(0.0, _itemHorizontalPadding).toDouble();
|
|
}
|
|
return _itemHorizontalPadding;
|
|
}
|
|
|
|
static double horizontalPaddingForContext(BuildContext context, {required bool isCollapsed}) {
|
|
if (!isCollapsed) return _horizontalPadding;
|
|
final centeredPadding =
|
|
((collapsedWidthForContext(context) - _defaultIconSize) / 2) -
|
|
itemHorizontalPaddingForContext(context, isCollapsed: isCollapsed);
|
|
return centeredPadding.clamp(0.0, _horizontalPadding).toDouble();
|
|
}
|
|
|
|
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();
|
|
}
|
|
}
|
|
}
|
|
|
|
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 itemHorizontalPadding = itemHorizontalPaddingForContext(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 hasExplore = 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 + groupLibrariesByServer setting so the rail
|
|
// rebuilds when the user toggles "Group libraries by server" in Appearance.
|
|
return ListenableBuilder(
|
|
listenable: Listenable.merge([
|
|
FullscreenStateManager(),
|
|
SettingsService.instance.listenable(SettingsService.groupLibrariesByServer),
|
|
]),
|
|
builder: (context, _) {
|
|
// 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: t.normal,
|
|
curve: Curves.easeOutCubic,
|
|
width: isCollapsed ? effectiveCollapsedWidth : expandedWidth,
|
|
clipBehavior: Clip.hardEdge,
|
|
decoration: const BoxDecoration(),
|
|
child: Stack(
|
|
children: [
|
|
Positioned.fill(
|
|
child: AnimatedOpacity(
|
|
opacity: PlatformDetector.isTV() ? 0.0 : 1.0,
|
|
duration: t.normal,
|
|
curve: Curves.easeOutCubic,
|
|
child: ColoredBox(color: t.surface),
|
|
),
|
|
),
|
|
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: ListView(
|
|
padding: .symmetric(horizontal: horizontalPadding),
|
|
clipBehavior: Clip.hardEdge,
|
|
children: [
|
|
if (widget.isOfflineMode && widget.onReconnect != null) ...[
|
|
_buildReconnectItem(isCollapsed: isCollapsed),
|
|
const SizedBox(height: 8),
|
|
],
|
|
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: 8),
|
|
// Now Playing — only while a music session is live.
|
|
if (nowPlayingTrack != null && musicService != null) ...[
|
|
_buildNowPlayingItem(nowPlayingTrack, musicService, isCollapsed: isCollapsed),
|
|
const SizedBox(height: 8),
|
|
],
|
|
_buildLibrariesSection(
|
|
visibleRows,
|
|
hiddenRows,
|
|
hiddenLibraries.length,
|
|
t,
|
|
isCollapsed: isCollapsed,
|
|
itemHorizontalPadding: itemHorizontalPadding,
|
|
),
|
|
const SizedBox(height: 8),
|
|
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: 8),
|
|
],
|
|
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: 8),
|
|
],
|
|
_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: 8),
|
|
],
|
|
// 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: 8),
|
|
],
|
|
_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)
|
|
Padding(
|
|
padding: .fromLTRB(horizontalPadding, 0, horizontalPadding, 12),
|
|
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);
|
|
final itemHorizontalPadding = itemHorizontalPaddingForContext(context, isCollapsed: isCollapsed);
|
|
|
|
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,
|
|
),
|
|
isSelected: isSelected,
|
|
isCollapsed: isCollapsed,
|
|
onTap: onTap,
|
|
focusNode: focusNode,
|
|
autofocus: autofocus,
|
|
horizontalPadding: itemHorizontalPadding,
|
|
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);
|
|
final itemHorizontalPadding = itemHorizontalPaddingForContext(context, isCollapsed: isCollapsed);
|
|
|
|
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,
|
|
),
|
|
],
|
|
),
|
|
isSelected: false,
|
|
isCollapsed: isCollapsed,
|
|
useSimpleLayout: true,
|
|
onTap: () => unawaited(openNowPlaying(context)),
|
|
focusNode: _focusTracker.get(_kNowPlaying),
|
|
horizontalPadding: itemHorizontalPadding,
|
|
onNavigateRight: widget.onNavigateToContent,
|
|
);
|
|
}
|
|
|
|
Widget _buildReconnectItem({required bool isCollapsed}) {
|
|
final t = tokens(context);
|
|
final itemHorizontalPadding = itemHorizontalPaddingForContext(context, isCollapsed: isCollapsed);
|
|
|
|
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,
|
|
),
|
|
isSelected: false,
|
|
isCollapsed: isCollapsed,
|
|
// ignore: no-empty-block - no-op tap handler while reconnecting
|
|
onTap: widget.isReconnecting ? () {} : () => widget.onReconnect?.call(),
|
|
focusNode: _focusTracker.get(_kReconnect),
|
|
horizontalPadding: itemHorizontalPadding,
|
|
onNavigateRight: widget.onNavigateToContent,
|
|
);
|
|
}
|
|
|
|
Widget _buildFullscreenItem({required bool isCollapsed}) {
|
|
final t = tokens(context);
|
|
final isFullscreen = FullscreenStateManager().isFullscreen;
|
|
final itemHorizontalPadding = itemHorizontalPaddingForContext(context, isCollapsed: isCollapsed);
|
|
|
|
return NavigationRailItem(
|
|
icon: isFullscreen ? Symbols.fullscreen_exit_rounded : Symbols.fullscreen_rounded,
|
|
label: Text(
|
|
isFullscreen ? Translations.of(context).common.exitFullscreen : Translations.of(context).common.fullscreen,
|
|
style: TextStyle(fontSize: 14, fontWeight: .w400, color: t.textMuted),
|
|
overflow: .ellipsis,
|
|
maxLines: 1,
|
|
),
|
|
isSelected: false,
|
|
isCollapsed: isCollapsed,
|
|
onTap: () => unawaited(FullscreenStateManager().toggleFullscreen()),
|
|
focusNode: _focusTracker.get(_kFullscreen),
|
|
horizontalPadding: itemHorizontalPadding,
|
|
onNavigateRight: widget.onNavigateToContent,
|
|
);
|
|
}
|
|
|
|
Widget _buildLibrariesSection(
|
|
List<_LibraryNavRow> visibleRows,
|
|
List<_LibraryNavRow> hiddenRows,
|
|
int hiddenLibraryCount,
|
|
dynamic t, {
|
|
bool isCollapsed = false,
|
|
required double itemHorizontalPadding,
|
|
}) {
|
|
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: [
|
|
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,
|
|
),
|
|
),
|
|
trailing: AnimatedOpacity(
|
|
opacity: isCollapsed ? 0.0 : 1.0,
|
|
duration: tokens(context).fast,
|
|
child: AppIcon(
|
|
_librariesExpanded ? Symbols.expand_less_rounded : Symbols.expand_more_rounded,
|
|
fill: 1,
|
|
size: 20,
|
|
color: t.textMuted,
|
|
),
|
|
),
|
|
isSelected: isLibrariesTabSelected,
|
|
isCollapsed: isCollapsed,
|
|
onTap: () => setState(() => _librariesExpanded = !_librariesExpanded),
|
|
focusNode: _focusTracker.get(_kLibraries),
|
|
borderRadius: BorderRadius.circular(tokens(context).radiusMd),
|
|
horizontalPadding: itemHorizontalPadding,
|
|
// 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: tokens(context).normal,
|
|
curve: Curves.easeOutCubic,
|
|
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(tokens(context).radiusSm);
|
|
// Match library-item indent: outer Padding(left: 12) + inner horizontal 17.
|
|
return Padding(
|
|
padding: const EdgeInsets.only(left: 12),
|
|
child: 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 Padding(
|
|
padding: const EdgeInsets.only(left: 12),
|
|
child: 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,
|
|
useSimpleLayout: true,
|
|
onTap: () => widget.onLibrarySelected(library.globalKey),
|
|
focusNode: focusNode,
|
|
borderRadius: BorderRadius.circular(tokens(context).radiusSm),
|
|
iconSize: 18,
|
|
suppressSelectedBackground: widget.isSidebarFocused,
|
|
onNavigateRight: widget.onNavigateToContent,
|
|
),
|
|
);
|
|
}
|
|
}
|