676 lines
21 KiB
Dart
676 lines
21 KiB
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 '../models/plex_library.dart';
|
|
import '../providers/hidden_libraries_provider.dart';
|
|
import '../providers/multi_server_provider.dart';
|
|
import '../services/fullscreen_state_manager.dart';
|
|
import '../services/storage_service.dart';
|
|
import '../theme/theme_helper.dart';
|
|
import '../i18n/strings.g.dart';
|
|
|
|
/// Reusable navigation rail item widget that handles focus, selection, and interaction
|
|
class NavigationRailItem extends StatelessWidget {
|
|
final IconData icon;
|
|
final IconData? selectedIcon;
|
|
final Widget label;
|
|
final bool isSelected;
|
|
final bool isFocused;
|
|
final VoidCallback onTap;
|
|
final FocusNode focusNode;
|
|
final bool autofocus;
|
|
final EdgeInsets padding;
|
|
final BorderRadius borderRadius;
|
|
final double iconSize;
|
|
|
|
const NavigationRailItem({
|
|
super.key,
|
|
required this.icon,
|
|
this.selectedIcon,
|
|
required this.label,
|
|
required this.isSelected,
|
|
required this.isFocused,
|
|
required this.onTap,
|
|
required this.focusNode,
|
|
this.autofocus = false,
|
|
this.padding = const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
|
this.borderRadius = const BorderRadius.all(Radius.circular(12)),
|
|
this.iconSize = 22,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final t = tokens(context);
|
|
|
|
return Focus(
|
|
focusNode: focusNode,
|
|
autofocus: autofocus,
|
|
onKeyEvent: (node, event) {
|
|
if (event is! KeyDownEvent) return KeyEventResult.ignored;
|
|
if (event.logicalKey.isSelectKey) {
|
|
onTap();
|
|
return KeyEventResult.handled;
|
|
}
|
|
return KeyEventResult.ignored;
|
|
},
|
|
child: Material(
|
|
color: Colors.transparent,
|
|
child: InkWell(
|
|
onTap: onTap,
|
|
borderRadius: borderRadius,
|
|
child: Container(
|
|
padding: padding,
|
|
decoration: BoxDecoration(
|
|
color: isSelected && isFocused
|
|
? t.text.withValues(alpha: 0.15) // Selected + focused
|
|
: isSelected
|
|
? t.text.withValues(alpha: 0.1) // Just selected
|
|
: isFocused
|
|
? t.text.withValues(
|
|
alpha: 0.12,
|
|
) // Just focused (more visible)
|
|
: null,
|
|
borderRadius: borderRadius,
|
|
),
|
|
child: Row(
|
|
children: [
|
|
AppIcon(
|
|
isSelected && selectedIcon != null ? selectedIcon! : icon,
|
|
fill: 1,
|
|
size: iconSize,
|
|
color: isSelected ? t.text : t.textMuted,
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(child: label),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Side navigation rail for Desktop and Android TV platforms
|
|
class SideNavigationRail extends StatefulWidget {
|
|
final int selectedIndex;
|
|
final String? selectedLibraryKey;
|
|
final bool isOfflineMode;
|
|
final ValueChanged<int> onDestinationSelected;
|
|
final ValueChanged<String> onLibrarySelected;
|
|
|
|
const SideNavigationRail({
|
|
super.key,
|
|
required this.selectedIndex,
|
|
this.selectedLibraryKey,
|
|
this.isOfflineMode = false,
|
|
required this.onDestinationSelected,
|
|
required this.onLibrarySelected,
|
|
});
|
|
|
|
@override
|
|
State<SideNavigationRail> createState() => SideNavigationRailState();
|
|
}
|
|
|
|
class SideNavigationRailState extends State<SideNavigationRail> {
|
|
bool _librariesExpanded = true;
|
|
List<PlexLibrary> _libraries = [];
|
|
bool _isLoadingLibraries = true;
|
|
|
|
// Focus nodes for main nav items
|
|
late FocusNode _homeFocusNode;
|
|
late FocusNode _librariesFocusNode;
|
|
late FocusNode _searchFocusNode;
|
|
late FocusNode _downloadsFocusNode;
|
|
late FocusNode _settingsFocusNode;
|
|
|
|
// Focus state tracking
|
|
bool _isHomeFocused = false;
|
|
bool _isLibrariesFocused = false;
|
|
bool _isSearchFocused = false;
|
|
bool _isDownloadsFocused = false;
|
|
bool _isSettingsFocused = false;
|
|
|
|
// Map to store library item focus nodes and states
|
|
final Map<String, FocusNode> _libraryFocusNodes = {};
|
|
final Set<String> _focusedLibraryKeys = {};
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_homeFocusNode = FocusNode(debugLabel: 'nav_home');
|
|
_librariesFocusNode = FocusNode(debugLabel: 'nav_libraries');
|
|
_searchFocusNode = FocusNode(debugLabel: 'nav_search');
|
|
_downloadsFocusNode = FocusNode(debugLabel: 'nav_downloads');
|
|
_settingsFocusNode = FocusNode(debugLabel: 'nav_settings');
|
|
|
|
_homeFocusNode.addListener(
|
|
() => _onFocusChange(_homeFocusNode, () {
|
|
setState(() => _isHomeFocused = _homeFocusNode.hasFocus);
|
|
}),
|
|
);
|
|
_librariesFocusNode.addListener(
|
|
() => _onFocusChange(_librariesFocusNode, () {
|
|
setState(() => _isLibrariesFocused = _librariesFocusNode.hasFocus);
|
|
}),
|
|
);
|
|
_searchFocusNode.addListener(
|
|
() => _onFocusChange(_searchFocusNode, () {
|
|
setState(() => _isSearchFocused = _searchFocusNode.hasFocus);
|
|
}),
|
|
);
|
|
_downloadsFocusNode.addListener(
|
|
() => _onFocusChange(_downloadsFocusNode, () {
|
|
setState(() => _isDownloadsFocused = _downloadsFocusNode.hasFocus);
|
|
}),
|
|
);
|
|
_settingsFocusNode.addListener(
|
|
() => _onFocusChange(_settingsFocusNode, () {
|
|
setState(() => _isSettingsFocused = _settingsFocusNode.hasFocus);
|
|
}),
|
|
);
|
|
|
|
_loadLibraries();
|
|
}
|
|
|
|
void _onFocusChange(FocusNode node, VoidCallback updateState) {
|
|
if (mounted) updateState();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_homeFocusNode.dispose();
|
|
_librariesFocusNode.dispose();
|
|
_searchFocusNode.dispose();
|
|
_downloadsFocusNode.dispose();
|
|
_settingsFocusNode.dispose();
|
|
for (final node in _libraryFocusNodes.values) {
|
|
node.dispose();
|
|
}
|
|
super.dispose();
|
|
}
|
|
|
|
/// Get or create a focus node for a library item
|
|
FocusNode _getLibraryFocusNode(String globalKey) {
|
|
return _libraryFocusNodes.putIfAbsent(globalKey, () {
|
|
final node = FocusNode(debugLabel: 'nav_library_$globalKey');
|
|
node.addListener(() {
|
|
if (mounted) {
|
|
setState(() {
|
|
if (node.hasFocus) {
|
|
_focusedLibraryKeys.add(globalKey);
|
|
} else {
|
|
_focusedLibraryKeys.remove(globalKey);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
return node;
|
|
});
|
|
}
|
|
|
|
/// Focus the currently selected nav item
|
|
void focusActiveItem() {
|
|
if (widget.selectedLibraryKey != null) {
|
|
// A library is selected - focus that library item
|
|
final node = _libraryFocusNodes[widget.selectedLibraryKey];
|
|
node?.requestFocus();
|
|
} else {
|
|
// Focus main nav item based on selectedIndex
|
|
switch (widget.selectedIndex) {
|
|
case 0:
|
|
_homeFocusNode.requestFocus();
|
|
break;
|
|
case 1:
|
|
_librariesFocusNode.requestFocus();
|
|
break;
|
|
case 2:
|
|
_searchFocusNode.requestFocus();
|
|
break;
|
|
case 3:
|
|
_downloadsFocusNode.requestFocus();
|
|
break;
|
|
case 4:
|
|
_settingsFocusNode.requestFocus();
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _loadLibraries() async {
|
|
final multiServerProvider = context.read<MultiServerProvider>();
|
|
|
|
if (!multiServerProvider.hasConnectedServers) {
|
|
setState(() {
|
|
_isLoadingLibraries = false;
|
|
});
|
|
return;
|
|
}
|
|
|
|
try {
|
|
final libraries = await multiServerProvider.aggregationService
|
|
.getLibrariesFromAllServers();
|
|
|
|
// Filter out music libraries (not supported)
|
|
var filteredLibraries = libraries
|
|
.where((lib) => lib.type != 'artist')
|
|
.toList();
|
|
|
|
// Apply saved library order
|
|
final storage = await StorageService.getInstance();
|
|
final savedOrder = storage.getLibraryOrder();
|
|
if (savedOrder != null && savedOrder.isNotEmpty) {
|
|
final libraryMap = {
|
|
for (var lib in filteredLibraries) lib.globalKey: lib,
|
|
};
|
|
final orderedLibraries = <PlexLibrary>[];
|
|
for (final key in savedOrder) {
|
|
if (libraryMap.containsKey(key)) {
|
|
orderedLibraries.add(libraryMap[key]!);
|
|
libraryMap.remove(key);
|
|
}
|
|
}
|
|
// Add any new libraries not in saved order
|
|
orderedLibraries.addAll(libraryMap.values);
|
|
filteredLibraries = orderedLibraries;
|
|
}
|
|
|
|
if (mounted) {
|
|
setState(() {
|
|
_libraries = filteredLibraries;
|
|
_isLoadingLibraries = false;
|
|
});
|
|
}
|
|
} catch (e) {
|
|
if (mounted) {
|
|
setState(() {
|
|
_isLoadingLibraries = false;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Reload libraries (called when servers change)
|
|
void reloadLibraries() {
|
|
setState(() {
|
|
_isLoadingLibraries = true;
|
|
});
|
|
_loadLibraries();
|
|
}
|
|
|
|
IconData _getLibraryIcon(String type) {
|
|
switch (type.toLowerCase()) {
|
|
case 'movie':
|
|
return Symbols.movie_rounded;
|
|
case 'show':
|
|
return Symbols.tv_rounded;
|
|
case 'artist':
|
|
return Symbols.music_note_rounded;
|
|
case 'photo':
|
|
return Symbols.photo_rounded;
|
|
default:
|
|
return Symbols.folder_rounded;
|
|
}
|
|
}
|
|
|
|
IconData _getLibraryIconFilled(String type) {
|
|
switch (type.toLowerCase()) {
|
|
case 'movie':
|
|
return Symbols.movie_rounded;
|
|
case 'show':
|
|
return Symbols.tv_rounded;
|
|
case 'artist':
|
|
return Symbols.music_note_rounded;
|
|
case 'photo':
|
|
return Symbols.photo_rounded;
|
|
default:
|
|
return Symbols.folder_rounded;
|
|
}
|
|
}
|
|
|
|
/// Calculate top padding for macOS traffic lights
|
|
double _getTopPadding(BuildContext context) {
|
|
double basePadding = MediaQuery.of(context).padding.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 hiddenLibrariesProvider = context.watch<HiddenLibrariesProvider>();
|
|
final hiddenKeys = hiddenLibrariesProvider.hiddenLibraryKeys;
|
|
|
|
// Filter visible libraries
|
|
final visibleLibraries = _libraries
|
|
.where((lib) => !hiddenKeys.contains(lib.globalKey))
|
|
.toList();
|
|
|
|
// Listen to fullscreen changes for macOS
|
|
return ListenableBuilder(
|
|
listenable: FullscreenStateManager(),
|
|
builder: (context, _) {
|
|
return Container(
|
|
width: 220,
|
|
color: t.surface,
|
|
child: Column(
|
|
children: [
|
|
// Safe area for status bar and macOS traffic lights
|
|
SizedBox(height: _getTopPadding(context)),
|
|
|
|
// Navigation content
|
|
Expanded(
|
|
child: ListView(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
|
children: [
|
|
// In offline mode, only show Downloads and Settings
|
|
if (!widget.isOfflineMode) ...[
|
|
// Home
|
|
_buildNavItem(
|
|
icon: Symbols.home_rounded,
|
|
selectedIcon: Symbols.home_rounded,
|
|
label: Translations.of(context).navigation.home,
|
|
isSelected: widget.selectedIndex == 0,
|
|
isFocused: _isHomeFocused,
|
|
onTap: () => widget.onDestinationSelected(0),
|
|
focusNode: _homeFocusNode,
|
|
),
|
|
|
|
const SizedBox(height: 8),
|
|
|
|
// Libraries section
|
|
_buildLibrariesSection(visibleLibraries, t),
|
|
|
|
const SizedBox(height: 8),
|
|
|
|
// Search
|
|
_buildNavItem(
|
|
icon: Symbols.search_rounded,
|
|
selectedIcon: Symbols.search_rounded,
|
|
label: Translations.of(context).navigation.search,
|
|
isSelected: widget.selectedIndex == 2,
|
|
isFocused: _isSearchFocused,
|
|
onTap: () => widget.onDestinationSelected(2),
|
|
focusNode: _searchFocusNode,
|
|
),
|
|
|
|
const SizedBox(height: 8),
|
|
],
|
|
|
|
// Downloads (index 0 in offline mode, 3 in online mode)
|
|
_buildNavItem(
|
|
icon: Symbols.download_rounded,
|
|
selectedIcon: Symbols.download_rounded,
|
|
label: Translations.of(context).navigation.downloads,
|
|
isSelected: widget.isOfflineMode
|
|
? widget.selectedIndex == 0
|
|
: widget.selectedIndex == 3,
|
|
isFocused: _isDownloadsFocused,
|
|
onTap: () => widget.onDestinationSelected(
|
|
widget.isOfflineMode ? 0 : 3,
|
|
),
|
|
focusNode: _downloadsFocusNode,
|
|
),
|
|
|
|
const SizedBox(height: 8),
|
|
|
|
// Settings (index 1 in offline mode, 4 in online mode)
|
|
_buildNavItem(
|
|
icon: Symbols.settings_rounded,
|
|
selectedIcon: Symbols.settings_rounded,
|
|
label: Translations.of(context).navigation.settings,
|
|
isSelected: widget.isOfflineMode
|
|
? widget.selectedIndex == 1
|
|
: widget.selectedIndex == 4,
|
|
isFocused: _isSettingsFocused,
|
|
onTap: () => widget.onDestinationSelected(
|
|
widget.isOfflineMode ? 1 : 4,
|
|
),
|
|
focusNode: _settingsFocusNode,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildNavItem({
|
|
required IconData icon,
|
|
required IconData selectedIcon,
|
|
required String label,
|
|
required bool isSelected,
|
|
required bool isFocused,
|
|
required VoidCallback onTap,
|
|
required FocusNode focusNode,
|
|
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,
|
|
),
|
|
),
|
|
isSelected: isSelected,
|
|
isFocused: isFocused,
|
|
onTap: onTap,
|
|
focusNode: focusNode,
|
|
autofocus: autofocus,
|
|
);
|
|
}
|
|
|
|
Widget _buildLibrariesSection(List<PlexLibrary> visibleLibraries, dynamic t) {
|
|
final isLibrariesSelected =
|
|
widget.selectedIndex == 1 && widget.selectedLibraryKey == null;
|
|
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// Libraries header with expand/collapse
|
|
Focus(
|
|
focusNode: _librariesFocusNode,
|
|
onKeyEvent: (node, event) {
|
|
if (event is! KeyDownEvent) return KeyEventResult.ignored;
|
|
if (event.logicalKey.isSelectKey) {
|
|
setState(() {
|
|
_librariesExpanded = !_librariesExpanded;
|
|
});
|
|
return KeyEventResult.handled;
|
|
}
|
|
return KeyEventResult.ignored;
|
|
},
|
|
child: Material(
|
|
color: Colors.transparent,
|
|
child: InkWell(
|
|
onTap: () {
|
|
setState(() {
|
|
_librariesExpanded = !_librariesExpanded;
|
|
});
|
|
},
|
|
borderRadius: BorderRadius.circular(12),
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 16,
|
|
vertical: 12,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: isLibrariesSelected
|
|
? t.text.withValues(alpha: 0.1)
|
|
: _isLibrariesFocused
|
|
? t.text.withValues(alpha: 0.08)
|
|
: null,
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
AppIcon(
|
|
widget.selectedIndex == 1
|
|
? Symbols.video_library_rounded
|
|
: Symbols.video_library_rounded,
|
|
fill: 1,
|
|
size: 22,
|
|
color: widget.selectedIndex == 1 ? t.text : t.textMuted,
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Text(
|
|
Translations.of(context).navigation.libraries,
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
fontWeight: widget.selectedIndex == 1
|
|
? FontWeight.w600
|
|
: FontWeight.w400,
|
|
color: widget.selectedIndex == 1
|
|
? t.text
|
|
: t.textMuted,
|
|
),
|
|
),
|
|
),
|
|
AppIcon(
|
|
_librariesExpanded
|
|
? Symbols.expand_less_rounded
|
|
: Symbols.expand_more_rounded,
|
|
fill: 1,
|
|
size: 20,
|
|
color: t.textMuted,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
|
|
// Library items
|
|
if (_librariesExpanded)
|
|
_isLoadingLibraries
|
|
? Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Center(
|
|
child: SizedBox(
|
|
width: 20,
|
|
height: 20,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2,
|
|
color: t.textMuted,
|
|
),
|
|
),
|
|
),
|
|
)
|
|
: visibleLibraries.isEmpty
|
|
? Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Text(
|
|
Translations.of(context).libraries.noLibrariesFound,
|
|
style: TextStyle(fontSize: 12, color: t.textMuted),
|
|
),
|
|
)
|
|
: _buildLibraryItems(visibleLibraries, t),
|
|
],
|
|
);
|
|
}
|
|
|
|
/// Get set of library names that appear more than once (not globally unique)
|
|
Set<String> _getNonUniqueLibraryNames(List<PlexLibrary> 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 _buildLibraryItems(List<PlexLibrary> visibleLibraries, dynamic t) {
|
|
// Find which library names are not unique
|
|
final nonUniqueNames = _getNonUniqueLibraryNames(visibleLibraries);
|
|
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: visibleLibraries.map((library) {
|
|
final showServerName =
|
|
nonUniqueNames.contains(library.title) &&
|
|
library.serverName != null;
|
|
return _buildLibraryItem(library, t, showServerName: showServerName);
|
|
}).toList(),
|
|
);
|
|
}
|
|
|
|
Widget _buildLibraryItem(
|
|
PlexLibrary library,
|
|
dynamic t, {
|
|
bool showServerName = false,
|
|
}) {
|
|
final isSelected =
|
|
widget.selectedIndex == 1 &&
|
|
widget.selectedLibraryKey == library.globalKey;
|
|
final isFocused = _focusedLibraryKeys.contains(library.globalKey);
|
|
final focusNode = _getLibraryFocusNode(library.globalKey);
|
|
|
|
return NavigationRailItem(
|
|
icon: _getLibraryIcon(library.type),
|
|
selectedIcon: _getLibraryIconFilled(library.type),
|
|
label: SizedBox(
|
|
height: 32, // Fixed height for consistent item sizing
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Text(
|
|
library.title,
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400,
|
|
color: isSelected ? t.text : t.textMuted,
|
|
),
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
if (showServerName)
|
|
Text(
|
|
library.serverName!,
|
|
style: TextStyle(
|
|
fontSize: 9,
|
|
color: t.textMuted.withValues(alpha: 0.4),
|
|
),
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
isSelected: isSelected,
|
|
isFocused: isFocused,
|
|
onTap: () => widget.onLibrarySelected(library.globalKey),
|
|
focusNode: focusNode,
|
|
padding: const EdgeInsets.only(left: 28, right: 12, top: 10, bottom: 10),
|
|
borderRadius: BorderRadius.circular(8),
|
|
iconSize: 18,
|
|
);
|
|
}
|
|
}
|