Files
plezy/lib/screens/libraries/libraries_screen.dart
edde746 5a666fd10f feat(discover): refresh home and library content from server library push events
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
2026-08-31 18:52:03 +02:00

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);
}
}