On phone layouts main_screen pushes SettingsScreen as its own route, and that route carried no OverlaySheetHost. showAdaptive could not find one from the tile's context, so Manage Libraries fell back to showModalBottomSheet. The sheet also owns a focused Back handler, so a single Android Back arrived twice — once as a key event, once as popRoute — and the two route-based paths raced, tearing down Settings along with the sheet. Installs one route-local host when no enclosing host exists, and opens the sheet from a context below it. OverlaySheetHost then holds the route while a sheet is open and deduplicates the key path, so one Back closes only the sheet.
533 lines
19 KiB
Dart
533 lines
19 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:material_symbols_icons/symbols.dart';
|
|
import 'package:provider/provider.dart';
|
|
|
|
import '../focus/dpad_reorder_mixin.dart';
|
|
import '../focus/focus_theme.dart';
|
|
import '../focus/input_mode_tracker.dart';
|
|
import '../i18n/strings.g.dart';
|
|
import '../media/media_backend.dart';
|
|
import '../media/media_library.dart';
|
|
import '../media/media_server_client.dart';
|
|
import '../providers/hidden_libraries_provider.dart';
|
|
import '../providers/libraries_provider.dart';
|
|
import '../utils/app_logger.dart';
|
|
import '../utils/content_utils.dart';
|
|
import '../utils/dialogs.dart';
|
|
import '../utils/platform_detector.dart';
|
|
import '../utils/provider_extensions.dart';
|
|
import '../utils/snackbar_helper.dart';
|
|
import 'app_icon.dart';
|
|
import 'app_menu.dart';
|
|
import 'bottom_sheet_header.dart';
|
|
import 'overlay_sheet.dart';
|
|
|
|
/// A menu action item for context menus
|
|
class ContextMenuItem {
|
|
final String value;
|
|
final IconData icon;
|
|
final String label;
|
|
final bool requiresConfirmation;
|
|
final String? confirmationTitle;
|
|
final String? confirmationMessage;
|
|
final bool isDestructive;
|
|
|
|
const ContextMenuItem({
|
|
required this.value,
|
|
required this.icon,
|
|
required this.label,
|
|
this.requiresConfirmation = false,
|
|
this.confirmationTitle,
|
|
this.confirmationMessage,
|
|
this.isDestructive = false,
|
|
});
|
|
}
|
|
|
|
/// Shows the manage/reorder-libraries sheet (dialog on TV, overlay sheet
|
|
/// otherwise). Reorder and hide/unhide are provider-backed, so any screen can
|
|
/// open it.
|
|
///
|
|
/// [onOrderChanged] runs after the new order is written to
|
|
/// [LibrariesProvider] (the libraries screen uses it to poke MainScreen's
|
|
/// side nav). [onToggleVisibility] overrides the default plain hide/unhide
|
|
/// (the libraries screen adds "re-select first visible library" logic).
|
|
Future<void> showLibraryManagementSheet(
|
|
BuildContext context, {
|
|
VoidCallback? onOrderChanged,
|
|
Future<void> Function(MediaLibrary library)? onToggleVisibility,
|
|
}) {
|
|
final librariesProvider = context.read<LibrariesProvider>();
|
|
final hiddenLibrariesProvider = context.read<HiddenLibrariesProvider>();
|
|
final allLibraries = librariesProvider.libraries;
|
|
|
|
Future<void> defaultToggleVisibility(MediaLibrary library) async {
|
|
final isHidden = hiddenLibrariesProvider.hiddenLibraryKeys.contains(library.globalKey);
|
|
if (isHidden) {
|
|
await hiddenLibrariesProvider.unhideLibrary(library.globalKey);
|
|
} else {
|
|
await hiddenLibrariesProvider.hideLibrary(library.globalKey);
|
|
}
|
|
}
|
|
|
|
Widget buildSheet({required bool isDialog}) => _LibraryManagementSheet(
|
|
isDialog: isDialog,
|
|
allLibraries: List.from(allLibraries),
|
|
hiddenLibraryKeys: hiddenLibrariesProvider.hiddenLibraryKeys,
|
|
onReorder: (reorderedLibraries) {
|
|
librariesProvider.updateLibraryOrder(reorderedLibraries);
|
|
onOrderChanged?.call();
|
|
},
|
|
onToggleVisibility: onToggleVisibility ?? defaultToggleVisibility,
|
|
getLibraryMenuItems: _getLibraryMenuItems,
|
|
onLibraryMenuAction: (action, library) => _handleLibraryMenuAction(context, action, library),
|
|
);
|
|
|
|
if (PlatformDetector.isTV()) {
|
|
return showScopedDialog<void>(context: context, builder: (context) => buildSheet(isDialog: true));
|
|
}
|
|
// Use the host supplied by the calling screen when available while keeping
|
|
// this reusable entry point safe for routes without one. isScrollControlled
|
|
// keeps that modal fallback from capping the sheet at ~9/16 of the screen.
|
|
return OverlaySheetController.showAdaptive<void>(
|
|
context,
|
|
showDragHandle: true,
|
|
isScrollControlled: true,
|
|
builder: (context) => buildSheet(isDialog: false),
|
|
);
|
|
}
|
|
|
|
List<ContextMenuItem> _getLibraryMenuItems(MediaLibrary library) {
|
|
// Refresh metadata is the only admin action both backends support — Plex
|
|
// hits `/library/sections/{id}/refresh?force=1`, Jellyfin posts to
|
|
// `/Items/{id}/Refresh` (the library view is itself an item).
|
|
final refresh = ContextMenuItem(
|
|
value: 'refresh',
|
|
icon: Symbols.sync_rounded,
|
|
label: t.libraries.refreshMetadata,
|
|
requiresConfirmation: true,
|
|
confirmationTitle: t.libraries.refreshMetadata,
|
|
confirmationMessage: t.libraries.refreshMetadataConfirm(title: library.title),
|
|
isDestructive: true,
|
|
);
|
|
// Scan / analyze / empty trash hit Plex-only endpoints, so backend
|
|
// capability gating keeps them out of Jellyfin menus. The library-qualified
|
|
// resolver independently requires the exact owning Plex server.
|
|
if (library.backend != MediaBackend.plex) return [refresh];
|
|
return [
|
|
ContextMenuItem(
|
|
value: 'scan',
|
|
icon: Symbols.refresh_rounded,
|
|
label: t.libraries.scanLibraryFiles,
|
|
requiresConfirmation: true,
|
|
confirmationTitle: t.libraries.scanLibrary,
|
|
confirmationMessage: t.libraries.scanLibraryConfirm(title: library.title),
|
|
),
|
|
ContextMenuItem(
|
|
value: 'analyze',
|
|
icon: Symbols.analytics_rounded,
|
|
label: t.libraries.analyze,
|
|
requiresConfirmation: true,
|
|
confirmationTitle: t.libraries.analyzeLibrary,
|
|
confirmationMessage: t.libraries.analyzeLibraryConfirm(title: library.title),
|
|
),
|
|
refresh,
|
|
ContextMenuItem(
|
|
value: 'empty_trash',
|
|
icon: Symbols.delete_outline_rounded,
|
|
label: t.libraries.emptyTrash,
|
|
requiresConfirmation: true,
|
|
confirmationTitle: t.libraries.emptyTrash,
|
|
confirmationMessage: t.libraries.emptyTrashConfirm(title: library.title),
|
|
isDestructive: true,
|
|
),
|
|
];
|
|
}
|
|
|
|
Future<void> _handleLibraryMenuAction(BuildContext context, String action, MediaLibrary library) async {
|
|
// Find the menu item for confirmation details
|
|
final menuItems = _getLibraryMenuItems(library);
|
|
final item = menuItems.where((i) => i.value == action).firstOrNull;
|
|
if (item == null) return;
|
|
|
|
if (item.requiresConfirmation) {
|
|
final confirmed = await showConfirmDialog(
|
|
context,
|
|
title: item.confirmationTitle ?? t.dialog.confirmAction,
|
|
message: item.confirmationMessage ?? t.libraries.confirmActionMessage,
|
|
confirmText: t.common.confirm,
|
|
isDestructive: item.isDestructive,
|
|
);
|
|
if (!confirmed || !context.mounted) return;
|
|
}
|
|
|
|
switch (action) {
|
|
case 'scan':
|
|
unawaited(_scanLibrary(context, library));
|
|
break;
|
|
case 'analyze':
|
|
unawaited(_analyzeLibrary(context, library));
|
|
break;
|
|
case 'refresh':
|
|
unawaited(_refreshLibraryMetadata(context, library));
|
|
break;
|
|
case 'empty_trash':
|
|
unawaited(_emptyLibraryTrash(context, library));
|
|
break;
|
|
}
|
|
}
|
|
|
|
/// Runs a library admin action, wrapping it in progress/success/failure
|
|
/// snackbars.
|
|
///
|
|
/// [resolveClient] picks the client flavour: `getPlexClientForLibrary` for the
|
|
/// Plex-only endpoints (scan / analyze / empty trash), `getMediaClientForLibrary`
|
|
/// for ops that exist on the backend-neutral [MediaServerClient] interface
|
|
/// (currently just refresh metadata). Both resolvers require the library's exact
|
|
/// owning server and throw the same error when it isn't available.
|
|
Future<void> _performLibraryAction<T extends MediaServerClient>(
|
|
BuildContext context, {
|
|
required T Function(BuildContext context) resolveClient,
|
|
required Future<void> Function(T client) action,
|
|
required String progressMessage,
|
|
required String successMessage,
|
|
required String Function(Object error) failureMessage,
|
|
}) async {
|
|
try {
|
|
final client = resolveClient(context);
|
|
|
|
if (context.mounted) {
|
|
showAppSnackBar(context, progressMessage, duration: const Duration(seconds: 2));
|
|
}
|
|
|
|
await action(client);
|
|
|
|
if (context.mounted) {
|
|
showSuccessSnackBar(context, successMessage);
|
|
}
|
|
} catch (e) {
|
|
appLogger.e('Library action failed', error: e);
|
|
if (context.mounted) {
|
|
showErrorSnackBar(context, failureMessage(e));
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _scanLibrary(BuildContext context, MediaLibrary library) {
|
|
return _performLibraryAction(
|
|
context,
|
|
resolveClient: (ctx) => ctx.getPlexClientForLibrary(library),
|
|
action: (client) => client.scanLibrary(library.id),
|
|
progressMessage: t.messages.libraryScanning(title: library.title),
|
|
successMessage: t.messages.libraryScanStarted(title: library.title),
|
|
failureMessage: (error) => t.messages.libraryScanFailed(error: error.toString()),
|
|
);
|
|
}
|
|
|
|
Future<void> _refreshLibraryMetadata(BuildContext context, MediaLibrary library) {
|
|
return _performLibraryAction(
|
|
context,
|
|
resolveClient: (ctx) => ctx.getMediaClientForLibrary(library),
|
|
action: (client) => client.refreshLibraryMetadata(library.id),
|
|
progressMessage: t.messages.metadataRefreshing(title: library.title),
|
|
successMessage: t.messages.metadataRefreshStarted(title: library.title),
|
|
failureMessage: (error) => t.messages.metadataRefreshFailed(error: error.toString()),
|
|
);
|
|
}
|
|
|
|
Future<void> _emptyLibraryTrash(BuildContext context, MediaLibrary library) {
|
|
return _performLibraryAction(
|
|
context,
|
|
resolveClient: (ctx) => ctx.getPlexClientForLibrary(library),
|
|
action: (client) => client.emptyLibraryTrash(library.id),
|
|
progressMessage: t.libraries.emptyingTrash(title: library.title),
|
|
successMessage: t.libraries.trashEmptied(title: library.title),
|
|
failureMessage: (error) => t.libraries.failedToEmptyTrash(error: error),
|
|
);
|
|
}
|
|
|
|
Future<void> _analyzeLibrary(BuildContext context, MediaLibrary library) {
|
|
return _performLibraryAction(
|
|
context,
|
|
resolveClient: (ctx) => ctx.getPlexClientForLibrary(library),
|
|
action: (client) => client.analyzeLibrary(library.id),
|
|
progressMessage: t.libraries.analyzing(title: library.title),
|
|
successMessage: t.libraries.analysisStarted(title: library.title),
|
|
failureMessage: (error) => t.libraries.failedToAnalyze(error: error),
|
|
);
|
|
}
|
|
|
|
class _LibraryManagementSheet extends StatefulWidget {
|
|
final bool isDialog;
|
|
final List<MediaLibrary> allLibraries;
|
|
final Set<String> hiddenLibraryKeys;
|
|
final Function(List<MediaLibrary>) onReorder;
|
|
final Function(MediaLibrary) onToggleVisibility;
|
|
final List<ContextMenuItem> Function(MediaLibrary) getLibraryMenuItems;
|
|
final void Function(String action, MediaLibrary library) onLibraryMenuAction;
|
|
|
|
const _LibraryManagementSheet({
|
|
this.isDialog = false,
|
|
required this.allLibraries,
|
|
required this.hiddenLibraryKeys,
|
|
required this.onReorder,
|
|
required this.onToggleVisibility,
|
|
required this.getLibraryMenuItems,
|
|
required this.onLibraryMenuAction,
|
|
});
|
|
|
|
@override
|
|
State<_LibraryManagementSheet> createState() => _LibraryManagementSheetState();
|
|
}
|
|
|
|
class _LibraryManagementSheetState extends State<_LibraryManagementSheet>
|
|
with DpadReorderListMixin<MediaLibrary, _LibraryManagementSheet> {
|
|
late List<MediaLibrary> _tempLibraries;
|
|
|
|
final FocusNode _listFocusNode = FocusNode();
|
|
final ScrollController _dialogScrollController = ScrollController();
|
|
final ScrollController _sheetScrollController = ScrollController();
|
|
|
|
// Keyboard navigation: column 0 = row, 1 = visibility button, 2 = options button.
|
|
@override
|
|
List<MediaLibrary> get reorderItems => _tempLibraries;
|
|
|
|
@override
|
|
set reorderItems(List<MediaLibrary> value) => _tempLibraries = value;
|
|
|
|
@override
|
|
int get lastReorderColumn => 2;
|
|
|
|
/// Only the TV dialog scrolls the focused row into view; the bottom sheet
|
|
/// list is not keyboard-driven.
|
|
@override
|
|
ScrollController? get reorderScrollController => widget.isDialog ? _dialogScrollController : null;
|
|
|
|
@override
|
|
void onReorderMoveConfirmed() => widget.onReorder(_tempLibraries);
|
|
|
|
@override
|
|
void onReorderColumnActivated(int column, int index) {
|
|
final library = _tempLibraries[index];
|
|
if (column == 1) {
|
|
widget.onToggleVisibility(library);
|
|
} else if (column == 2) {
|
|
_showLibraryMenuBottomSheet(context, library);
|
|
}
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_tempLibraries = List.from(widget.allLibraries);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_listFocusNode.dispose();
|
|
_dialogScrollController.dispose();
|
|
_sheetScrollController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _reorderLibraries(int oldIndex, int newIndex) {
|
|
setState(() {
|
|
final library = _tempLibraries.removeAt(oldIndex);
|
|
_tempLibraries.insert(newIndex, library);
|
|
});
|
|
// Apply immediately
|
|
widget.onReorder(_tempLibraries);
|
|
}
|
|
|
|
void _showLibraryMenuBottomSheet(BuildContext outerContext, MediaLibrary library) {
|
|
final menuItems = widget.getLibraryMenuItems(library);
|
|
OverlaySheetController.pushAdaptive<String>(
|
|
outerContext,
|
|
builder: (context) => AppMenuSheet<String>(
|
|
title: library.title,
|
|
entries: [
|
|
for (final item in menuItems)
|
|
AppMenuItem<String>(value: item.value, icon: item.icon, label: item.label, destructive: item.isDestructive),
|
|
],
|
|
onSelected: (value) => widget.onLibraryMenuAction(value, library),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Whether the libraries span more than one connected server.
|
|
bool _hasMultipleServers() {
|
|
final serverIds = _tempLibraries.where((lib) => lib.serverId != null).map((lib) => lib.serverId).toSet();
|
|
return serverIds.length > 1;
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
// Watch provider to rebuild when hidden libraries change
|
|
final hiddenLibrariesProvider = context.watch<HiddenLibrariesProvider>();
|
|
final hiddenLibraryKeys = hiddenLibrariesProvider.hiddenLibraryKeys;
|
|
|
|
if (widget.isDialog) {
|
|
return Dialog(
|
|
child: PopScope(
|
|
canPop: false, // Prevent system back from double-popping; handled by handleReorderKeyEvent
|
|
// ignore: no-empty-block - required callback, blocks system back on Android TV
|
|
onPopInvokedWithResult: (didPop, result) {},
|
|
child: Scaffold(
|
|
appBar: AppBar(
|
|
title: Row(
|
|
children: [
|
|
const AppIcon(Symbols.edit_rounded, fill: 1),
|
|
const SizedBox(width: 12),
|
|
Text(t.libraries.manageLibraries),
|
|
],
|
|
),
|
|
automaticallyImplyLeading: false,
|
|
actions: [
|
|
IconButton(
|
|
icon: const AppIcon(Symbols.close_rounded, fill: 1),
|
|
onPressed: () => Navigator.pop(context),
|
|
),
|
|
],
|
|
),
|
|
body: Focus(
|
|
focusNode: _listFocusNode,
|
|
descendantsAreFocusable: false,
|
|
autofocus: InputModeTracker.isKeyboardMode(context),
|
|
onKeyEvent: handleReorderKeyEvent,
|
|
child: _buildFlatLibraryList(_dialogScrollController, hiddenLibraryKeys),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
return Column(
|
|
children: [
|
|
BottomSheetHeader(title: t.libraries.manageLibraries, icon: Symbols.edit_rounded),
|
|
Flexible(
|
|
child: Focus(
|
|
focusNode: _listFocusNode,
|
|
descendantsAreFocusable: false,
|
|
autofocus: InputModeTracker.isKeyboardMode(context),
|
|
onKeyEvent: handleReorderKeyEvent,
|
|
child: _buildFlatLibraryList(_sheetScrollController, hiddenLibraryKeys),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
/// Build flat library list with a server subtitle when multiple servers are
|
|
/// connected. The TV dialog passes [_dialogScrollController] so focused rows
|
|
/// can be scrolled into view; the bottom sheet passes its own controller.
|
|
Widget _buildFlatLibraryList(ScrollController scrollController, Set<String> hiddenLibraryKeys) {
|
|
final showServerNames = _hasMultipleServers();
|
|
final isKeyboardMode = InputModeTracker.isKeyboardMode(context);
|
|
|
|
return ReorderableListView.builder(
|
|
scrollController: scrollController,
|
|
onReorderItem: _reorderLibraries,
|
|
itemCount: _tempLibraries.length,
|
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
|
buildDefaultDragHandles: false,
|
|
itemBuilder: (context, index) {
|
|
final library = _tempLibraries[index];
|
|
final showServerName = showServerNames && library.serverName != null;
|
|
final isFocused = isKeyboardMode && index == focusedIndex;
|
|
final isMoving = index == movingIndex;
|
|
return _buildLibraryTile(
|
|
library,
|
|
index,
|
|
hiddenLibraryKeys,
|
|
showServerName: showServerName,
|
|
isFocused: isFocused,
|
|
isMoving: isMoving,
|
|
focusedColumn: isFocused ? focusedColumn : null,
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
/// Build a single library tile
|
|
Widget _buildLibraryTile(
|
|
MediaLibrary library,
|
|
int index,
|
|
Set<String> hiddenLibraryKeys, {
|
|
bool showServerName = false,
|
|
bool isFocused = false,
|
|
bool isMoving = false,
|
|
int? focusedColumn,
|
|
}) {
|
|
final isHidden = hiddenLibraryKeys.contains(library.globalKey);
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
|
|
// Determine background color based on state
|
|
Color? tileColor;
|
|
if (isMoving) {
|
|
tileColor = colorScheme.primaryContainer;
|
|
} else if (isFocused && focusedColumn == 0) {
|
|
// Only highlight row when row itself is focused (column 0)
|
|
tileColor = colorScheme.surfaceContainerHighest;
|
|
}
|
|
|
|
// Button focus states
|
|
final isVisibilityButtonFocused = isFocused && focusedColumn == 1;
|
|
final isOptionsButtonFocused = isFocused && focusedColumn == 2;
|
|
|
|
return Opacity(
|
|
key: ValueKey(library.globalKey),
|
|
opacity: isHidden ? 0.5 : 1.0,
|
|
child: ListTile(
|
|
tileColor: tileColor,
|
|
leading: Row(
|
|
mainAxisSize: .min,
|
|
children: [
|
|
ReorderableDragStartListener(
|
|
index: index,
|
|
child: AppIcon(
|
|
isMoving ? Symbols.swap_vert_rounded : Symbols.drag_indicator_rounded,
|
|
fill: 1,
|
|
color: isMoving ? colorScheme.primary : IconTheme.of(context).color?.withValues(alpha: 0.5),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
AppIcon(ContentTypeHelper.getLibraryIcon(library.kind.id), fill: 1),
|
|
],
|
|
),
|
|
title: Text(library.title),
|
|
subtitle: showServerName
|
|
? Text(
|
|
library.serverName!,
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
color: Theme.of(context).textTheme.bodySmall?.color?.withValues(alpha: 0.6),
|
|
),
|
|
)
|
|
: null,
|
|
trailing: Row(
|
|
mainAxisSize: .min,
|
|
children: [
|
|
Container(
|
|
decoration: FocusTheme.focusBackgroundDecoration(isFocused: isVisibilityButtonFocused, borderRadius: 20),
|
|
child: IconButton(
|
|
icon: AppIcon(isHidden ? Symbols.visibility_off_rounded : Symbols.visibility_rounded, fill: 1),
|
|
tooltip: isHidden ? t.libraries.showLibrary : t.libraries.hideLibrary,
|
|
onPressed: () => widget.onToggleVisibility(library),
|
|
),
|
|
),
|
|
Container(
|
|
decoration: FocusTheme.focusBackgroundDecoration(isFocused: isOptionsButtonFocused, borderRadius: 20),
|
|
child: IconButton(
|
|
icon: const AppIcon(Symbols.more_vert_rounded, fill: 1),
|
|
tooltip: t.libraries.libraryOptions,
|
|
onPressed: () => _showLibraryMenuBottomSheet(context, library),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|