Skip auto-focus in touch mode to prevent ensureVisible() from fighting TabBarView animations, and clip each tab with ClipRect to stop hub row overflow bleeding into adjacent tabs during swipe transitions.
1643 lines
58 KiB
Dart
1643 lines
58 KiB
Dart
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 'package:dio/dio.dart';
|
|
import '../../focus/focus_theme.dart';
|
|
import '../../focus/focusable_action_bar.dart';
|
|
import '../../focus/focusable_button.dart';
|
|
import '../../focus/dpad_navigator.dart';
|
|
import '../../focus/input_mode_tracker.dart';
|
|
import '../../focus/key_event_utils.dart';
|
|
import '../../mixins/tab_navigation_mixin.dart';
|
|
import '../../../services/plex_client.dart';
|
|
import '../../models/plex_library.dart';
|
|
import '../../models/plex_metadata.dart';
|
|
import '../../models/plex_sort.dart';
|
|
import '../../providers/hidden_libraries_provider.dart';
|
|
import '../../providers/libraries_provider.dart';
|
|
import '../../providers/multi_server_provider.dart';
|
|
import '../../utils/app_logger.dart';
|
|
import '../../utils/platform_detector.dart';
|
|
import '../../utils/provider_extensions.dart';
|
|
import '../../utils/snackbar_helper.dart';
|
|
import '../../utils/content_utils.dart';
|
|
import '../../widgets/desktop_app_bar.dart';
|
|
import '../../widgets/focusable_tab_chip.dart';
|
|
import '../../widgets/overlay_sheet.dart';
|
|
import '../../services/storage_service.dart';
|
|
import '../../mixins/refreshable.dart';
|
|
import '../../mixins/item_updatable.dart';
|
|
import '../../i18n/strings.g.dart';
|
|
import '../../utils/error_message_utils.dart';
|
|
import 'state_messages.dart';
|
|
import 'tabs/library_browse_tab.dart';
|
|
import 'tabs/library_recommended_tab.dart';
|
|
import 'tabs/library_collections_tab.dart';
|
|
import 'tabs/library_playlists_tab.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,
|
|
});
|
|
}
|
|
|
|
class LibrariesScreen extends StatefulWidget {
|
|
final VoidCallback? onLibraryOrderChanged;
|
|
|
|
const LibrariesScreen({super.key, this.onLibraryOrderChanged});
|
|
|
|
@override
|
|
State<LibrariesScreen> createState() => _LibrariesScreenState();
|
|
}
|
|
|
|
class _LibrariesScreenState extends State<LibrariesScreen>
|
|
with
|
|
Refreshable,
|
|
FullRefreshable,
|
|
FocusableTab,
|
|
LibraryLoadable,
|
|
ItemUpdatable,
|
|
SingleTickerProviderStateMixin,
|
|
TabNavigationMixin {
|
|
@override
|
|
PlexClient get client {
|
|
final multiServerProvider = Provider.of<MultiServerProvider>(context, listen: false);
|
|
final serverId = multiServerProvider.onlineServerIds.firstOrNull;
|
|
if (serverId == null) {
|
|
throw Exception(t.errors.noClientAvailable);
|
|
}
|
|
return context.getClientForServer(serverId);
|
|
}
|
|
|
|
// GlobalKeys for tabs to enable refresh
|
|
final _recommendedTabKey = GlobalKey();
|
|
final _browseTabKey = GlobalKey();
|
|
final _collectionsTabKey = GlobalKey();
|
|
final _playlistsTabKey = GlobalKey();
|
|
|
|
String? _errorMessage;
|
|
String? _selectedLibraryGlobalKey;
|
|
bool _isInitialLoad = true;
|
|
|
|
Map<String, String> _selectedFilters = {};
|
|
PlexSort? _selectedSort;
|
|
bool _isSortDescending = false;
|
|
List<PlexMetadata> _items = [];
|
|
int _currentPage = 0;
|
|
bool _hasMoreItems = true;
|
|
CancelToken? _cancelToken;
|
|
int _requestId = 0;
|
|
static const int _pageSize = 1000;
|
|
|
|
/// Flag to prevent onTabChanged from focusing when we're programmatically changing tabs
|
|
bool _isRestoringTab = false;
|
|
|
|
/// Track which tabs have loaded data (used to trigger focus after tab restore)
|
|
final Set<int> _loadedTabs = {};
|
|
|
|
/// Key for the library dropdown popup menu button
|
|
final _libraryDropdownKey = GlobalKey<PopupMenuButtonState<String>>();
|
|
|
|
// Focus nodes for tab chips
|
|
final _recommendedTabChipFocusNode = FocusNode(debugLabel: 'tab_chip_recommended');
|
|
final _browseTabChipFocusNode = FocusNode(debugLabel: 'tab_chip_browse');
|
|
final _collectionsTabChipFocusNode = FocusNode(debugLabel: 'tab_chip_collections');
|
|
final _playlistsTabChipFocusNode = FocusNode(debugLabel: 'tab_chip_playlists');
|
|
|
|
@override
|
|
List<FocusNode> get tabChipFocusNodes => [
|
|
_recommendedTabChipFocusNode,
|
|
_browseTabChipFocusNode,
|
|
_collectionsTabChipFocusNode,
|
|
_playlistsTabChipFocusNode,
|
|
];
|
|
|
|
// App bar action bar
|
|
final _actionBarKey = GlobalKey<FocusableActionBarState>();
|
|
|
|
// Scroll controller for the outer CustomScrollView
|
|
final ScrollController _outerScrollController = ScrollController();
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
initTabNavigation();
|
|
|
|
// Initialize with libraries from the provider
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
_initializeWithLibraries();
|
|
});
|
|
}
|
|
|
|
/// Initialize the screen with libraries from the provider.
|
|
/// This handles initial library selection and content loading.
|
|
Future<void> _initializeWithLibraries() async {
|
|
final librariesProvider = context.read<LibrariesProvider>();
|
|
final hiddenLibrariesProvider = context.read<HiddenLibrariesProvider>();
|
|
final allLibraries = librariesProvider.libraries;
|
|
|
|
if (allLibraries.isEmpty) {
|
|
// No libraries available yet
|
|
return;
|
|
}
|
|
|
|
// Compute visible libraries for initial load
|
|
final hiddenKeys = hiddenLibrariesProvider.hiddenLibraryKeys;
|
|
final visibleLibraries = allLibraries.where((lib) => !hiddenKeys.contains(lib.globalKey)).toList();
|
|
|
|
// Load saved preferences
|
|
final storage = await StorageService.getInstance();
|
|
final savedLibraryKey = storage.getSelectedLibraryKey();
|
|
|
|
// Find the library by key in visible libraries
|
|
String? libraryGlobalKeyToLoad;
|
|
if (savedLibraryKey != null) {
|
|
// Check if saved library exists and is visible
|
|
final libraryExists = visibleLibraries.any((lib) => lib.globalKey == savedLibraryKey);
|
|
if (libraryExists) {
|
|
libraryGlobalKeyToLoad = savedLibraryKey;
|
|
}
|
|
}
|
|
|
|
// Fallback to first visible library if saved key not found
|
|
if (libraryGlobalKeyToLoad == null && visibleLibraries.isNotEmpty) {
|
|
libraryGlobalKeyToLoad = visibleLibraries.first.globalKey;
|
|
}
|
|
|
|
if (libraryGlobalKeyToLoad != null && mounted) {
|
|
final savedFilters = storage.getLibraryFilters(sectionId: libraryGlobalKeyToLoad);
|
|
if (savedFilters.isNotEmpty) {
|
|
_selectedFilters = Map.from(savedFilters);
|
|
}
|
|
_loadLibraryContent(libraryGlobalKeyToLoad);
|
|
}
|
|
}
|
|
|
|
@override
|
|
void onTabChanged() {
|
|
// Save tab index when changed (but not when restoring from storage)
|
|
if (_selectedLibraryGlobalKey != null && !tabController.indexIsChanging) {
|
|
// Only save if this was a user-initiated tab change, not a restore
|
|
if (!_isRestoringTab) {
|
|
StorageService.getInstance().then((storage) {
|
|
storage.saveLibraryTab(_selectedLibraryGlobalKey!, tabController.index);
|
|
});
|
|
|
|
// Focus first item in the current tab (only for user-initiated changes)
|
|
// But not when navigating via tab bar (suppressAutoFocus is true)
|
|
if (!suppressAutoFocus) {
|
|
_focusCurrentTab();
|
|
}
|
|
}
|
|
}
|
|
// Rebuild to update chip selection state
|
|
super.onTabChanged();
|
|
}
|
|
|
|
/// Focus the first item in the currently active tab.
|
|
/// Used for initial load and tab switching - focuses the grid content directly.
|
|
void _focusCurrentTab() {
|
|
// Don't focus during tab animations - wait for animation to complete
|
|
// This prevents race conditions during focus restoration
|
|
if (tabController.indexIsChanging) {
|
|
return;
|
|
}
|
|
// On mobile (touch mode), skip auto-focus to prevent ensureVisible()
|
|
// from interfering with TabBarView page animations
|
|
if (!InputModeTracker.isKeyboardMode(context)) return;
|
|
|
|
// Re-enable auto-focus since user is navigating into tab content
|
|
// Only call setState if the value actually changes to avoid unnecessary rebuilds
|
|
if (suppressAutoFocus) {
|
|
setState(() {
|
|
suppressAutoFocus = false;
|
|
});
|
|
}
|
|
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (!mounted) return;
|
|
|
|
final tabState = _getTabState(tabController.index);
|
|
if (tabState != null) {
|
|
(tabState as dynamic).focusFirstItem();
|
|
} else {
|
|
// State not available yet, retry after another frame
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (!mounted) return;
|
|
_focusCurrentTabImmediate();
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Focus without additional frame delay (used for retry)
|
|
void _focusCurrentTabImmediate() {
|
|
final tabState = _getTabState(tabController.index);
|
|
if (tabState != null) {
|
|
(tabState as dynamic).focusFirstItem();
|
|
}
|
|
}
|
|
|
|
/// Focus tab content when navigating DOWN from the tab bar.
|
|
/// For browse tab, this focuses the chips bar first so DOWN navigates to grid.
|
|
/// For other tabs, focuses the first item directly.
|
|
void _focusCurrentTabFromTabBar() {
|
|
if (tabController.indexIsChanging) {
|
|
return;
|
|
}
|
|
|
|
if (suppressAutoFocus) {
|
|
setState(() {
|
|
suppressAutoFocus = false;
|
|
});
|
|
}
|
|
|
|
// Scroll outer view to top to ensure tab content (including chips bar) is visible
|
|
if (_outerScrollController.hasClients && _outerScrollController.offset > 0) {
|
|
_outerScrollController.jumpTo(0);
|
|
}
|
|
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (!mounted) return;
|
|
|
|
final tabState = _getTabState(tabController.index);
|
|
if (tabState != null) {
|
|
// Browse tab has a chips bar - focus that first so DOWN navigates to grid
|
|
if (tabController.index == 1) {
|
|
(tabState as dynamic).focusChipsBar();
|
|
} else {
|
|
(tabState as dynamic).focusFirstItem();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Get the state for a tab by index
|
|
State? _getTabState(int index) {
|
|
switch (index) {
|
|
case 0:
|
|
return _recommendedTabKey.currentState;
|
|
case 1:
|
|
return _browseTabKey.currentState;
|
|
case 2:
|
|
return _collectionsTabKey.currentState;
|
|
case 3:
|
|
return _playlistsTabKey.currentState;
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// Handle when a tab's data has finished loading
|
|
void _handleTabDataLoaded(int tabIndex) {
|
|
// Track that this tab has loaded
|
|
_loadedTabs.add(tabIndex);
|
|
|
|
// Don't auto-focus if suppressed (e.g., when navigating via tab bar)
|
|
if (suppressAutoFocus) return;
|
|
|
|
// Only focus if this is the currently active tab
|
|
if (tabController.index == tabIndex && mounted) {
|
|
// Use post-frame callback to ensure the widget tree is fully built
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted && tabController.index == tabIndex && !suppressAutoFocus) {
|
|
_focusCurrentTab();
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
/// Called by parent when the Libraries screen becomes visible.
|
|
/// If the active tab has already loaded data (often the case after preloading
|
|
/// while on another main tab), re-request focus so the first item is focused
|
|
/// once the screen is actually shown.
|
|
@override
|
|
void focusActiveTabIfReady() {
|
|
if (_selectedLibraryGlobalKey == null) return;
|
|
_focusCurrentTab();
|
|
}
|
|
|
|
|
|
@override
|
|
void dispose() {
|
|
_cancelToken?.cancel();
|
|
_outerScrollController.dispose();
|
|
_recommendedTabChipFocusNode.dispose();
|
|
_browseTabChipFocusNode.dispose();
|
|
_collectionsTabChipFocusNode.dispose();
|
|
_playlistsTabChipFocusNode.dispose();
|
|
disposeTabNavigation();
|
|
super.dispose();
|
|
}
|
|
|
|
void _updateState(VoidCallback fn) {
|
|
if (!mounted) return;
|
|
setState(fn);
|
|
}
|
|
|
|
/// Helper method to get user-friendly error message from exception
|
|
String _getErrorMessage(dynamic error, String context) {
|
|
if (error is DioException) {
|
|
return mapDioErrorToMessage(error, context: context);
|
|
}
|
|
|
|
return mapUnexpectedErrorToMessage(error, context: context);
|
|
}
|
|
|
|
/// Check if libraries come from multiple servers
|
|
bool _hasMultipleServers(List<PlexLibrary> libraries) {
|
|
final uniqueServerIds = libraries.where((lib) => lib.serverId != null).map((lib) => lib.serverId).toSet();
|
|
return uniqueServerIds.length > 1;
|
|
}
|
|
|
|
/// Notify parent that library order changed
|
|
void _notifyLibraryOrderChanged() {
|
|
widget.onLibraryOrderChanged?.call();
|
|
}
|
|
|
|
/// Public method to load a library by key (called from MainScreen side nav)
|
|
@override
|
|
void loadLibraryByKey(String libraryGlobalKey) {
|
|
_loadLibraryContent(libraryGlobalKey);
|
|
}
|
|
|
|
Future<void> _loadLibraryContent(String libraryGlobalKey) async {
|
|
// Get libraries from provider
|
|
final librariesProvider = context.read<LibrariesProvider>();
|
|
final allLibraries = librariesProvider.libraries;
|
|
|
|
// Compute visible libraries based on current provider state
|
|
final hiddenLibrariesProvider = Provider.of<HiddenLibrariesProvider>(context, listen: false);
|
|
final hiddenKeys = hiddenLibrariesProvider.hiddenLibraryKeys;
|
|
final visibleLibraries = allLibraries.where((lib) => !hiddenKeys.contains(lib.globalKey)).toList();
|
|
|
|
// Find the library by key
|
|
final libraryIndex = visibleLibraries.indexWhere((lib) => lib.globalKey == libraryGlobalKey);
|
|
if (libraryIndex == -1) return; // Library not found or hidden
|
|
|
|
final library = visibleLibraries[libraryIndex];
|
|
|
|
final isChangingLibrary = !_isInitialLoad && _selectedLibraryGlobalKey != libraryGlobalKey;
|
|
|
|
// Get the correct client for this library's server
|
|
final client = context.getClientForLibrary(library);
|
|
|
|
_updateState(() {
|
|
_selectedLibraryGlobalKey = libraryGlobalKey;
|
|
_errorMessage = null;
|
|
// Clear loaded tabs tracking for new library
|
|
_loadedTabs.clear();
|
|
// Only clear filters when explicitly changing library (not on initial load)
|
|
if (isChangingLibrary) {
|
|
_selectedFilters.clear();
|
|
}
|
|
});
|
|
|
|
// Mark that initial load is complete
|
|
if (_isInitialLoad) {
|
|
_isInitialLoad = false;
|
|
}
|
|
|
|
// Save selected library key and restore saved tab
|
|
final storage = await StorageService.getInstance();
|
|
await storage.saveSelectedLibraryKey(libraryGlobalKey);
|
|
|
|
// Restore saved tab index for this library
|
|
final savedTabIndex = storage.getLibraryTab(libraryGlobalKey);
|
|
if (savedTabIndex != null && savedTabIndex >= 0 && savedTabIndex < 4) {
|
|
// Set flag to prevent _onTabChanged from triggering focus
|
|
_isRestoringTab = true;
|
|
// Use animateTo with zero duration for instant switch without animation race conditions
|
|
tabController.animateTo(savedTabIndex, duration: Duration.zero);
|
|
// Clear flag synchronously - animateTo with zero duration completes immediately
|
|
_isRestoringTab = false;
|
|
}
|
|
|
|
// Focus is handled by onDataLoaded callbacks from each tab.
|
|
// However, on first load the tab might finish loading before the tab index
|
|
// is restored. Check if the current tab has already loaded and focus if so.
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted && _selectedLibraryGlobalKey == libraryGlobalKey && _loadedTabs.contains(tabController.index)) {
|
|
_focusCurrentTab();
|
|
}
|
|
});
|
|
|
|
// Cancel any existing requests
|
|
_cancelToken?.cancel();
|
|
_cancelToken = CancelToken();
|
|
final currentRequestId = ++_requestId;
|
|
|
|
// Reset pagination state
|
|
_updateState(() {
|
|
_currentPage = 0;
|
|
_hasMoreItems = true;
|
|
_items = [];
|
|
});
|
|
|
|
try {
|
|
// Load sort options for the new library
|
|
await _loadSortOptions(library);
|
|
|
|
final filtersWithSort = _buildFiltersWithSort();
|
|
|
|
// Load pages sequentially
|
|
await _loadAllPagesSequentially(library, filtersWithSort, currentRequestId, client);
|
|
} catch (e) {
|
|
// Ignore cancellation errors
|
|
if (e is DioException && e.type == DioExceptionType.cancel) {
|
|
return;
|
|
}
|
|
|
|
_updateState(() {
|
|
_errorMessage = _getErrorMessage(e, 'library content');
|
|
});
|
|
}
|
|
}
|
|
|
|
/// Load all pages sequentially until all items are fetched
|
|
Future<void> _loadAllPagesSequentially(
|
|
PlexLibrary library,
|
|
Map<String, String> filtersWithSort,
|
|
int requestId,
|
|
PlexClient client,
|
|
) async {
|
|
while (_hasMoreItems && requestId == _requestId) {
|
|
try {
|
|
final result = await client.getLibraryContent(
|
|
library.key,
|
|
start: _currentPage * _pageSize,
|
|
size: _pageSize,
|
|
filters: filtersWithSort,
|
|
cancelToken: _cancelToken,
|
|
);
|
|
|
|
// Tag items with server info for multi-server support
|
|
final taggedItems = result.items
|
|
.map((item) => item.copyWith(serverId: library.serverId, serverName: library.serverName))
|
|
.toList();
|
|
|
|
// Check if request is still valid
|
|
if (requestId != _requestId) {
|
|
return; // Request was superseded
|
|
}
|
|
|
|
_updateState(() {
|
|
_items.addAll(taggedItems);
|
|
_currentPage++;
|
|
_hasMoreItems = taggedItems.length >= _pageSize;
|
|
});
|
|
} catch (e) {
|
|
// Check if it's a cancellation
|
|
if (e is DioException && e.type == DioExceptionType.cancel) {
|
|
return;
|
|
}
|
|
|
|
// For other errors, update state and rethrow
|
|
_updateState(() {
|
|
_hasMoreItems = false;
|
|
});
|
|
rethrow;
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _loadSortOptions(PlexLibrary library) async {
|
|
try {
|
|
final client = context.getClientForLibrary(library);
|
|
|
|
final sortOptions = await client.getLibrarySorts(library.key);
|
|
|
|
// Load saved sort preference for this library
|
|
final storage = await StorageService.getInstance();
|
|
final savedSortData = storage.getLibrarySort(library.globalKey);
|
|
|
|
// Find the saved sort in the options
|
|
PlexSort? savedSort;
|
|
bool descending = false;
|
|
|
|
if (savedSortData != null) {
|
|
final sortKey = savedSortData['key'] as String?;
|
|
if (sortKey != null) {
|
|
savedSort = sortOptions.firstWhere((s) => s.key == sortKey, orElse: () => sortOptions.first);
|
|
descending = (savedSortData['descending'] as bool?) ?? false;
|
|
} else {
|
|
savedSort = sortOptions.first;
|
|
}
|
|
} else {
|
|
savedSort = sortOptions.first;
|
|
}
|
|
|
|
_updateState(() {
|
|
_selectedSort = savedSort;
|
|
_isSortDescending = descending;
|
|
});
|
|
} catch (e) {
|
|
_updateState(() {
|
|
_selectedSort = null;
|
|
_isSortDescending = false;
|
|
});
|
|
}
|
|
}
|
|
|
|
Map<String, String> _buildFiltersWithSort() {
|
|
final filtersWithSort = Map<String, String>.from(_selectedFilters);
|
|
if (_selectedSort != null) {
|
|
filtersWithSort['sort'] = _selectedSort!.getSortKey(descending: _isSortDescending);
|
|
}
|
|
return filtersWithSort;
|
|
}
|
|
|
|
@override
|
|
void updateItemInLists(String ratingKey, PlexMetadata updatedMetadata) {
|
|
final index = _items.indexWhere((item) => item.ratingKey == ratingKey);
|
|
if (index != -1) {
|
|
_items[index] = updatedMetadata;
|
|
}
|
|
}
|
|
|
|
// Public method to refresh content (for normal navigation)
|
|
@override
|
|
void refresh() {
|
|
// Reinitialize with current libraries
|
|
_initializeWithLibraries();
|
|
}
|
|
|
|
// Refresh the currently active tab
|
|
void _refreshCurrentTab() {
|
|
final key = switch (tabController.index) {
|
|
0 => _recommendedTabKey,
|
|
1 => _browseTabKey,
|
|
2 => _collectionsTabKey,
|
|
3 => _playlistsTabKey,
|
|
_ => null,
|
|
};
|
|
(key?.currentState as dynamic)?.refresh();
|
|
}
|
|
|
|
// Public method to fully reload all content (for profile switches)
|
|
@override
|
|
void fullRefresh() {
|
|
appLogger.d('LibrariesScreen.fullRefresh() called - reloading all content');
|
|
setState(() {
|
|
_selectedLibraryGlobalKey = null;
|
|
_selectedFilters.clear();
|
|
_items.clear();
|
|
_errorMessage = null;
|
|
});
|
|
|
|
// Reinitialize with current libraries from provider
|
|
_initializeWithLibraries();
|
|
}
|
|
|
|
Future<void> _toggleLibraryVisibility(PlexLibrary library) async {
|
|
final librariesProvider = context.read<LibrariesProvider>();
|
|
final hiddenLibrariesProvider = Provider.of<HiddenLibrariesProvider>(context, listen: false);
|
|
final isHidden = hiddenLibrariesProvider.hiddenLibraryKeys.contains(library.globalKey);
|
|
|
|
if (isHidden) {
|
|
await hiddenLibrariesProvider.unhideLibrary(library.globalKey);
|
|
} else {
|
|
// Check if we're hiding the currently selected library
|
|
final isCurrentlySelected = _selectedLibraryGlobalKey == library.globalKey;
|
|
|
|
await hiddenLibrariesProvider.hideLibrary(library.globalKey);
|
|
|
|
// If we just hid the selected library, select the first visible one
|
|
if (isCurrentlySelected) {
|
|
// Compute visible libraries after hiding
|
|
final allLibraries = librariesProvider.libraries;
|
|
final visibleLibraries = allLibraries
|
|
.where((lib) => !hiddenLibrariesProvider.hiddenLibraryKeys.contains(lib.globalKey))
|
|
.toList();
|
|
|
|
if (visibleLibraries.isNotEmpty) {
|
|
_loadLibraryContent(visibleLibraries.first.globalKey);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
List<ContextMenuItem> _getLibraryMenuItems(PlexLibrary library) {
|
|
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),
|
|
),
|
|
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,
|
|
),
|
|
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,
|
|
),
|
|
];
|
|
}
|
|
|
|
void _handleLibraryMenuAction(String action, PlexLibrary library) {
|
|
switch (action) {
|
|
case 'scan':
|
|
_scanLibrary(library);
|
|
break;
|
|
case 'analyze':
|
|
_analyzeLibrary(library);
|
|
break;
|
|
case 'refresh':
|
|
_refreshLibraryMetadata(library);
|
|
break;
|
|
case 'empty_trash':
|
|
_emptyLibraryTrash(library);
|
|
break;
|
|
}
|
|
}
|
|
|
|
void _showLibraryManagementSheet() {
|
|
final librariesProvider = context.read<LibrariesProvider>();
|
|
final hiddenLibrariesProvider = Provider.of<HiddenLibrariesProvider>(context, listen: false);
|
|
final allLibraries = librariesProvider.libraries;
|
|
|
|
if (PlatformDetector.isTV()) {
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) => _LibraryManagementSheet(
|
|
isDialog: true,
|
|
allLibraries: List.from(allLibraries),
|
|
hiddenLibraryKeys: hiddenLibrariesProvider.hiddenLibraryKeys,
|
|
onReorder: (reorderedLibraries) {
|
|
librariesProvider.updateLibraryOrder(reorderedLibraries);
|
|
_notifyLibraryOrderChanged();
|
|
},
|
|
onToggleVisibility: _toggleLibraryVisibility,
|
|
getLibraryMenuItems: _getLibraryMenuItems,
|
|
onLibraryMenuAction: _handleLibraryMenuAction,
|
|
),
|
|
);
|
|
} else {
|
|
OverlaySheetController.of(context).show(
|
|
builder: (context) => _LibraryManagementSheet(
|
|
allLibraries: List.from(allLibraries),
|
|
hiddenLibraryKeys: hiddenLibrariesProvider.hiddenLibraryKeys,
|
|
onReorder: (reorderedLibraries) {
|
|
librariesProvider.updateLibraryOrder(reorderedLibraries);
|
|
_notifyLibraryOrderChanged();
|
|
},
|
|
onToggleVisibility: _toggleLibraryVisibility,
|
|
getLibraryMenuItems: _getLibraryMenuItems,
|
|
onLibraryMenuAction: _handleLibraryMenuAction,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> _performLibraryAction({
|
|
required PlexLibrary library,
|
|
required Future<void> Function(PlexClient client) action,
|
|
required String progressMessage,
|
|
required String successMessage,
|
|
required String Function(Object error) failureMessage,
|
|
}) async {
|
|
try {
|
|
final client = context.getClientForLibrary(library);
|
|
|
|
if (mounted) {
|
|
showAppSnackBar(context, progressMessage, duration: const Duration(seconds: 2));
|
|
}
|
|
|
|
await action(client);
|
|
|
|
if (mounted) {
|
|
showSuccessSnackBar(context, successMessage);
|
|
}
|
|
} catch (e) {
|
|
appLogger.e('Library action failed', error: e);
|
|
if (mounted) {
|
|
showErrorSnackBar(context, failureMessage(e));
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _scanLibrary(PlexLibrary library) {
|
|
return _performLibraryAction(
|
|
library: library,
|
|
action: (client) => client.scanLibrary(library.key),
|
|
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(PlexLibrary library) {
|
|
return _performLibraryAction(
|
|
library: library,
|
|
action: (client) => client.refreshLibraryMetadata(library.key),
|
|
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(PlexLibrary library) {
|
|
return _performLibraryAction(
|
|
library: library,
|
|
action: (client) => client.emptyLibraryTrash(library.key),
|
|
progressMessage: t.libraries.emptyingTrash(title: library.title),
|
|
successMessage: t.libraries.trashEmptied(title: library.title),
|
|
failureMessage: (error) => t.libraries.failedToEmptyTrash(error: error),
|
|
);
|
|
}
|
|
|
|
Future<void> _analyzeLibrary(PlexLibrary library) {
|
|
return _performLibraryAction(
|
|
library: library,
|
|
action: (client) => client.analyzeLibrary(library.key),
|
|
progressMessage: t.libraries.analyzing(title: library.title),
|
|
successMessage: t.libraries.analysisStarted(title: library.title),
|
|
failureMessage: (error) => t.libraries.failedToAnalyze(error: error),
|
|
);
|
|
}
|
|
|
|
/// 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();
|
|
}
|
|
|
|
/// Build dropdown menu items with server subtitle for non-unique names
|
|
List<PopupMenuEntry<String>> _buildGroupedLibraryMenuItems(List<PlexLibrary> visibleLibraries) {
|
|
// Find which library names are not unique
|
|
final nonUniqueNames = _getNonUniqueLibraryNames(visibleLibraries);
|
|
|
|
return visibleLibraries.map((library) {
|
|
final isSelected = library.globalKey == _selectedLibraryGlobalKey;
|
|
final showServerName = nonUniqueNames.contains(library.title) && library.serverName != null;
|
|
|
|
return PopupMenuItem<String>(
|
|
value: library.globalKey,
|
|
child: Row(
|
|
children: [
|
|
AppIcon(
|
|
ContentTypeHelper.getLibraryIcon(library.type),
|
|
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),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}).toList();
|
|
}
|
|
|
|
Widget _buildTabChip(String label, int index) {
|
|
final isSelected = tabController.index == index;
|
|
|
|
return FocusableTabChip(
|
|
label: label,
|
|
isSelected: isSelected,
|
|
focusNode: getTabChipFocusNode(index),
|
|
onSelect: () {
|
|
if (isSelected) {
|
|
// Already selected - navigate to tab content
|
|
_focusCurrentTab();
|
|
} else {
|
|
// Switch to this tab
|
|
setState(() {
|
|
tabController.index = index;
|
|
});
|
|
}
|
|
},
|
|
onNavigateLeft: index > 0
|
|
? () {
|
|
final newIndex = index - 1;
|
|
setState(() {
|
|
suppressAutoFocus = true;
|
|
tabController.index = newIndex;
|
|
});
|
|
getTabChipFocusNode(newIndex).requestFocus();
|
|
}
|
|
: onTabBarBack,
|
|
onNavigateRight: index < tabCount - 1
|
|
? () {
|
|
final newIndex = index + 1;
|
|
setState(() {
|
|
suppressAutoFocus = true;
|
|
tabController.index = newIndex;
|
|
});
|
|
getTabChipFocusNode(newIndex).requestFocus();
|
|
}
|
|
: () {
|
|
_actionBarKey.currentState?.getFocusNode(0).requestFocus();
|
|
},
|
|
onNavigateDown: _focusCurrentTabFromTabBar,
|
|
onBack: onTabBarBack,
|
|
);
|
|
}
|
|
|
|
/// Build the app bar title - either dropdown on mobile or simple title on desktop
|
|
Widget _buildAppBarTitle(List<PlexLibrary> visibleLibraries) {
|
|
// No libraries or no selection
|
|
if (visibleLibraries.isEmpty || _selectedLibraryGlobalKey == null) {
|
|
return Text(t.libraries.title);
|
|
}
|
|
|
|
// On desktop/TV with side nav, show tabs in app bar (library name is in side nav)
|
|
if (PlatformDetector.shouldUseSideNavigation(context)) {
|
|
return Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
_buildTabChip(t.libraries.tabs.recommended, 0),
|
|
const SizedBox(width: 8),
|
|
_buildTabChip(t.libraries.tabs.browse, 1),
|
|
const SizedBox(width: 8),
|
|
_buildTabChip(t.libraries.tabs.collections, 2),
|
|
const SizedBox(width: 8),
|
|
_buildTabChip(t.libraries.tabs.playlists, 3),
|
|
],
|
|
);
|
|
}
|
|
|
|
// On mobile, show the dropdown
|
|
return _buildLibraryDropdownTitle(visibleLibraries);
|
|
}
|
|
|
|
Widget _buildLibraryDropdownTitle(List<PlexLibrary> visibleLibraries) {
|
|
final selectedLibrary = visibleLibraries.firstWhere(
|
|
(lib) => lib.globalKey == _selectedLibraryGlobalKey,
|
|
orElse: () => visibleLibraries.first,
|
|
);
|
|
|
|
return PopupMenuButton<String>(
|
|
key: _libraryDropdownKey,
|
|
offset: const Offset(0, 48),
|
|
tooltip: t.libraries.selectLibrary,
|
|
onSelected: (libraryGlobalKey) {
|
|
_loadLibraryContent(libraryGlobalKey);
|
|
},
|
|
itemBuilder: (context) => _buildGroupedLibraryMenuItems(visibleLibraries),
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
AppIcon(ContentTypeHelper.getLibraryIcon(selectedLibrary.type), fill: 1, size: 20),
|
|
const SizedBox(width: 8),
|
|
if (_hasMultipleServers(visibleLibraries) && selectedLibrary.serverName != null)
|
|
Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(selectedLibrary.title, style: Theme.of(context).textTheme.titleMedium),
|
|
Text(
|
|
selectedLibrary.serverName!,
|
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
|
color: Theme.of(context).textTheme.bodySmall?.color?.withValues(alpha: 0.6),
|
|
),
|
|
),
|
|
],
|
|
)
|
|
else
|
|
Text(selectedLibrary.title, style: Theme.of(context).textTheme.titleLarge),
|
|
const SizedBox(width: 4),
|
|
const AppIcon(Symbols.arrow_drop_down_rounded, fill: 1, size: 24),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
// Watch libraries provider for updates
|
|
final librariesProvider = context.watch<LibrariesProvider>();
|
|
final allLibraries = librariesProvider.libraries;
|
|
final isLoadingLibraries = librariesProvider.isLoading;
|
|
|
|
// Watch for hidden libraries changes to trigger rebuild
|
|
final hiddenLibrariesProvider = context.watch<HiddenLibrariesProvider>();
|
|
final hiddenKeys = hiddenLibrariesProvider.hiddenLibraryKeys;
|
|
|
|
// Compute visible libraries (filtered from all libraries)
|
|
final visibleLibraries = allLibraries.where((lib) => !hiddenKeys.contains(lib.globalKey)).toList();
|
|
|
|
return Scaffold(
|
|
body: ScrollConfiguration(
|
|
behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false),
|
|
child: CustomScrollView(
|
|
controller: _outerScrollController,
|
|
slivers: [
|
|
DesktopSliverAppBar(
|
|
title: _buildAppBarTitle(visibleLibraries),
|
|
pinned: true,
|
|
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
|
surfaceTintColor: Colors.transparent,
|
|
shadowColor: Colors.transparent,
|
|
scrolledUnderElevation: 0,
|
|
actions: [
|
|
FocusableActionBar(
|
|
key: _actionBarKey,
|
|
onNavigateLeft: () => getTabChipFocusNode(3).requestFocus(),
|
|
onNavigateDown: _focusCurrentTab,
|
|
actions: [
|
|
if (allLibraries.isNotEmpty)
|
|
FocusableAction(
|
|
icon: Symbols.edit_rounded,
|
|
tooltip: t.libraries.manageLibraries,
|
|
onPressed: _showLibraryManagementSheet,
|
|
),
|
|
FocusableAction(
|
|
icon: Symbols.refresh_rounded,
|
|
tooltip: t.common.refresh,
|
|
onPressed: _refreshCurrentTab,
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
if (isLoadingLibraries)
|
|
const SliverFillRemaining(child: Center(child: CircularProgressIndicator()))
|
|
else if (_errorMessage != null && visibleLibraries.isEmpty)
|
|
SliverFillRemaining(
|
|
child: ErrorStateWidget(
|
|
message: _errorMessage!,
|
|
icon: Symbols.error_outline_rounded,
|
|
onRetry: () {
|
|
final librariesProvider = context.read<LibrariesProvider>();
|
|
librariesProvider.refresh();
|
|
},
|
|
),
|
|
)
|
|
else if (visibleLibraries.isEmpty)
|
|
SliverFillRemaining(
|
|
child: EmptyStateWidget(message: t.libraries.noLibrariesFound, icon: Symbols.video_library_rounded),
|
|
)
|
|
else ...[
|
|
// Tab selector chips (only on mobile - desktop has them in app bar)
|
|
if (_selectedLibraryGlobalKey != null && !PlatformDetector.shouldUseSideNavigation(context))
|
|
SliverToBoxAdapter(
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
child: SingleChildScrollView(
|
|
scrollDirection: Axis.horizontal,
|
|
child: Row(
|
|
children: [
|
|
_buildTabChip(t.libraries.tabs.recommended, 0),
|
|
const SizedBox(width: 8),
|
|
_buildTabChip(t.libraries.tabs.browse, 1),
|
|
const SizedBox(width: 8),
|
|
_buildTabChip(t.libraries.tabs.collections, 2),
|
|
const SizedBox(width: 8),
|
|
_buildTabChip(t.libraries.tabs.playlists, 3),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
|
|
// Tab content
|
|
if (_selectedLibraryGlobalKey != null)
|
|
SliverFillRemaining(
|
|
child: TabBarView(
|
|
key: ValueKey(_selectedLibraryGlobalKey),
|
|
controller: tabController,
|
|
// Disable swipe on desktop - trackpad scrolling triggers accidental tab switches
|
|
// See: https://github.com/flutter/flutter/issues/11132
|
|
physics: PlatformDetector.isDesktop(context) ? const NeverScrollableScrollPhysics() : null,
|
|
// Wrap each tab in ClipRect so horizontal overflow (e.g. hub rows
|
|
// with Clip.none) doesn't bleed into adjacent tabs during swipe transitions.
|
|
// The TabBarView's own clipBehavior only clips at the viewport level,
|
|
// not per-page, so we need per-child clipping.
|
|
children: [
|
|
ClipRect(
|
|
child: LibraryRecommendedTab(
|
|
key: _recommendedTabKey,
|
|
library: allLibraries.firstWhere((lib) => lib.globalKey == _selectedLibraryGlobalKey),
|
|
isActive: tabController.index == 0,
|
|
suppressAutoFocus: suppressAutoFocus,
|
|
onDataLoaded: () => _handleTabDataLoaded(0),
|
|
onBack: focusTabBar,
|
|
),
|
|
),
|
|
ClipRect(
|
|
child: LibraryBrowseTab(
|
|
key: _browseTabKey,
|
|
library: allLibraries.firstWhere((lib) => lib.globalKey == _selectedLibraryGlobalKey),
|
|
isActive: tabController.index == 1,
|
|
suppressAutoFocus: suppressAutoFocus,
|
|
onDataLoaded: () => _handleTabDataLoaded(1),
|
|
onBack: focusTabBar,
|
|
),
|
|
),
|
|
ClipRect(
|
|
child: LibraryCollectionsTab(
|
|
key: _collectionsTabKey,
|
|
library: allLibraries.firstWhere((lib) => lib.globalKey == _selectedLibraryGlobalKey),
|
|
isActive: tabController.index == 2,
|
|
suppressAutoFocus: suppressAutoFocus,
|
|
onDataLoaded: () => _handleTabDataLoaded(2),
|
|
onBack: focusTabBar,
|
|
),
|
|
),
|
|
ClipRect(
|
|
child: LibraryPlaylistsTab(
|
|
key: _playlistsTabKey,
|
|
library: allLibraries.firstWhere((lib) => lib.globalKey == _selectedLibraryGlobalKey),
|
|
isActive: tabController.index == 3,
|
|
suppressAutoFocus: suppressAutoFocus,
|
|
onDataLoaded: () => _handleTabDataLoaded(3),
|
|
onBack: focusTabBar,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _LibraryManagementSheet extends StatefulWidget {
|
|
|
|
final bool isDialog;
|
|
final List<PlexLibrary> allLibraries;
|
|
final Set<String> hiddenLibraryKeys;
|
|
final Function(List<PlexLibrary>) onReorder;
|
|
final Function(PlexLibrary) onToggleVisibility;
|
|
final List<ContextMenuItem> Function(PlexLibrary) getLibraryMenuItems;
|
|
final void Function(String action, PlexLibrary 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> {
|
|
late List<PlexLibrary> _tempLibraries;
|
|
|
|
// Keyboard navigation state
|
|
int _focusedIndex = 0;
|
|
int _focusedColumn = 0; // 0 = row, 1 = visibility button, 2 = options button
|
|
int? _movingIndex; // Non-null when in move mode
|
|
int? _originalIndex; // Original position before move (for cancel)
|
|
List<PlexLibrary>? _originalOrder; // Original order before move (for cancel)
|
|
final FocusNode _listFocusNode = FocusNode();
|
|
final ScrollController _dialogScrollController = ScrollController();
|
|
bool _backKeyDownSeen = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_tempLibraries = List.from(widget.allLibraries);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_listFocusNode.dispose();
|
|
_dialogScrollController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _ensureFocusedVisible() {
|
|
if (!widget.isDialog) return;
|
|
if (!_dialogScrollController.hasClients) return;
|
|
|
|
const double itemHeight = 72.0; // Material ListTile with subtitle
|
|
const double listTopPadding = 8.0;
|
|
final double targetTop = listTopPadding + (_focusedIndex * itemHeight);
|
|
final double targetBottom = targetTop + itemHeight;
|
|
|
|
final double viewportTop = _dialogScrollController.offset;
|
|
final double viewportHeight = _dialogScrollController.position.viewportDimension;
|
|
final double viewportBottom = viewportTop + viewportHeight;
|
|
|
|
// Already fully visible — skip
|
|
if (targetTop >= viewportTop && targetBottom <= viewportBottom) return;
|
|
|
|
// Place item at ~25% from top of viewport
|
|
final double destination = (targetTop - viewportHeight * 0.25).clamp(
|
|
0.0,
|
|
_dialogScrollController.position.maxScrollExtent,
|
|
);
|
|
|
|
_dialogScrollController.animateTo(destination, duration: const Duration(milliseconds: 150), curve: Curves.easeOut);
|
|
}
|
|
|
|
KeyEventResult _handleKeyEvent(FocusNode _, KeyEvent event) {
|
|
final key = event.logicalKey;
|
|
|
|
// Track back key down/up pairing. If focus was elsewhere during KeyDown
|
|
// (e.g., on a bottom sheet) and returns here before KeyUp, we get a stray
|
|
// KeyUp that would incorrectly pop the dialog. Consume it instead.
|
|
if (key.isBackKey) {
|
|
if (event is KeyDownEvent) {
|
|
_backKeyDownSeen = true;
|
|
} else if (event is KeyUpEvent && !_backKeyDownSeen) {
|
|
return KeyEventResult.handled;
|
|
}
|
|
if (event is KeyUpEvent) {
|
|
_backKeyDownSeen = false;
|
|
}
|
|
}
|
|
|
|
final backResult = handleBackKeyAction(event, () {
|
|
if (_movingIndex != null) {
|
|
// Cancel move - restore original position
|
|
setState(() {
|
|
if (_originalOrder != null) {
|
|
_tempLibraries = List.from(_originalOrder!);
|
|
}
|
|
_focusedIndex = _originalIndex ?? 0;
|
|
_movingIndex = null;
|
|
_originalIndex = null;
|
|
_originalOrder = null;
|
|
});
|
|
} else {
|
|
Navigator.pop(context);
|
|
}
|
|
});
|
|
if (backResult != KeyEventResult.ignored) {
|
|
return backResult;
|
|
}
|
|
|
|
if (!event.isActionable) return KeyEventResult.ignored;
|
|
|
|
if (_movingIndex != null) {
|
|
// Move mode - arrows reorder the item
|
|
if (key.isUpKey && _movingIndex! > 0) {
|
|
setState(() {
|
|
final item = _tempLibraries.removeAt(_movingIndex!);
|
|
_tempLibraries.insert(_movingIndex! - 1, item);
|
|
_movingIndex = _movingIndex! - 1;
|
|
_focusedIndex = _movingIndex!;
|
|
});
|
|
_ensureFocusedVisible();
|
|
return KeyEventResult.handled;
|
|
}
|
|
if (key.isDownKey && _movingIndex! < _tempLibraries.length - 1) {
|
|
setState(() {
|
|
final item = _tempLibraries.removeAt(_movingIndex!);
|
|
_tempLibraries.insert(_movingIndex! + 1, item);
|
|
_movingIndex = _movingIndex! + 1;
|
|
_focusedIndex = _movingIndex!;
|
|
});
|
|
_ensureFocusedVisible();
|
|
return KeyEventResult.handled;
|
|
}
|
|
if (key.isSelectKey) {
|
|
// Confirm move - apply the reorder
|
|
widget.onReorder(_tempLibraries);
|
|
setState(() {
|
|
_movingIndex = null;
|
|
_originalIndex = null;
|
|
_originalOrder = null;
|
|
});
|
|
return KeyEventResult.handled;
|
|
}
|
|
} else {
|
|
// Navigation mode
|
|
if (key.isUpKey && _focusedIndex > 0) {
|
|
setState(() {
|
|
_focusedIndex--;
|
|
_focusedColumn = 0; // Reset to row when changing rows
|
|
});
|
|
_ensureFocusedVisible();
|
|
return KeyEventResult.handled;
|
|
}
|
|
if (key.isDownKey && _focusedIndex < _tempLibraries.length - 1) {
|
|
setState(() {
|
|
_focusedIndex++;
|
|
_focusedColumn = 0; // Reset to row when changing rows
|
|
});
|
|
_ensureFocusedVisible();
|
|
return KeyEventResult.handled;
|
|
}
|
|
if (key.isLeftKey && _focusedColumn > 0) {
|
|
setState(() => _focusedColumn--);
|
|
return KeyEventResult.handled;
|
|
}
|
|
if (key.isRightKey && _focusedColumn < 2) {
|
|
setState(() => _focusedColumn++);
|
|
return KeyEventResult.handled;
|
|
}
|
|
if (key.isSelectKey) {
|
|
if (_focusedColumn == 0) {
|
|
// Enter move mode
|
|
setState(() {
|
|
_movingIndex = _focusedIndex;
|
|
_originalIndex = _focusedIndex;
|
|
_originalOrder = List.from(_tempLibraries);
|
|
});
|
|
} else if (_focusedColumn == 1) {
|
|
// Toggle visibility
|
|
final library = _tempLibraries[_focusedIndex];
|
|
widget.onToggleVisibility(library);
|
|
} else if (_focusedColumn == 2) {
|
|
// Show options menu
|
|
final library = _tempLibraries[_focusedIndex];
|
|
_showLibraryMenuBottomSheet(context, library);
|
|
}
|
|
return KeyEventResult.handled;
|
|
}
|
|
}
|
|
|
|
// Block d-pad keys at boundaries so focus doesn't escape the dialog
|
|
if (key.isDpadDirection) {
|
|
return KeyEventResult.handled;
|
|
}
|
|
|
|
return KeyEventResult.ignored;
|
|
}
|
|
|
|
void _reorderLibraries(int oldIndex, int newIndex) {
|
|
setState(() {
|
|
if (newIndex > oldIndex) {
|
|
newIndex -= 1;
|
|
}
|
|
final library = _tempLibraries.removeAt(oldIndex);
|
|
_tempLibraries.insert(newIndex, library);
|
|
});
|
|
// Apply immediately
|
|
widget.onReorder(_tempLibraries);
|
|
}
|
|
|
|
Future<void> _showLibraryMenuBottomSheet(BuildContext outerContext, PlexLibrary library) async {
|
|
final menuItems = widget.getLibraryMenuItems(library);
|
|
final selected = await OverlaySheetController.pushAdaptive<String>(
|
|
outerContext,
|
|
builder: (context) => SafeArea(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Text(library.title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
|
),
|
|
...menuItems.indexed.map(
|
|
(entry) => ListTile(
|
|
leading: AppIcon(entry.$2.icon, fill: 1),
|
|
title: Text(entry.$2.label),
|
|
onTap: () => OverlaySheetController.popAdaptive(context, entry.$2.value),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
|
|
if (selected != null && mounted) {
|
|
// Find the selected item to check if confirmation is needed
|
|
final selectedItem = menuItems.firstWhere((item) => item.value == selected);
|
|
|
|
if (selectedItem.requiresConfirmation) {
|
|
if (!mounted || !context.mounted) return;
|
|
final confirmed = await showDialog<bool>(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: Text(selectedItem.confirmationTitle ?? t.dialog.confirmAction),
|
|
content: Text(selectedItem.confirmationMessage ?? t.libraries.confirmActionMessage),
|
|
actions: [
|
|
FocusableButton(
|
|
autofocus: true,
|
|
onPressed: () => Navigator.pop(context, false),
|
|
child: TextButton(
|
|
onPressed: () => Navigator.pop(context, false),
|
|
child: Text(t.common.cancel),
|
|
),
|
|
),
|
|
FocusableButton(
|
|
onPressed: () => Navigator.pop(context, true),
|
|
child: TextButton(
|
|
onPressed: () => Navigator.pop(context, true),
|
|
style: selectedItem.isDestructive ? TextButton.styleFrom(foregroundColor: Colors.red) : null,
|
|
child: Text(t.common.confirm),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
if (confirmed != true) return;
|
|
}
|
|
|
|
widget.onLibraryMenuAction(selected, library);
|
|
}
|
|
}
|
|
|
|
/// Get set of library names that appear more than once (not globally unique)
|
|
Set<String> _getNonUniqueLibraryNames() {
|
|
final nameCounts = <String, int>{};
|
|
for (final lib in _tempLibraries) {
|
|
nameCounts[lib.title] = (nameCounts[lib.title] ?? 0) + 1;
|
|
}
|
|
return nameCounts.entries.where((e) => e.value > 1).map((e) => e.key).toSet();
|
|
}
|
|
|
|
@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 _handleKeyEvent
|
|
// 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,
|
|
autofocus: InputModeTracker.isKeyboardMode(context),
|
|
onKeyEvent: _handleKeyEvent,
|
|
child: _buildFlatLibraryListDialog(hiddenLibraryKeys),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
return DraggableScrollableSheet(
|
|
initialChildSize: 0.7,
|
|
minChildSize: 0.5,
|
|
maxChildSize: 0.95,
|
|
expand: false,
|
|
builder: (context, scrollController) {
|
|
return Column(
|
|
children: [
|
|
// Header
|
|
Container(
|
|
padding: const EdgeInsets.all(16),
|
|
decoration: BoxDecoration(
|
|
border: Border(bottom: BorderSide(color: Theme.of(context).dividerColor)),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
const AppIcon(Symbols.edit_rounded, fill: 1),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Text(
|
|
t.libraries.manageLibraries,
|
|
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
|
),
|
|
),
|
|
IconButton(
|
|
icon: const AppIcon(Symbols.close_rounded, fill: 1),
|
|
onPressed: () => OverlaySheetController.popAdaptive(context),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
// Library list (grouped by server if multiple servers)
|
|
Expanded(
|
|
child: Focus(
|
|
focusNode: _listFocusNode,
|
|
autofocus: InputModeTracker.isKeyboardMode(context),
|
|
onKeyEvent: _handleKeyEvent,
|
|
child: _buildFlatLibraryList(scrollController, hiddenLibraryKeys),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
/// Build library list for dialog (TV) using ListView with scroll-into-view support
|
|
Widget _buildFlatLibraryListDialog(Set<String> hiddenLibraryKeys) {
|
|
final nonUniqueNames = _getNonUniqueLibraryNames();
|
|
final isKeyboardMode = InputModeTracker.isKeyboardMode(context);
|
|
|
|
return ReorderableListView.builder(
|
|
scrollController: _dialogScrollController,
|
|
onReorder: _reorderLibraries,
|
|
itemCount: _tempLibraries.length,
|
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
|
buildDefaultDragHandles: false,
|
|
itemBuilder: (context, index) {
|
|
final library = _tempLibraries[index];
|
|
final showServerName = nonUniqueNames.contains(library.title) && 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 flat library list with server subtitle for non-unique names
|
|
Widget _buildFlatLibraryList(ScrollController scrollController, Set<String> hiddenLibraryKeys) {
|
|
final nonUniqueNames = _getNonUniqueLibraryNames();
|
|
final isKeyboardMode = InputModeTracker.isKeyboardMode(context);
|
|
|
|
return ReorderableListView.builder(
|
|
scrollController: scrollController,
|
|
onReorder: _reorderLibraries,
|
|
itemCount: _tempLibraries.length,
|
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
|
buildDefaultDragHandles: false,
|
|
itemBuilder: (context, index) {
|
|
final library = _tempLibraries[index];
|
|
final showServerName = nonUniqueNames.contains(library.title) && 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(
|
|
PlexLibrary 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: Container(
|
|
decoration: BoxDecoration(color: tileColor),
|
|
child: ListTile(
|
|
leading: Row(
|
|
mainAxisSize: 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.type), 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: 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),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|