New media added server-side was invisible while the app was open - the client had no channel for library-change notifications, so the home screen and library tabs sat stale until an app restart. Each online server now runs a reconnecting websocket push channel (Plex `/:/websockets/notifications`; Jellyfin/Emby `/socket` with the dialect deltas, including Emby's capabilities registration), owned per server by a LibraryEventService supervisor that follows server online/offline/replaced-client transitions and app lifecycle. Channels emit one coalesced LibraryChangeEvent per burst on a leading-edge throttle modeled on Plex Web's repopulate pacing: the first settled change surfaces immediately and a library scan's flood merges behind it. Connection failure degrades silently to the stale-refresh paths - bounded reconnect backoff, re-armed on every status sync. Consumers pace their reaction through one shared RefreshPacer: burst debounce, blocked retry while video playback or an active scroll owns the surface, a cooldown bounding pass frequency during bulk imports, and credit for committed pull passes so a push landing right after a fresh load defers to the cooldown's trailing edge. - Discover runs a debounced full pass and swaps the result in place; the hero carousel resets only when a pass lands first content, so a push never yanks a screen the user is reading. Pushed removals drop from every visible list immediately via the deletion bus, scoped to the emitting server. - The visible library tab swaps its data in place: transaction tabs reload without clearing, and the browse and paginated card grids refetch their loaded span (Plex Web's repopulateRange) with the scroll offset anchored on the first visible item and the span clamped after alpha jumps. Hidden tabs - and tabs behind another main tab - mark per-library staleness epochs instead and reload when next shown; epochs are snapshotted at load start so a push racing an in-flight fetch stays stale. - The stale-resume and tab-shown paths refetch home hubs (previously Continue Watching only), covering setups where the socket cannot connect. close #1646
954 lines
35 KiB
Dart
954 lines
35 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:plezy/widgets/app_icon.dart';
|
|
import 'package:material_symbols_icons/symbols.dart';
|
|
import 'package:provider/provider.dart';
|
|
import '../../focus/focusable_action_bar.dart';
|
|
import '../../focus/dpad_navigator.dart';
|
|
import '../../focus/input_mode_tracker.dart';
|
|
import '../../mixins/tab_navigation_mixin.dart';
|
|
import '../../mixins/tab_visibility_aware.dart';
|
|
import '../../media/ids.dart';
|
|
import '../../media/media_library.dart';
|
|
import '../../providers/hidden_libraries_provider.dart';
|
|
import '../../providers/libraries_provider.dart';
|
|
import '../../providers/multi_server_provider.dart';
|
|
import '../../services/settings_service.dart';
|
|
import '../../widgets/settings_builder.dart';
|
|
import '../../utils/app_logger.dart';
|
|
import '../../utils/platform_detector.dart';
|
|
import '../../utils/content_utils.dart';
|
|
import '../../widgets/app_menu.dart';
|
|
import '../../widgets/desktop_app_bar.dart';
|
|
import '../../widgets/focusable_tab_chip.dart';
|
|
import '../../widgets/library_management_sheet.dart';
|
|
import '../../services/storage_service.dart';
|
|
import '../../mixins/refreshable.dart';
|
|
import '../../i18n/strings.g.dart';
|
|
import 'library_server_label.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';
|
|
import 'tabs/base_library_tab.dart';
|
|
|
|
enum LibraryTabType { recommended, browse, collections, playlists }
|
|
|
|
List<LibraryTabType> _getVisibleTabs(MediaLibrary library) {
|
|
if (library.isShared) return [LibraryTabType.browse, LibraryTabType.playlists];
|
|
return LibraryTabType.values;
|
|
}
|
|
|
|
class LibrariesScreen extends StatefulWidget {
|
|
final VoidCallback? onLibraryOrderChanged;
|
|
final ValueChanged<String>? onLibrarySelected;
|
|
|
|
const LibrariesScreen({super.key, this.onLibraryOrderChanged, this.onLibrarySelected});
|
|
|
|
@override
|
|
State<LibrariesScreen> createState() => _LibrariesScreenState();
|
|
}
|
|
|
|
class _LibrariesScreenState extends State<LibrariesScreen>
|
|
with
|
|
Refreshable,
|
|
FullRefreshable,
|
|
FocusableTab,
|
|
LibraryLoadable,
|
|
TickerProviderStateMixin,
|
|
TabNavigationMixin,
|
|
TabVisibilityAware {
|
|
final _recommendedTabKey = GlobalKey();
|
|
final _browseTabKey = GlobalKey();
|
|
final _collectionsTabKey = GlobalKey();
|
|
final _playlistsTabKey = GlobalKey();
|
|
|
|
String? _errorMessage;
|
|
String? _selectedLibraryGlobalKey;
|
|
|
|
/// 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 = {};
|
|
|
|
/// Whether the browse tab has active filters (badges the Library options icon)
|
|
bool _browseFiltersActive = false;
|
|
|
|
/// Key for the library dropdown menu button.
|
|
final _libraryDropdownKey = GlobalKey<AppMenuButtonState<String>>();
|
|
|
|
List<LibraryTabType> _visibleTabs = LibraryTabType.values;
|
|
List<FocusNode> _tabFocusNodes = List.generate(
|
|
LibraryTabType.values.length,
|
|
(i) => FocusNode(debugLabel: 'tab_chip_${LibraryTabType.values[i].name}'),
|
|
);
|
|
|
|
@override
|
|
List<FocusNode> get tabChipFocusNodes => _tabFocusNodes;
|
|
|
|
final _actionBarKey = GlobalKey<FocusableActionBarState>();
|
|
|
|
final ScrollController _outerScrollController = ScrollController();
|
|
|
|
/// Reveal the floating header by jumping the outer NestedScrollView back
|
|
/// to offset 0. The outer position is preserved across content changes
|
|
/// (library switch, library reload, filter/sort change), so any time the
|
|
/// inner is reset to the top we must explicitly resync the outer — the
|
|
/// natural delta-surrender coordination only fires on user scroll gestures.
|
|
///
|
|
/// Iterates `positions` rather than reading `offset` because the controller
|
|
/// is shared between the simple CustomScrollView (loading/empty/error) and
|
|
/// the NestedScrollView (selected library), and during the transition both
|
|
/// can briefly be attached — `offset` would throw on `_positions.single`.
|
|
void _resetOuterScroll() {
|
|
if (!_outerScrollController.hasClients) return;
|
|
for (final position in _outerScrollController.positions) {
|
|
if (position.pixels > 0) {
|
|
position.jumpTo(0);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Override the mixin's [focusTabBar] so we reveal the floating header
|
|
/// (which contains the tab chips) before requesting focus. Programmatic
|
|
/// requestFocus alone does not snap a floating SliverAppBar back into view.
|
|
@override
|
|
void focusTabBar() {
|
|
_resetOuterScroll();
|
|
super.focusTabBar();
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
initTabNavigation();
|
|
|
|
// Initialize with libraries from the provider
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (!mounted) return;
|
|
_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>();
|
|
await hiddenLibrariesProvider.ensureInitialized();
|
|
final allLibraries = librariesProvider.libraries;
|
|
|
|
if (allLibraries.isEmpty) {
|
|
// No libraries available yet
|
|
return;
|
|
}
|
|
|
|
final hiddenKeys = hiddenLibrariesProvider.hiddenLibraryKeys;
|
|
final visibleLibraries = allLibraries.where((lib) => !hiddenKeys.contains(lib.globalKey)).toList();
|
|
|
|
final storage = await StorageService.getInstance();
|
|
final savedLibraryKey = storage.getSelectedLibraryKey();
|
|
|
|
String? libraryGlobalKeyToLoad;
|
|
if (savedLibraryKey != null) {
|
|
final libraryExists = visibleLibraries.any((lib) => lib.globalKey == savedLibraryKey);
|
|
if (libraryExists) {
|
|
libraryGlobalKeyToLoad = savedLibraryKey;
|
|
}
|
|
}
|
|
|
|
if (libraryGlobalKeyToLoad == null && visibleLibraries.isNotEmpty) {
|
|
libraryGlobalKeyToLoad = visibleLibraries.first.globalKey;
|
|
}
|
|
|
|
if (libraryGlobalKeyToLoad != null && mounted) {
|
|
unawaited(_loadLibraryContent(libraryGlobalKeyToLoad));
|
|
}
|
|
}
|
|
|
|
@override
|
|
void onTabChanged() {
|
|
if (_selectedLibraryGlobalKey != null && !tabController.indexIsChanging) {
|
|
if (!_isRestoringTab) {
|
|
StorageService.getInstance().then((storage) {
|
|
storage.saveLibraryTab(_selectedLibraryGlobalKey!, _visibleTabs[tabController.index].name);
|
|
});
|
|
|
|
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).focusContentOrChrome();
|
|
} 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).focusContentOrChrome();
|
|
}
|
|
}
|
|
|
|
/// 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
|
|
_resetOuterScroll();
|
|
|
|
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 (_visibleTabs[tabController.index] == LibraryTabType.browse) {
|
|
(tabState as dynamic).focusChipsBar();
|
|
} else {
|
|
(tabState as dynamic).focusContentOrChrome();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Get the state for a tab by index
|
|
State? _getTabState(int index) {
|
|
if (index < 0 || index >= _visibleTabs.length) return null;
|
|
return switch (_visibleTabs[index]) {
|
|
LibraryTabType.recommended => _recommendedTabKey.currentState,
|
|
LibraryTabType.browse => _browseTabKey.currentState,
|
|
LibraryTabType.collections => _collectionsTabKey.currentState,
|
|
LibraryTabType.playlists => _playlistsTabKey.currentState,
|
|
};
|
|
}
|
|
|
|
void _showBrowseOptionsForCurrentTab() {
|
|
if (_visibleTabs.isEmpty) return;
|
|
final index = tabController.index.clamp(0, _visibleTabs.length - 1).toInt();
|
|
if (_visibleTabs[index] != LibraryTabType.browse) return;
|
|
final tabState = _browseTabKey.currentState;
|
|
if (tabState == null) return;
|
|
(tabState as dynamic).showBrowseOptionsSheet();
|
|
}
|
|
|
|
/// Handle when the browse tab's active-filter state changes
|
|
void _handleBrowseFiltersActiveChanged(bool active) {
|
|
if (_browseFiltersActive == active) return;
|
|
setState(() => _browseFiltersActive = active);
|
|
}
|
|
|
|
/// Handle when a tab's data has finished loading
|
|
void _handleTabDataLoaded(int tabIndex) {
|
|
_loadedTabs.add(tabIndex);
|
|
|
|
if (suppressAutoFocus) return;
|
|
|
|
if (tabController.index == tabIndex && mounted) {
|
|
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() {
|
|
_outerScrollController.dispose();
|
|
for (final node in _tabFocusNodes) {
|
|
node.dispose();
|
|
}
|
|
disposeTabNavigation();
|
|
super.dispose();
|
|
}
|
|
|
|
void _updateState(VoidCallback fn) {
|
|
if (!mounted) return;
|
|
setState(fn);
|
|
}
|
|
|
|
/// Rebuild tab infrastructure when the visible tab set changes.
|
|
void _updateVisibleTabs(List<LibraryTabType> newTabs) {
|
|
if (listEquals(_visibleTabs, newTabs)) return;
|
|
|
|
final currentTabType = _visibleTabs.length > tabController.index ? _visibleTabs[tabController.index] : null;
|
|
|
|
for (final node in _tabFocusNodes) {
|
|
node.dispose();
|
|
}
|
|
disposeTabNavigation();
|
|
|
|
_visibleTabs = newTabs;
|
|
_tabFocusNodes = List.generate(newTabs.length, (i) => FocusNode(debugLabel: 'tab_chip_${newTabs[i].name}'));
|
|
initTabNavigation();
|
|
|
|
final newIndex = currentTabType != null ? newTabs.indexOf(currentTabType) : -1;
|
|
if (newIndex > 0) {
|
|
tabController.index = newIndex;
|
|
}
|
|
}
|
|
|
|
String _getTabLabel(LibraryTabType type) => switch (type) {
|
|
LibraryTabType.recommended => t.libraries.tabs.recommended,
|
|
LibraryTabType.browse => t.libraries.tabs.browse,
|
|
LibraryTabType.collections => t.libraries.tabs.collections,
|
|
LibraryTabType.playlists => t.libraries.tabs.playlists,
|
|
};
|
|
|
|
Widget _buildTabContent(
|
|
LibraryTabType type, {
|
|
required MediaLibrary library,
|
|
required bool canGroupByFolders,
|
|
required bool isActive,
|
|
required int tabIndex,
|
|
}) {
|
|
return switch (type) {
|
|
LibraryTabType.recommended => LibraryRecommendedTab(
|
|
key: _recommendedTabKey,
|
|
library: library,
|
|
isActive: isActive,
|
|
suppressAutoFocus: suppressAutoFocus,
|
|
onDataLoaded: () => _handleTabDataLoaded(tabIndex),
|
|
onBack: focusTabBar,
|
|
onNavigateToChrome: focusTabBar,
|
|
),
|
|
LibraryTabType.browse => LibraryBrowseTab(
|
|
key: _browseTabKey,
|
|
library: library,
|
|
canGroupByFolders: canGroupByFolders,
|
|
isActive: isActive,
|
|
suppressAutoFocus: suppressAutoFocus,
|
|
onDataLoaded: () => _handleTabDataLoaded(tabIndex),
|
|
onBack: focusTabBar,
|
|
onResetScroll: _resetOuterScroll,
|
|
onFiltersActiveChanged: _handleBrowseFiltersActiveChanged,
|
|
),
|
|
LibraryTabType.collections => LibraryCollectionsTab(
|
|
key: _collectionsTabKey,
|
|
library: library,
|
|
isActive: isActive,
|
|
suppressAutoFocus: suppressAutoFocus,
|
|
onDataLoaded: () => _handleTabDataLoaded(tabIndex),
|
|
onBack: focusTabBar,
|
|
),
|
|
LibraryTabType.playlists => LibraryPlaylistsTab(
|
|
key: _playlistsTabKey,
|
|
library: library,
|
|
isActive: isActive,
|
|
suppressAutoFocus: suppressAutoFocus,
|
|
onDataLoaded: () => _handleTabDataLoaded(tabIndex),
|
|
onBack: focusTabBar,
|
|
),
|
|
};
|
|
}
|
|
|
|
/// 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 {
|
|
final librariesProvider = context.read<LibrariesProvider>();
|
|
final allLibraries = librariesProvider.libraries;
|
|
|
|
// Resolve from allLibraries — hidden libraries are still navigable from the
|
|
// sidebar's "Hidden libraries" section.
|
|
final selectedLibrary = allLibraries.where((lib) => lib.globalKey == libraryGlobalKey).firstOrNull;
|
|
if (selectedLibrary == null) return;
|
|
|
|
final isLibraryChange = _selectedLibraryGlobalKey != libraryGlobalKey;
|
|
|
|
// Update visible tabs and state in the same synchronous block so no
|
|
// intermediate rebuild can see a mismatched controller/key pair.
|
|
_updateVisibleTabs(_getVisibleTabs(selectedLibrary));
|
|
|
|
_updateState(() {
|
|
_selectedLibraryGlobalKey = libraryGlobalKey;
|
|
_errorMessage = null;
|
|
// 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
|
|
// newly-active tab is not browse (which would otherwise have no inner
|
|
// jumpTo to catch via the browse-tab callback).
|
|
if (isLibraryChange) {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (!mounted) return;
|
|
_resetOuterScroll();
|
|
});
|
|
}
|
|
|
|
// Save selected library key and restore saved tab (async — safe after state is consistent)
|
|
final storage = await StorageService.getInstance();
|
|
if (!mounted || _selectedLibraryGlobalKey != libraryGlobalKey) return;
|
|
await storage.saveSelectedLibraryKey(libraryGlobalKey);
|
|
if (!mounted || _selectedLibraryGlobalKey != libraryGlobalKey) return;
|
|
|
|
// Restore saved tab by name
|
|
final savedTabName = storage.getLibraryTab(libraryGlobalKey);
|
|
final savedType = LibraryTabType.values.where((t) => t.name == savedTabName).firstOrNull;
|
|
final targetTabIndex = savedType != null ? _visibleTabs.indexOf(savedType) : -1;
|
|
if (targetTabIndex >= 0 && targetTabIndex != tabController.index) {
|
|
// Set flag to prevent _onTabChanged from triggering focus
|
|
_isRestoringTab = true;
|
|
// Use animateTo with zero duration for instant switch without animation race conditions
|
|
tabController.animateTo(targetTabIndex, 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();
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Refetch content in place (stale-resume sweep from MainScreen, #2043).
|
|
///
|
|
/// With a library on screen this refetches its instantiated tabs — the same
|
|
/// action as the toolbar refresh button. Selecting the saved library again
|
|
/// would not: the tab widgets are keyed on stable GlobalKeys and only reload
|
|
/// when the library's globalKey changes. Without a selection yet (provider
|
|
/// was empty at startup) it re-runs initialization instead.
|
|
@override
|
|
void refresh() {
|
|
if (_selectedLibraryGlobalKey == null) {
|
|
_initializeWithLibraries();
|
|
return;
|
|
}
|
|
_refreshSelectedLibraryTabs();
|
|
}
|
|
|
|
/// Returning to the Libraries tab consumes push-marked staleness (#1646)
|
|
/// for the visible library tab only; hidden tabs consume on their own
|
|
/// activation, and a library switch reloads unconditionally anyway.
|
|
@override
|
|
void onTabShown() {
|
|
if (_selectedLibraryGlobalKey == null) return;
|
|
final Object? tabState = _getTabState(tabController.index);
|
|
if (tabState is BaseLibraryTabState) tabState.refreshIfLibraryContentStale();
|
|
}
|
|
|
|
@override
|
|
// ignore: no-empty-block - visibility mixin contract; nothing to pause.
|
|
void onTabHidden() {}
|
|
|
|
// Refresh every loaded tab for the selected library.
|
|
void _refreshSelectedLibraryTabs() {
|
|
for (var i = 0; i < _visibleTabs.length; i++) {
|
|
final Object? tabState = _getTabState(i);
|
|
if (tabState is Refreshable) {
|
|
tabState.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;
|
|
_errorMessage = null;
|
|
});
|
|
|
|
// Reinitialize with current libraries from provider
|
|
_initializeWithLibraries();
|
|
}
|
|
|
|
Future<void> _toggleLibraryVisibility(MediaLibrary library) async {
|
|
if (!mounted) return;
|
|
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) {
|
|
unawaited(_loadLibraryContent(visibleLibraries.first.globalKey));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
void _showLibraryManagementSheet() {
|
|
showLibraryManagementSheet(
|
|
context,
|
|
onOrderChanged: _notifyLibraryOrderChanged,
|
|
onToggleVisibility: _toggleLibraryVisibility,
|
|
);
|
|
}
|
|
|
|
AppMenuHeader<String> _buildLibraryServerHeaderMenuItem(MediaLibrary library, String serverKey) {
|
|
return AppMenuHeader<String>(
|
|
child: LibraryServerLabel(
|
|
library: library,
|
|
fallbackServerName: serverKey,
|
|
badgeSize: 12,
|
|
style: libraryServerHeaderStyle(context),
|
|
constrainText: true,
|
|
),
|
|
);
|
|
}
|
|
|
|
AppMenuItem<String> _buildLibraryMenuItem(MediaLibrary library, {required bool showServerName}) {
|
|
final isSelected = library.globalKey == _selectedLibraryGlobalKey;
|
|
return AppMenuItem<String>(
|
|
value: library.globalKey,
|
|
icon: ContentTypeHelper.getLibraryIcon(library.kind.id),
|
|
label: library.title,
|
|
selected: isSelected,
|
|
subtitleWidget: showServerName
|
|
? LibraryServerLabel(
|
|
library: library,
|
|
badgeSize: 10,
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
color: Theme.of(context).textTheme.bodySmall?.color?.withValues(alpha: 0.6),
|
|
),
|
|
constrainText: true,
|
|
)
|
|
: null,
|
|
);
|
|
}
|
|
|
|
/// Build dropdown menu entries via the shared server-label policy.
|
|
List<AppMenuEntry<String>> _buildGroupedLibraryMenuItems(
|
|
List<MediaLibrary> visibleLibraries, {
|
|
required bool groupByServer,
|
|
}) {
|
|
return buildLibraryServerEntries<AppMenuEntry<String>>(
|
|
visibleLibraries,
|
|
groupByServer: groupByServer,
|
|
buildHeader: _buildLibraryServerHeaderMenuItem,
|
|
buildItem: (library, {required bool showServerName}) =>
|
|
_buildLibraryMenuItem(library, showServerName: showServerName),
|
|
);
|
|
}
|
|
|
|
/// Build the app bar title - either dropdown on mobile or simple title on desktop
|
|
Widget _buildAppBarTitle(
|
|
List<MediaLibrary> visibleLibraries,
|
|
MediaLibrary? selectedLibrary, {
|
|
required bool groupByServer,
|
|
}) {
|
|
// No selection at all, or visible list is empty AND we're not browsing a hidden library
|
|
if (_selectedLibraryGlobalKey == null || (visibleLibraries.isEmpty && selectedLibrary == 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 TabChipStrip(
|
|
children: [
|
|
for (int i = 0; i < _visibleTabs.length; i++) ...[
|
|
if (i > 0) const SizedBox(width: 8),
|
|
buildTabChip(
|
|
_getTabLabel(_visibleTabs[i]),
|
|
i,
|
|
onSelectWhenActive: _focusCurrentTab,
|
|
onNavigateDown: _focusCurrentTabFromTabBar,
|
|
onNavigateToActions: () => _actionBarKey.currentState?.requestFocusOnFirst(),
|
|
),
|
|
],
|
|
],
|
|
);
|
|
}
|
|
|
|
// On mobile, show the dropdown
|
|
return _buildLibraryDropdownTitle(visibleLibraries, groupByServer: groupByServer);
|
|
}
|
|
|
|
Widget _buildLibraryDropdownTitle(List<MediaLibrary> visibleLibraries, {required bool groupByServer}) {
|
|
final selectedLibrary =
|
|
visibleLibraries.where((lib) => lib.globalKey == _selectedLibraryGlobalKey).firstOrNull ??
|
|
visibleLibraries.firstOrNull;
|
|
if (selectedLibrary == null) return Text(t.libraries.title);
|
|
|
|
return AppMenuButton<String>(
|
|
key: _libraryDropdownKey,
|
|
tooltip: t.libraries.selectLibrary,
|
|
adaptiveSheet: true,
|
|
onSelected: (libraryGlobalKey) {
|
|
_loadLibraryContent(libraryGlobalKey);
|
|
},
|
|
entriesBuilder: (context) => _buildGroupedLibraryMenuItems(visibleLibraries, groupByServer: groupByServer),
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
|
child: Row(
|
|
mainAxisSize: .min,
|
|
children: [
|
|
AppIcon(ContentTypeHelper.getLibraryIcon(selectedLibrary.kind.id), fill: 1, size: 20),
|
|
const SizedBox(width: 8),
|
|
if (librariesSpanMultipleServers(visibleLibraries) && selectedLibrary.serverName != null)
|
|
Column(
|
|
crossAxisAlignment: .start,
|
|
mainAxisSize: .min,
|
|
children: [
|
|
Text(selectedLibrary.title, style: Theme.of(context).textTheme.titleMedium),
|
|
LibraryServerLabel(
|
|
library: selectedLibrary,
|
|
badgeSize: 10,
|
|
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) {
|
|
return SettingValueBuilder<bool>(
|
|
pref: SettingsService.groupLibrariesByServer,
|
|
builder: (context, groupByServerSetting, _) => _buildContent(context, groupByServerSetting),
|
|
);
|
|
}
|
|
|
|
Widget _buildContent(BuildContext context, bool groupByServerSetting) {
|
|
// 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();
|
|
|
|
// Resolve selected library defensively — may be null if server temporarily dropped during refresh
|
|
final selectedLibrary = _selectedLibraryGlobalKey != null
|
|
? allLibraries.where((lib) => lib.globalKey == _selectedLibraryGlobalKey).firstOrNull
|
|
: null;
|
|
|
|
final useSideNavigation = PlatformDetector.shouldUseSideNavigation(context);
|
|
final showMobileTabsRow = selectedLibrary != null && !useSideNavigation;
|
|
final currentTabIndex = _visibleTabs.isEmpty ? 0 : tabController.index.clamp(0, _visibleTabs.length - 1).toInt();
|
|
final currentTabType = _visibleTabs.isEmpty ? null : _visibleTabs[currentTabIndex];
|
|
final useTvRecommendedBackdrop = PlatformDetector.isTV() && currentTabType == LibraryTabType.recommended;
|
|
final showBrowseOptionsAction =
|
|
selectedLibrary != null && PlatformDetector.isMobile(context) && currentTabType == LibraryTabType.browse;
|
|
final canSelectedLibraryGroupByFolders = context.select<MultiServerProvider, bool>((provider) {
|
|
if (selectedLibrary == null || selectedLibrary.isShared) return false;
|
|
final serverId = serverIdOrNull(selectedLibrary.serverId);
|
|
if (serverId == null) return false;
|
|
return provider.getClientForServer(serverId)?.capabilities.folderGrouping ?? false;
|
|
});
|
|
|
|
List<FocusableAction> appBarActions() => [
|
|
if (allLibraries.isNotEmpty)
|
|
FocusableAction(
|
|
icon: Symbols.edit_rounded,
|
|
tooltip: t.libraries.manageLibraries,
|
|
onPressed: _showLibraryManagementSheet,
|
|
),
|
|
if (showBrowseOptionsAction)
|
|
FocusableAction(
|
|
icon: Symbols.tune_rounded,
|
|
tooltip: t.libraries.libraryOptions,
|
|
onPressed: _showBrowseOptionsForCurrentTab,
|
|
// Badge the icon with a dot while the browse tab has active filters
|
|
// (issue #1470). A null child keeps the default rendering.
|
|
child: _browseFiltersActive
|
|
? IconButton(
|
|
tooltip: t.libraries.libraryOptions,
|
|
onPressed: _showBrowseOptionsForCurrentTab,
|
|
icon: Stack(
|
|
clipBehavior: Clip.none,
|
|
children: [
|
|
const AppIcon(Symbols.tune_rounded, fill: 1),
|
|
Positioned(
|
|
top: -2,
|
|
right: -2,
|
|
child: Container(
|
|
width: 8,
|
|
height: 8,
|
|
decoration: BoxDecoration(
|
|
color: Theme.of(context).colorScheme.primary,
|
|
shape: BoxShape.circle,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
)
|
|
: null,
|
|
),
|
|
FocusableAction(icon: Symbols.refresh_rounded, tooltip: t.common.refresh, onPressed: _refreshSelectedLibraryTabs),
|
|
];
|
|
|
|
Widget appBar({required bool floating}) => DesktopSliverAppBar(
|
|
title: _buildAppBarTitle(visibleLibraries, selectedLibrary, groupByServer: groupByServerSetting),
|
|
// When showing the tab content, let the app bar float away with the
|
|
// content. Otherwise (loading / empty / error states) keep it pinned so
|
|
// it stays visible over the centered state widget.
|
|
pinned: !floating,
|
|
floating: floating,
|
|
snap: floating,
|
|
backgroundColor: useTvRecommendedBackdrop ? Colors.transparent : Theme.of(context).scaffoldBackgroundColor,
|
|
surfaceTintColor: Colors.transparent,
|
|
shadowColor: Colors.transparent,
|
|
scrolledUnderElevation: 0,
|
|
actions: [
|
|
FocusableActionBar(
|
|
key: _actionBarKey,
|
|
onNavigateLeft: () => getTabChipFocusNode(_visibleTabs.length - 1).requestFocus(),
|
|
onNavigateDown: _focusCurrentTab,
|
|
actions: appBarActions(),
|
|
),
|
|
],
|
|
);
|
|
|
|
Widget buildSimpleScroll({required Widget body}) {
|
|
return CustomScrollView(
|
|
controller: _outerScrollController,
|
|
slivers: [
|
|
appBar(floating: false),
|
|
SliverFillRemaining(child: body),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget buildTransparentTvTopBar() {
|
|
return SafeArea(
|
|
bottom: false,
|
|
child: AppBar(
|
|
primary: false,
|
|
backgroundColor: Colors.transparent,
|
|
surfaceTintColor: Colors.transparent,
|
|
shadowColor: Colors.transparent,
|
|
elevation: 0,
|
|
scrolledUnderElevation: 0,
|
|
title: _buildAppBarTitle(visibleLibraries, selectedLibrary, groupByServer: groupByServerSetting),
|
|
actions: [
|
|
FocusableActionBar(
|
|
key: _actionBarKey,
|
|
onNavigateLeft: () => getTabChipFocusNode(_visibleTabs.length - 1).requestFocus(),
|
|
onNavigateDown: _focusCurrentTab,
|
|
actions: appBarActions(),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget body;
|
|
if (isLoadingLibraries) {
|
|
body = buildSimpleScroll(body: const Center(child: CircularProgressIndicator()));
|
|
} else if (_errorMessage != null && visibleLibraries.isEmpty && selectedLibrary == null) {
|
|
body = buildSimpleScroll(
|
|
body: ErrorStateWidget(
|
|
message: _errorMessage!,
|
|
icon: Symbols.error_outline_rounded,
|
|
onRetry: () {
|
|
final librariesProvider = context.read<LibrariesProvider>();
|
|
librariesProvider.refresh();
|
|
},
|
|
),
|
|
);
|
|
} else if (visibleLibraries.isEmpty && selectedLibrary == null) {
|
|
body = buildSimpleScroll(
|
|
body: allLibraries.isEmpty
|
|
? EmptyStateWidget(message: t.libraries.noLibrariesFound, icon: Symbols.video_library_rounded)
|
|
: EmptyStateWidget(
|
|
message: t.libraries.allLibrariesHidden,
|
|
icon: Symbols.visibility_off_rounded,
|
|
onAction: _showLibraryManagementSheet,
|
|
actionLabel: t.libraries.manageLibraries,
|
|
actionIcon: Symbols.edit_rounded,
|
|
),
|
|
);
|
|
} else if (selectedLibrary != null) {
|
|
Widget buildTab(int index) {
|
|
final tabContent = _buildTabContent(
|
|
_visibleTabs[index],
|
|
library: selectedLibrary,
|
|
canGroupByFolders: canSelectedLibraryGroupByFolders,
|
|
isActive: tabController.index == index,
|
|
tabIndex: index,
|
|
);
|
|
if (useTvRecommendedBackdrop) return tabContent;
|
|
|
|
return ClipRect(child: tabContent);
|
|
}
|
|
|
|
Widget buildTabs({bool activeOnly = false}) {
|
|
if (activeOnly) return buildTab(currentTabIndex);
|
|
|
|
final children = [for (int i = 0; i < _visibleTabs.length; i++) buildTab(i)];
|
|
|
|
return TabBarView(
|
|
key: ValueKey(_selectedLibraryGlobalKey),
|
|
controller: tabController,
|
|
// Disable swipe on desktop/TV - trackpad and d-pad scroll actions can trigger accidental tab switches.
|
|
// See: https://github.com/flutter/flutter/issues/11132
|
|
physics: useSideNavigation ? 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.
|
|
children: children,
|
|
);
|
|
}
|
|
|
|
if (useTvRecommendedBackdrop) {
|
|
body = Focus(
|
|
canRequestFocus: false,
|
|
skipTraversal: true,
|
|
onKeyEvent: (_, event) => event.logicalKey.isDpadDirection ? KeyEventResult.handled : KeyEventResult.ignored,
|
|
child: Stack(
|
|
fit: StackFit.expand,
|
|
clipBehavior: Clip.none,
|
|
children: [
|
|
buildTabs(activeOnly: true),
|
|
Positioned(top: 0, left: 0, right: 0, child: ExcludeFocusTraversal(child: buildTransparentTvTopBar())),
|
|
],
|
|
),
|
|
);
|
|
} else {
|
|
body = NestedScrollView(
|
|
controller: _outerScrollController,
|
|
floatHeaderSlivers: true,
|
|
headerSliverBuilder: (context, innerBoxIsScrolled) => [
|
|
SliverOverlapAbsorber(
|
|
handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
|
|
sliver: appBar(floating: true),
|
|
),
|
|
if (showMobileTabsRow)
|
|
SliverToBoxAdapter(
|
|
child: Container(
|
|
color: Theme.of(context).scaffoldBackgroundColor,
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
|
child: SingleChildScrollView(
|
|
scrollDirection: Axis.horizontal,
|
|
child: Row(
|
|
children: [
|
|
for (int i = 0; i < _visibleTabs.length; i++) ...[
|
|
if (i > 0) const SizedBox(width: 8),
|
|
buildTabChip(
|
|
_getTabLabel(_visibleTabs[i]),
|
|
i,
|
|
onSelectWhenActive: _focusCurrentTab,
|
|
onNavigateDown: _focusCurrentTabFromTabBar,
|
|
onNavigateToActions: () => _actionBarKey.currentState?.requestFocusOnFirst(),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
body: buildTabs(),
|
|
);
|
|
}
|
|
} else {
|
|
body = buildSimpleScroll(body: const SizedBox.shrink());
|
|
}
|
|
|
|
final scrollBody = ScrollConfiguration(
|
|
behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false),
|
|
child: body,
|
|
);
|
|
|
|
return Scaffold(body: scrollBody);
|
|
}
|
|
}
|