fix: group mobile library dropdown by server

This commit is contained in:
edde746
2026-05-01 07:55:15 +02:00
parent d5a12c404a
commit f03c84e8dc
3 changed files with 136 additions and 68 deletions
+113 -46
View File
@@ -19,12 +19,15 @@ import '../../media/media_library.dart';
import '../../media/media_server_client.dart';
import '../../providers/hidden_libraries_provider.dart';
import '../../providers/libraries_provider.dart';
import '../../providers/settings_provider.dart';
import '../../utils/app_logger.dart';
import '../../utils/dialogs.dart';
import '../../utils/library_grouping.dart';
import '../../utils/platform_detector.dart';
import '../../utils/provider_extensions.dart';
import '../../utils/snackbar_helper.dart';
import '../../utils/content_utils.dart';
import '../../widgets/backend_badge.dart';
import '../../widgets/desktop_app_bar.dart';
import '../../widgets/overlay_sheet.dart';
import '../../services/storage_service.dart';
@@ -762,53 +765,114 @@ class _LibrariesScreenState extends State<LibrariesScreen>
return nameCounts.entries.where((e) => e.value > 1).map((e) => e.key).toSet();
}
/// Build dropdown menu items with server subtitle for non-unique names
List<PopupMenuEntry<String>> _buildGroupedLibraryMenuItems(List<MediaLibrary> visibleLibraries) {
// Find which library names are not unique
final nonUniqueNames = _getNonUniqueLibraryNames(visibleLibraries);
Widget _buildLibraryServerLabel(
MediaLibrary library,
TextStyle? style, {
double badgeSize = 11,
bool constrainText = false,
String? fallbackServerName,
}) {
final serverName = library.serverName ?? fallbackServerName;
if (serverName == null || serverName.isEmpty) return const SizedBox.shrink();
return visibleLibraries.map((library) {
final isSelected = library.globalKey == _selectedLibraryGlobalKey;
final showServerName = nonUniqueNames.contains(library.title) && library.serverName != null;
final text = Text(serverName, style: style, overflow: TextOverflow.ellipsis);
return Row(
mainAxisSize: MainAxisSize.min,
children: [
BackendBadge(backend: library.backend, size: badgeSize, color: style?.color),
const SizedBox(width: 4),
if (constrainText) Flexible(child: text) else text,
],
);
}
return PopupMenuItem<String>(
value: library.globalKey,
child: Row(
children: [
AppIcon(
ContentTypeHelper.getLibraryIcon(library.kind.id),
fill: 1,
size: 20,
color: isSelected ? Theme.of(context).colorScheme.primary : null,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
library.title,
style: TextStyle(
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400,
color: isSelected ? Theme.of(context).colorScheme.primary : null,
),
PopupMenuItem<String> _buildLibraryServerHeaderMenuItem(MediaLibrary library, String serverKey) {
final style = Theme.of(context).textTheme.labelSmall?.copyWith(
fontWeight: FontWeight.w600,
letterSpacing: 0.4,
color: Theme.of(context).textTheme.bodySmall?.color?.withValues(alpha: 0.65),
);
return PopupMenuItem<String>(
enabled: false,
height: 32,
child: _buildLibraryServerLabel(
library,
style,
badgeSize: 12,
constrainText: true,
fallbackServerName: serverKey,
),
);
}
PopupMenuItem<String> _buildLibraryMenuItem(MediaLibrary library, {required bool showServerName}) {
final isSelected = library.globalKey == _selectedLibraryGlobalKey;
return PopupMenuItem<String>(
value: library.globalKey,
child: Row(
children: [
AppIcon(
ContentTypeHelper.getLibraryIcon(library.kind.id),
fill: 1,
size: 20,
color: isSelected ? Theme.of(context).colorScheme.primary : null,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
library.title,
style: TextStyle(
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400,
color: isSelected ? Theme.of(context).colorScheme.primary : null,
),
if (showServerName)
Text(
library.serverName!,
style: TextStyle(
fontSize: 11,
color: Theme.of(context).textTheme.bodySmall?.color?.withValues(alpha: 0.6),
),
),
if (showServerName)
_buildLibraryServerLabel(
library,
TextStyle(
fontSize: 11,
color: Theme.of(context).textTheme.bodySmall?.color?.withValues(alpha: 0.6),
),
],
),
badgeSize: 10,
constrainText: true,
),
],
),
],
),
);
}).toList();
),
],
),
);
}
/// Build dropdown menu items with server subtitle when needed for clarity.
List<PopupMenuEntry<String>> _buildGroupedLibraryMenuItems(
List<MediaLibrary> visibleLibraries, {
required bool showServerHeaders,
}) {
if (!showServerHeaders) {
final nonUniqueNames = _getNonUniqueLibraryNames(visibleLibraries);
return visibleLibraries.map((library) {
final showServerName = library.serverName != null && nonUniqueNames.contains(library.title);
return _buildLibraryMenuItem(library, showServerName: showServerName);
}).toList();
}
final grouped = groupLibrariesByFirstAppearance(visibleLibraries);
final menuItems = <PopupMenuEntry<String>>[];
for (final serverKey in grouped.serverOrder) {
final bucket = grouped.byServer[serverKey]!;
if (serverKey.isNotEmpty) {
menuItems.add(_buildLibraryServerHeaderMenuItem(bucket.first, serverKey));
}
for (final library in bucket) {
menuItems.add(_buildLibraryMenuItem(library, showServerName: false));
}
}
return menuItems;
}
/// Build the app bar title - either dropdown on mobile or simple title on desktop
@@ -846,6 +910,8 @@ class _LibrariesScreenState extends State<LibrariesScreen>
visibleLibraries.where((lib) => lib.globalKey == _selectedLibraryGlobalKey).firstOrNull ??
visibleLibraries.firstOrNull;
if (selectedLibrary == null) return Text(t.libraries.title);
final groupByServerSetting = context.select<SettingsProvider, bool>((p) => p.groupLibrariesByServer);
final showServerHeaders = _hasMultipleServers(visibleLibraries) && groupByServerSetting;
return PopupMenuButton<String>(
key: _libraryDropdownKey,
@@ -854,7 +920,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
onSelected: (libraryGlobalKey) {
_loadLibraryContent(libraryGlobalKey);
},
itemBuilder: (context) => _buildGroupedLibraryMenuItems(visibleLibraries),
itemBuilder: (context) => _buildGroupedLibraryMenuItems(visibleLibraries, showServerHeaders: showServerHeaders),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Row(
@@ -868,11 +934,12 @@ class _LibrariesScreenState extends State<LibrariesScreen>
mainAxisSize: MainAxisSize.min,
children: [
Text(selectedLibrary.title, style: Theme.of(context).textTheme.titleMedium),
Text(
selectedLibrary.serverName!,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
_buildLibraryServerLabel(
selectedLibrary,
Theme.of(context).textTheme.labelSmall?.copyWith(
color: Theme.of(context).textTheme.bodySmall?.color?.withValues(alpha: 0.6),
),
badgeSize: 10,
),
],
)
+20
View File
@@ -0,0 +1,20 @@
import '../media/media_library.dart';
typedef LibraryServerGroups = ({List<String> serverOrder, Map<String, List<MediaLibrary>> byServer});
/// Groups libraries by server while preserving each server's first appearance in
/// the provided list. Libraries without a server id are placed in the empty-key
/// bucket at their first occurrence.
LibraryServerGroups groupLibrariesByFirstAppearance(List<MediaLibrary> libraries) {
final order = <String>[];
final byServer = <String, List<MediaLibrary>>{};
for (final lib in libraries) {
final key = lib.serverId ?? '';
if (!byServer.containsKey(key)) {
order.add(key);
byServer[key] = [];
}
byServer[key]!.add(lib);
}
return (serverOrder: order, byServer: byServer);
}
+3 -22
View File
@@ -15,6 +15,7 @@ import '../providers/hidden_libraries_provider.dart';
import '../providers/libraries_provider.dart';
import '../providers/settings_provider.dart';
import '../utils/platform_detector.dart';
import '../utils/library_grouping.dart';
import '../providers/multi_server_provider.dart';
import '../services/fullscreen_state_manager.dart';
import '../theme/mono_tokens.dart';
@@ -284,7 +285,7 @@ class SideNavigationRailState extends State<SideNavigationRail> {
if (!showServerHeaders) {
return libs.map((lib) => lib.globalKey).toList();
}
final grouped = _groupByFirstAppearance(libs);
final grouped = groupLibrariesByFirstAppearance(libs);
final result = <String>[];
for (final serverKey in grouped.serverOrder) {
if (serverKey.isNotEmpty) {
@@ -844,26 +845,6 @@ class SideNavigationRailState extends State<SideNavigationRail> {
return nameCounts.entries.where((e) => e.value > 1).map((e) => e.key).toSet();
}
/// First-appearance grouping: walk libraries once, recording each serverId's
/// first-seen position and bucketing libraries underneath. Returns the server
/// order plus a per-server library list. Libraries without a serverId end up
/// in a synthetic '' bucket appearing at their first occurrence.
({List<String> serverOrder, Map<String, List<MediaLibrary>> byServer}) _groupByFirstAppearance(
List<MediaLibrary> libs,
) {
final order = <String>[];
final byServer = <String, List<MediaLibrary>>{};
for (final lib in libs) {
final key = lib.serverId ?? '';
if (!byServer.containsKey(key)) {
order.add(key);
byServer[key] = [];
}
byServer[key]!.add(lib);
}
return (serverOrder: order, byServer: byServer);
}
Widget _buildLibraryGroupedColumn(List<MediaLibrary> libraries, dynamic t, {required bool showServerHeaders}) {
if (!showServerHeaders) {
final nonUniqueNames = _getNonUniqueLibraryNames(libraries);
@@ -876,7 +857,7 @@ class SideNavigationRailState extends State<SideNavigationRail> {
);
}
final grouped = _groupByFirstAppearance(libraries);
final grouped = groupLibrariesByFirstAppearance(libraries);
final children = <Widget>[];
for (final serverKey in grouped.serverOrder) {
final bucket = grouped.byServer[serverKey]!;