@@ -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
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user