feat(libraries): add nav quick picker

close #1144
This commit is contained in:
edde746
2026-05-27 06:52:03 +02:00
parent 56cfb95af5
commit e45bfadc14
4 changed files with 369 additions and 15 deletions
+3 -1
View File
@@ -71,8 +71,9 @@ class ContextMenuItem {
class LibrariesScreen extends StatefulWidget {
final VoidCallback? onLibraryOrderChanged;
final ValueChanged<String>? onLibrarySelected;
const LibrariesScreen({super.key, this.onLibraryOrderChanged});
const LibrariesScreen({super.key, this.onLibraryOrderChanged, this.onLibrarySelected});
@override
State<LibrariesScreen> createState() => _LibrariesScreenState();
@@ -469,6 +470,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
// Clear loaded tabs tracking for new library
_loadedTabs.clear();
});
widget.onLibrarySelected?.call(libraryGlobalKey);
// The new TabBarView mounts with fresh inner positions at offset 0;
// bring the floating header back too. Also covers the case where the
@@ -0,0 +1,167 @@
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../i18n/strings.g.dart';
import '../../media/media_library.dart';
import '../../utils/content_utils.dart';
import '../../utils/library_grouping.dart';
import '../../widgets/app_icon.dart';
import '../../widgets/backend_badge.dart';
import '../../widgets/focusable_list_tile.dart';
class LibraryQuickPickerSheet extends StatelessWidget {
final List<MediaLibrary> libraries;
final String? selectedLibraryKey;
final bool isLoading;
final bool groupByServer;
final String emptyMessage;
final ValueChanged<String> onSelected;
const LibraryQuickPickerSheet({
super.key,
required this.libraries,
required this.selectedLibraryKey,
required this.isLoading,
required this.groupByServer,
required this.emptyMessage,
required this.onSelected,
});
bool get _showServerHeaders {
final serverIds = libraries.where((library) => library.serverId != null).map((library) => library.serverId).toSet();
return serverIds.length > 1 && groupByServer;
}
Set<String> _getNonUniqueLibraryNames() {
final nameCounts = <String, int>{};
for (final library in libraries) {
nameCounts[library.title] = (nameCounts[library.title] ?? 0) + 1;
}
return nameCounts.entries.where((entry) => entry.value > 1).map((entry) => entry.key).toSet();
}
List<Widget> _buildLibraryRows(BuildContext context) {
if (!_showServerHeaders) {
final nonUniqueNames = _getNonUniqueLibraryNames();
return libraries.map((library) {
return _buildLibraryTile(
context,
library,
showServerName: library.serverName != null && nonUniqueNames.contains(library.title),
);
}).toList();
}
final grouped = groupLibrariesByFirstAppearance(libraries);
final rows = <Widget>[];
for (final serverKey in grouped.serverOrder) {
final bucket = grouped.byServer[serverKey]!;
if (serverKey.isNotEmpty) {
rows.add(_buildServerHeader(context, bucket.first, serverKey));
}
for (final library in bucket) {
rows.add(_buildLibraryTile(context, library, showServerName: false));
}
}
return rows;
}
Widget _buildServerHeader(BuildContext context, MediaLibrary library, String fallbackServerName) {
final theme = Theme.of(context);
final labelStyle = theme.textTheme.labelSmall?.copyWith(
fontWeight: FontWeight.w600,
letterSpacing: 0.4,
color: theme.textTheme.bodySmall?.color?.withValues(alpha: 0.65),
);
return Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Row(
children: [
BackendBadge(backend: library.backend, size: 12, color: labelStyle?.color),
const SizedBox(width: 6),
Expanded(
child: Text(
library.serverName ?? fallbackServerName,
style: labelStyle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
);
}
Widget _buildServerSubtitle(BuildContext context, MediaLibrary library) {
final style = Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: Theme.of(context).textTheme.bodySmall?.color?.withValues(alpha: 0.6));
return Row(
mainAxisSize: MainAxisSize.min,
children: [
BackendBadge(backend: library.backend, size: 10, color: style?.color),
const SizedBox(width: 4),
Flexible(
child: Text(library.serverName!, style: style, maxLines: 1, overflow: TextOverflow.ellipsis),
),
],
);
}
Widget _buildLibraryTile(BuildContext context, MediaLibrary library, {required bool showServerName}) {
final colorScheme = Theme.of(context).colorScheme;
final isSelected = library.globalKey == selectedLibraryKey;
final foregroundColor = isSelected ? colorScheme.primary : null;
return FocusableListTile(
key: ValueKey('library_quick_picker_${library.globalKey}'),
dense: false,
visualDensity: VisualDensity.standard,
selected: isSelected,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 2),
leading: AppIcon(ContentTypeHelper.getLibraryIcon(library.kind.id), fill: 1, size: 22, color: foregroundColor),
title: Text(
library.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400, color: foregroundColor),
),
subtitle: showServerName ? _buildServerSubtitle(context, library) : null,
trailing: isSelected ? AppIcon(Symbols.check_rounded, fill: 1, color: colorScheme.primary) : null,
onTap: () => onSelected(library.globalKey),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 8),
child: Align(
alignment: Alignment.centerLeft,
child: Text(t.libraries.selectLibrary, style: theme.textTheme.titleMedium),
),
),
if (isLoading && libraries.isEmpty)
const Padding(padding: EdgeInsets.symmetric(vertical: 32), child: CircularProgressIndicator())
else if (libraries.isEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 32),
child: Text(emptyMessage, textAlign: TextAlign.center, style: theme.textTheme.bodyMedium),
)
else
Flexible(
child: ListView(
shrinkWrap: true,
padding: const EdgeInsets.only(bottom: 8),
children: _buildLibraryRows(context),
),
),
],
);
}
}
+106 -14
View File
@@ -47,6 +47,7 @@ import '../widgets/side_navigation_rail.dart';
import '../focus/dpad_navigator.dart';
import '../focus/key_event_utils.dart';
import 'discover_screen.dart';
import 'libraries/library_quick_picker_sheet.dart';
import 'libraries/libraries_screen.dart';
import 'livetv/live_tv_screen.dart';
import 'search_screen.dart';
@@ -890,6 +891,7 @@ class _MainScreenState extends State<MainScreen>
NavigationTabId.libraries => LibrariesScreen(
key: _librariesKey,
onLibraryOrderChanged: _onLibraryOrderChanged,
onLibrarySelected: _handleLibrariesScreenSelected,
),
NavigationTabId.liveTv => LiveTvScreen(key: _liveTvKey),
NavigationTabId.search => SearchScreen(key: _searchKey),
@@ -1396,6 +1398,63 @@ class _MainScreenState extends State<MainScreen>
}
}
void _handleLibrariesScreenSelected(String libraryGlobalKey) {
if (_selectedLibraryGlobalKey == libraryGlobalKey) return;
setState(() => _selectedLibraryGlobalKey = libraryGlobalKey);
}
void _showLibraryQuickPicker(BuildContext context) {
if (_isOffline) return;
final controller = OverlaySheetController.of(context);
final groupByServer = SettingsService.instanceOrNull?.read(SettingsService.groupLibrariesByServer) ?? false;
final maxHeight = MediaQuery.sizeOf(context).height * 0.62;
controller
.show<String>(
showDragHandle: true,
constraints: BoxConstraints(maxHeight: maxHeight),
builder: (sheetContext) {
return Consumer2<LibrariesProvider, HiddenLibrariesProvider>(
builder: (context, librariesProvider, hiddenLibrariesProvider, _) {
if (!hiddenLibrariesProvider.isInitialized) {
return LibraryQuickPickerSheet(
libraries: const [],
selectedLibraryKey: _selectedLibraryGlobalKey,
isLoading: true,
groupByServer: groupByServer,
emptyMessage: t.libraries.noLibrariesFound,
onSelected: (libraryGlobalKey) => controller.close(libraryGlobalKey),
);
}
final allLibraries = librariesProvider.libraries;
final hiddenKeys = hiddenLibrariesProvider.hiddenLibraryKeys;
final visibleLibraries = allLibraries
.where((library) => !hiddenKeys.contains(library.globalKey))
.toList();
final emptyMessage = allLibraries.isEmpty
? t.libraries.noLibrariesFound
: t.libraries.allLibrariesHidden;
return LibraryQuickPickerSheet(
libraries: visibleLibraries,
selectedLibraryKey: _selectedLibraryGlobalKey,
isLoading: librariesProvider.isLoading,
groupByServer: groupByServer,
emptyMessage: emptyMessage,
onSelected: (libraryGlobalKey) => controller.close(libraryGlobalKey),
);
},
);
},
)
.then((libraryGlobalKey) {
if (!mounted || libraryGlobalKey == null) return;
_selectLibrary(libraryGlobalKey);
});
}
/// Whether the Live TV tab is currently visible
/// Use the synchronized value so screens list and nav bar always agree.
/// Updated by _handleLiveTvChanged when the provider notifies.
@@ -1418,9 +1477,52 @@ class _MainScreenState extends State<MainScreen>
};
}
/// Build navigation destinations for bottom navigation bar.
List<NavigationDestination> _buildNavDestinations(bool isOffline) {
return _getVisibleTabs(isOffline).map((tab) => tab.toDestination()).toList();
Widget _buildBottomNavigationBar(BuildContext context, {required bool hideLabels}) {
final tabs = _getVisibleTabs(_isOffline);
final navigationBar = NavigationBar(
selectedIndex: _currentIndex,
onDestinationSelected: (i) {
if (i >= 0 && i < tabs.length) _selectTab(tabs[i].id);
},
labelBehavior: hideLabels
? NavigationDestinationLabelBehavior.alwaysHide
: NavigationDestinationLabelBehavior.alwaysShow,
destinations: tabs.map((tab) => tab.toDestination()).toList(),
);
final librariesIndex = tabs.indexWhere((tab) => tab.id == NavigationTabId.libraries);
if (librariesIndex < 0 || tabs.isEmpty) return navigationBar;
return LayoutBuilder(
builder: (context, constraints) {
if (!constraints.hasBoundedWidth) return navigationBar;
final itemWidth = constraints.maxWidth / tabs.length;
final isRtl = Directionality.of(context) == TextDirection.rtl;
final left = isRtl ? constraints.maxWidth - (itemWidth * (librariesIndex + 1)) : itemWidth * librariesIndex;
return Stack(
children: [
navigationBar,
Positioned(
left: left,
top: 0,
bottom: 0,
width: itemWidth,
child: GestureDetector(
behavior: HitTestBehavior.translucent,
excludeFromSemantics: true,
onLongPress: () {
Feedback.forLongPress(context);
_showLibraryQuickPicker(context);
},
child: const SizedBox.expand(),
),
),
],
);
},
);
}
@override
@@ -1595,17 +1697,7 @@ class _MainScreenState extends State<MainScreen>
final hideLabels = !showNavBarLabels;
return NavigationBarTheme(
data: NavigationBarTheme.of(context).copyWith(height: hideLabels ? 56 : null),
child: NavigationBar(
selectedIndex: _currentIndex,
onDestinationSelected: (i) {
final tabs = _getVisibleTabs(_isOffline);
if (i >= 0 && i < tabs.length) _selectTab(tabs[i].id);
},
labelBehavior: hideLabels
? NavigationDestinationLabelBehavior.alwaysHide
: NavigationDestinationLabelBehavior.alwaysShow,
destinations: _buildNavDestinations(_isOffline),
),
child: _buildBottomNavigationBar(context, hideLabels: hideLabels),
);
},
),
@@ -0,0 +1,93 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/media/media_library.dart';
import 'package:plezy/screens/libraries/library_quick_picker_sheet.dart';
void main() {
testWidgets('groups libraries by server and reports selection', (tester) async {
final libraries = [
const MediaLibrary(
id: '1',
backend: MediaBackend.plex,
title: 'Movies',
kind: MediaKind.movie,
serverId: 'plex-server',
serverName: 'Plex Server',
),
const MediaLibrary(
id: '2',
backend: MediaBackend.jellyfin,
title: 'Shows',
kind: MediaKind.show,
serverId: 'jellyfin-server',
serverName: 'Jellyfin Server',
),
];
String? selectedKey;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: LibraryQuickPickerSheet(
libraries: libraries,
selectedLibraryKey: libraries.last.globalKey,
isLoading: false,
groupByServer: true,
emptyMessage: 'No libraries',
onSelected: (key) => selectedKey = key,
),
),
),
);
expect(find.text('Plex Server'), findsOneWidget);
expect(find.text('Jellyfin Server'), findsOneWidget);
expect(find.text('Movies'), findsOneWidget);
expect(find.text('Shows'), findsOneWidget);
await tester.tap(find.text('Movies'));
expect(selectedKey, libraries.first.globalKey);
});
testWidgets('shows duplicate library server subtitles without grouping', (tester) async {
final libraries = [
const MediaLibrary(
id: '1',
backend: MediaBackend.plex,
title: 'Movies',
kind: MediaKind.movie,
serverId: 'plex-server',
serverName: 'Plex Server',
),
const MediaLibrary(
id: '2',
backend: MediaBackend.jellyfin,
title: 'Movies',
kind: MediaKind.movie,
serverId: 'jellyfin-server',
serverName: 'Jellyfin Server',
),
];
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: LibraryQuickPickerSheet(
libraries: libraries,
selectedLibraryKey: null,
isLoading: false,
groupByServer: false,
emptyMessage: 'No libraries',
onSelected: (_) {},
),
),
),
);
expect(find.text('Movies'), findsNWidgets(2));
expect(find.text('Plex Server'), findsOneWidget);
expect(find.text('Jellyfin Server'), findsOneWidget);
});
}