From 551be5bbef35204a770531eeac8292d6965b40a3 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 15 Mar 2026 21:36:17 +0100 Subject: [PATCH] feat: dynamic library tabs and shared library navigation - Replace hardcoded 4-tab system with data-driven visible tab list - Shared libraries show only Browse + Playlists tabs - Fix dpad navigation for shared libraries (no hidden tab targets) - Switch to TickerProviderStateMixin to support tab controller recreation - Navigate to library screen when tapping shared library sections - Persist tab selection by name instead of index --- lib/focus/focusable_wrapper.dart | 8 +- lib/mixins/tab_navigation_mixin.dart | 2 +- lib/models/plex_library.dart | 7 + lib/models/plex_metadata.dart | 12 + lib/screens/downloads/downloads_screen.dart | 2 +- lib/screens/libraries/libraries_screen.dart | 235 ++++++++++-------- .../libraries/tabs/library_browse_tab.dart | 60 +++-- lib/screens/livetv/live_tv_screen.dart | 2 +- lib/screens/main_screen.dart | 24 +- lib/services/plex_client.dart | 121 +++++---- lib/services/storage_service.dart | 17 +- lib/utils/content_utils.dart | 2 + lib/utils/media_navigation_helper.dart | 16 ++ lib/widgets/media_card.dart | 1 + lib/widgets/side_navigation_rail.dart | 2 + 15 files changed, 322 insertions(+), 189 deletions(-) diff --git a/lib/focus/focusable_wrapper.dart b/lib/focus/focusable_wrapper.dart index 98a94721..1abb2aed 100644 --- a/lib/focus/focusable_wrapper.dart +++ b/lib/focus/focusable_wrapper.dart @@ -240,13 +240,15 @@ class _FocusableWrapperState extends State with SingleTickerPr final renderObject = context.findRenderObject(); if (renderObject == null) return; - // Find the nearest scrollable that actually has scroll range. + // Find the nearest vertical scrollable that actually has scroll range. // Skip inner scrollables with no extent (e.g. shrinkWrap ListView - // with NeverScrollableScrollPhysics inside an outer scroll view). + // with NeverScrollableScrollPhysics inside an outer scroll view) + // and horizontal scrollables (e.g. TabBarView) since we only do + // vertical scroll calculations. var scrollable = Scrollable.maybeOf(context); while (scrollable != null) { final pos = scrollable.position; - if (pos.maxScrollExtent > pos.minScrollExtent) break; + if (pos.axis == Axis.vertical && pos.maxScrollExtent > pos.minScrollExtent) break; scrollable = Scrollable.maybeOf(scrollable.context); } if (scrollable == null) return; diff --git a/lib/mixins/tab_navigation_mixin.dart b/lib/mixins/tab_navigation_mixin.dart index 555dfe66..abcff056 100644 --- a/lib/mixins/tab_navigation_mixin.dart +++ b/lib/mixins/tab_navigation_mixin.dart @@ -12,7 +12,7 @@ import '../screens/main_screen.dart'; /// - Tab bar back navigation to sidebar /// /// Subclasses must provide [tabChipFocusNodes] — one [FocusNode] per tab. -mixin TabNavigationMixin on State, SingleTickerProviderStateMixin { +mixin TabNavigationMixin on State, TickerProviderStateMixin { late TabController tabController; /// When true, suppress auto-focus in tabs (used when navigating via tab bar). diff --git a/lib/models/plex_library.dart b/lib/models/plex_library.dart index b1ab5ae1..e45e1508 100644 --- a/lib/models/plex_library.dart +++ b/lib/models/plex_library.dart @@ -26,6 +26,10 @@ class PlexLibrary with MultiServerFields { @JsonKey(includeFromJson: false, includeToJson: false) final String? serverName; + /// Whether this is a shared library (individually shared items, not a real section) + @JsonKey(includeFromJson: false, includeToJson: false) + final bool isShared; + /// Global unique identifier across all servers (serverId:key) String get globalKey => serverId != null ? buildGlobalKey(serverId!, key) : key; @@ -42,6 +46,7 @@ class PlexLibrary with MultiServerFields { this.hidden, this.serverId, this.serverName, + this.isShared = false, }); factory PlexLibrary.fromJson(Map json) => _$PlexLibraryFromJson(json); @@ -62,6 +67,7 @@ class PlexLibrary with MultiServerFields { int? hidden, String? serverId, String? serverName, + bool? isShared, }) { return PlexLibrary( key: key ?? this.key, @@ -76,6 +82,7 @@ class PlexLibrary with MultiServerFields { hidden: hidden ?? this.hidden, serverId: serverId ?? this.serverId, serverName: serverName ?? this.serverName, + isShared: isShared ?? this.isShared, ); } } diff --git a/lib/models/plex_metadata.dart b/lib/models/plex_metadata.dart index 6074547f..0735b4c1 100644 --- a/lib/models/plex_metadata.dart +++ b/lib/models/plex_metadata.dart @@ -133,6 +133,18 @@ class PlexMetadata with MultiServerFields { /// Global unique identifier across all servers (serverId:ratingKey) String get globalKey => serverId != null ? buildGlobalKey(serverId!, ratingKey) : ratingKey; + /// Whether this item represents a library section (shared whole-library, not a media item). + /// These have keys like `/library/sections/5/all` instead of `/library/metadata/12345`. + bool get isLibrarySection => key != null && key!.startsWith('/library/sections/'); + + /// Extract the library section ID from a library-section item's key. + /// Returns null if this is not a library section item. + String? get librarySectionKey { + if (!isLibrarySection) return null; + final match = RegExp(r'/library/sections/(\d+)').firstMatch(key!); + return match?.group(1); + } + /// Parsed media type enum for type-safe comparisons PlexMediaType get mediaType { if (type == null) return PlexMediaType.unknown; diff --git a/lib/screens/downloads/downloads_screen.dart b/lib/screens/downloads/downloads_screen.dart index 4ef43028..03774366 100644 --- a/lib/screens/downloads/downloads_screen.dart +++ b/lib/screens/downloads/downloads_screen.dart @@ -25,7 +25,7 @@ class DownloadsScreen extends StatefulWidget { State createState() => DownloadsScreenState(); } -class DownloadsScreenState extends State with SingleTickerProviderStateMixin, TabNavigationMixin { +class DownloadsScreenState extends State with TickerProviderStateMixin, TabNavigationMixin { // Focus nodes for tab chips final _queueTabChipFocusNode = FocusNode(debugLabel: 'tab_chip_queue'); final _tvShowsTabChipFocusNode = FocusNode(debugLabel: 'tab_chip_tv_shows'); diff --git a/lib/screens/libraries/libraries_screen.dart b/lib/screens/libraries/libraries_screen.dart index b50ebd71..5f8cbfdf 100644 --- a/lib/screens/libraries/libraries_screen.dart +++ b/lib/screens/libraries/libraries_screen.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:plezy/widgets/app_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; @@ -34,6 +35,13 @@ import 'tabs/library_recommended_tab.dart'; import 'tabs/library_collections_tab.dart'; import 'tabs/library_playlists_tab.dart'; +enum LibraryTabType { recommended, browse, collections, playlists } + +List _getVisibleTabs(PlexLibrary library) { + if (library.isShared) return [LibraryTabType.browse, LibraryTabType.playlists]; + return LibraryTabType.values; +} + /// A menu action item for context menus class ContextMenuItem { final String value; @@ -71,7 +79,7 @@ class _LibrariesScreenState extends State FocusableTab, LibraryLoadable, ItemUpdatable, - SingleTickerProviderStateMixin, + TickerProviderStateMixin, TabNavigationMixin { @override PlexClient get client { @@ -103,19 +111,15 @@ class _LibrariesScreenState extends State /// Key for the library dropdown popup menu button final _libraryDropdownKey = GlobalKey>(); - // 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'); + // Dynamic visible tabs and their focus nodes + List _visibleTabs = LibraryTabType.values; + List _tabFocusNodes = List.generate( + LibraryTabType.values.length, + (i) => FocusNode(debugLabel: 'tab_chip_${LibraryTabType.values[i].name}'), + ); @override - List get tabChipFocusNodes => [ - _recommendedTabChipFocusNode, - _browseTabChipFocusNode, - _collectionsTabChipFocusNode, - _playlistsTabChipFocusNode, - ]; + List get tabChipFocusNodes => _tabFocusNodes; // App bar action bar final _actionBarKey = GlobalKey(); @@ -176,12 +180,12 @@ class _LibrariesScreenState extends State @override void onTabChanged() { - // Save tab index when changed (but not when restoring from storage) + // Save tab name 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); + storage.saveLibraryTab(_selectedLibraryGlobalKey!, _visibleTabs[tabController.index].name); }); // Focus first item in the current tab (only for user-initiated changes) @@ -264,7 +268,7 @@ class _LibrariesScreenState extends State 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) { + if (_visibleTabs[tabController.index] == LibraryTabType.browse) { (tabState as dynamic).focusChipsBar(); } else { (tabState as dynamic).focusFirstItem(); @@ -275,18 +279,13 @@ class _LibrariesScreenState extends State /// 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; - } + 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, + }; } /// Handle when a tab's data has finished loading @@ -322,10 +321,9 @@ class _LibrariesScreenState extends State @override void dispose() { _outerScrollController.dispose(); - _recommendedTabChipFocusNode.dispose(); - _browseTabChipFocusNode.dispose(); - _collectionsTabChipFocusNode.dispose(); - _playlistsTabChipFocusNode.dispose(); + for (final node in _tabFocusNodes) { + node.dispose(); + } disposeTabNavigation(); super.dispose(); } @@ -335,6 +333,80 @@ class _LibrariesScreenState extends State setState(fn); } + /// Rebuild tab infrastructure when the visible tab set changes. + void _updateVisibleTabs(List newTabs) { + if (listEquals(_visibleTabs, newTabs)) return; + + // Save current tab type before changing + final currentTabType = _visibleTabs.length > tabController.index + ? _visibleTabs[tabController.index] + : null; + + // Dispose old focus nodes and controller + for (final node in _tabFocusNodes) { + node.dispose(); + } + disposeTabNavigation(); + + // Build new + _visibleTabs = newTabs; + _tabFocusNodes = List.generate( + newTabs.length, + (i) => FocusNode(debugLabel: 'tab_chip_${newTabs[i].name}'), + ); + initTabNavigation(); + + // Restore tab position: find current tab type in new set, default to first + 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 PlexLibrary library, 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, + ), + LibraryTabType.browse => LibraryBrowseTab( + key: _browseTabKey, + library: library, + isActive: isActive, + suppressAutoFocus: suppressAutoFocus, + onDataLoaded: () => _handleTabDataLoaded(tabIndex), + onBack: focusTabBar, + ), + 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, + ), + }; + } + /// Check if libraries come from multiple servers bool _hasMultipleServers(List libraries) { final uniqueServerIds = libraries.where((lib) => lib.serverId != null).map((lib) => lib.serverId).toSet(); @@ -366,6 +438,11 @@ class _LibrariesScreenState extends State final libraryIndex = visibleLibraries.indexWhere((lib) => lib.globalKey == libraryGlobalKey); if (libraryIndex == -1) return; // Library not found or hidden + // Update visible tabs and state in the same synchronous block so no + // intermediate rebuild can see a mismatched controller/key pair. + final selectedLibrary = visibleLibraries[libraryIndex]; + _updateVisibleTabs(_getVisibleTabs(selectedLibrary)); + _updateState(() { _selectedLibraryGlobalKey = libraryGlobalKey; _errorMessage = null; @@ -378,17 +455,20 @@ class _LibrariesScreenState extends State _isInitialLoad = false; } - // Save selected library key and restore saved tab + // Save selected library key and restore saved tab (async — safe after state is consistent) final storage = await StorageService.getInstance(); + if (!mounted) return; await storage.saveSelectedLibraryKey(libraryGlobalKey); - // Restore saved tab index for this library - final savedTabIndex = storage.getLibraryTab(libraryGlobalKey); - if (savedTabIndex != null && savedTabIndex >= 0 && savedTabIndex < 4) { + // 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) { // 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); + tabController.animateTo(targetTabIndex, duration: Duration.zero); // Clear flag synchronously - animateTo with zero duration completes immediately _isRestoringTab = false; } @@ -418,14 +498,14 @@ class _LibrariesScreenState extends State // Refresh the currently active tab void _refreshCurrentTab() { - final key = switch (tabController.index) { - 0 => _recommendedTabKey, - 1 => _browseTabKey, - 2 => _collectionsTabKey, - 3 => _playlistsTabKey, - _ => null, + if (tabController.index < 0 || tabController.index >= _visibleTabs.length) return; + final key = switch (_visibleTabs[tabController.index]) { + LibraryTabType.recommended => _recommendedTabKey, + LibraryTabType.browse => _browseTabKey, + LibraryTabType.collections => _collectionsTabKey, + LibraryTabType.playlists => _playlistsTabKey, }; - (key?.currentState as dynamic)?.refresh(); + (key.currentState as dynamic)?.refresh(); } // Public method to fully reload all content (for profile switches) @@ -745,13 +825,10 @@ class _LibrariesScreenState extends State 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), + for (int i = 0; i < _visibleTabs.length; i++) ...[ + if (i > 0) const SizedBox(width: 8), + _buildTabChip(_getTabLabel(_visibleTabs[i]), i), + ], ], ); } @@ -835,7 +912,7 @@ class _LibrariesScreenState extends State actions: [ FocusableActionBar( key: _actionBarKey, - onNavigateLeft: () => getTabChipFocusNode(3).requestFocus(), + onNavigateLeft: () => getTabChipFocusNode(_visibleTabs.length - 1).requestFocus(), onNavigateDown: _focusCurrentTab, actions: [ if (allLibraries.isNotEmpty) @@ -880,13 +957,10 @@ class _LibrariesScreenState extends State 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), + for (int i = 0; i < _visibleTabs.length; i++) ...[ + if (i > 0) const SizedBox(width: 8), + _buildTabChip(_getTabLabel(_visibleTabs[i]), i), + ], ], ), ), @@ -907,46 +981,13 @@ class _LibrariesScreenState extends State // 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, + for (int i = 0; i < _visibleTabs.length; i++) + ClipRect(child: _buildTabContent( + _visibleTabs[i], 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, - ), - ), + isActive: tabController.index == i, + tabIndex: i, + )), ], ), ), diff --git a/lib/screens/libraries/tabs/library_browse_tab.dart b/lib/screens/libraries/tabs/library_browse_tab.dart index 214b2b72..85fb8ffa 100644 --- a/lib/screens/libraries/tabs/library_browse_tab.dart +++ b/lib/screens/libraries/tabs/library_browse_tab.dart @@ -302,8 +302,12 @@ class _LibraryBrowseTabState extends BaseLibraryTabState _loadFirstCharacters({int? requestId}) async { + // Shared libraries don't support first characters + if (widget.library.isShared) return; final currentRequestId = requestId ?? ++_firstCharactersRequestId; final client = getClientForLibrary(); final filterParams = Map.from(_selectedFilters); @@ -983,7 +997,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState _groupingChipFocusNode.requestFocus(), + onNavigateUp: _navigateToChips, ), ), ], @@ -1107,6 +1121,8 @@ class _LibraryBrowseTabState extends BaseLibraryTabState _sortChipFocusNode.requestFocus(); } + final isShared = widget.library.isShared; + return Container( color: Theme.of(context).scaffoldBackgroundColor, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), @@ -1114,19 +1130,21 @@ class _LibraryBrowseTabState extends BaseLibraryTabState _groupingChipFocusNode.requestFocus(), + onNavigateLeft: isShared ? _navigateToSidebar : () => _groupingChipFocusNode.requestFocus(), onNavigateRight: _isSortChipVisible ? () => _sortChipFocusNode.requestFocus() : null, onBack: widget.onBack, ), @@ -1154,7 +1172,9 @@ class _LibraryBrowseTabState extends BaseLibraryTabState _filtersChipFocusNode.requestFocus() - : () => _groupingChipFocusNode.requestFocus(), + : isShared + ? _navigateToSidebar + : () => _groupingChipFocusNode.requestFocus(), onBack: widget.onBack, ), ], diff --git a/lib/screens/livetv/live_tv_screen.dart b/lib/screens/livetv/live_tv_screen.dart index 8fa08a60..eaf6a83c 100644 --- a/lib/screens/livetv/live_tv_screen.dart +++ b/lib/screens/livetv/live_tv_screen.dart @@ -26,7 +26,7 @@ class LiveTvScreen extends StatefulWidget { } class _LiveTvScreenState extends State - with SingleTickerProviderStateMixin, TabNavigationMixin + with TickerProviderStateMixin, TabNavigationMixin implements FocusableTab { final _guideTabFocusNode = FocusNode(debugLabel: 'tab_chip_guide'); final _whatsOnTabFocusNode = FocusNode(debugLabel: 'tab_chip_whats_on'); diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart index 217d9dee..65001578 100644 --- a/lib/screens/main_screen.dart +++ b/lib/screens/main_screen.dart @@ -52,12 +52,14 @@ class MainScreenFocusScope extends InheritedWidget { final VoidCallback focusSidebar; final VoidCallback focusContent; final bool isSidebarFocused; + final void Function(String libraryGlobalKey)? selectLibrary; const MainScreenFocusScope({ super.key, required this.focusSidebar, required this.focusContent, required this.isSidebarFocused, + this.selectLibrary, required super.child, }); @@ -137,6 +139,13 @@ class _MainScreenState extends State with RouteAware, WindowListener _lastOnlineTabId = _isOffline ? null : NavigationTabId.discover; _autoSwitchedToDownloads = _isOffline; + // Synchronize _lastHasLiveTv with provider before building screens + // so _buildScreens and _hasLiveTv getter agree from the start. + try { + _lastHasLiveTv = context.read().hasLiveTv; + } catch (_) { + _lastHasLiveTv = false; + } _screens = _buildScreens(_isOffline); // Set up Watch Together callbacks immediately (must be synchronous to catch early messages) @@ -539,7 +548,9 @@ class _MainScreenState extends State with RouteAware, WindowListener return [DownloadsScreen(key: _downloadsKey), SettingsScreen(key: _settingsKey)]; } - final hasLiveTv = context.read().hasLiveTv; + // Use _lastHasLiveTv (the value synchronized with _handleLiveTvChanged) + // so screens and nav bar always agree on whether LiveTV is included. + final hasLiveTv = _lastHasLiveTv; return [ DiscoverScreen(key: _discoverKey, onBecameVisible: _onDiscoverBecameVisible), @@ -983,13 +994,9 @@ class _MainScreenState extends State with RouteAware, WindowListener } /// Whether the Live TV tab is currently visible - bool get _hasLiveTv { - try { - return context.read().hasLiveTv; - } catch (_) { - return false; - } - } + /// Use the synchronized value so screens list and nav bar always agree. + /// Updated by _handleLiveTvChanged when the provider notifies. + bool get _hasLiveTv => _lastHasLiveTv; /// Get navigation tabs filtered by offline mode List _getVisibleTabs(bool isOffline) { @@ -1040,6 +1047,7 @@ class _MainScreenState extends State with RouteAware, WindowListener focusSidebar: _focusSidebar, focusContent: _focusContent, isSidebarFocused: _isSidebarFocused, + selectLibrary: _selectLibrary, child: SideNavigationScope( child: Stack( children: [ diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index 2054a6ca..f79f15b4 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -272,20 +272,35 @@ class PlexClient { if (directories == null) continue; for (final dir in directories) { - if (dir is! Map) continue; + try { + if (dir is! Map) continue; - // Skip entries without id (Home hub) and playlists - final id = dir['id'] as String?; - if (id == null) continue; - if (dir['type'] == 'playlist') continue; + // Skip entries without id (Home hub) and playlists + final id = dir['id']?.toString(); + if (id == null) continue; + if (dir['type'] == 'playlist') continue; - // Set key = id so downstream code gets a plain section ID (e.g. "1") - final json = Map.from(dir); - json['key'] = id; + final isNumericId = int.tryParse(id) != null; + final isSharedLibrary = !isNumericId && + dir['key']?.toString().startsWith('/library/shared') == true; - libraries.add( - PlexLibrary.fromJson(json).copyWith(serverId: serverId, serverName: serverName), - ); + // Skip non-numeric IDs unless it's a shared library + if (!isNumericId && !isSharedLibrary) continue; + + // Set key = id so downstream code gets a plain section ID (e.g. "1" or "shared") + final json = Map.from(dir); + json['key'] = id; + + libraries.add( + PlexLibrary.fromJson(json).copyWith( + serverId: serverId, + serverName: serverName, + isShared: isSharedLibrary, + ), + ); + } catch (e) { + appLogger.w('Failed to parse media provider directory entry', error: e); + } } } } @@ -553,8 +568,12 @@ class PlexClient { queryParams.addAll(filters); } + final endpoint = sectionId == 'shared' + ? '/library/shared/all' + : '/library/sections/$sectionId/all'; + final response = await _dio.get( - '/library/sections/$sectionId/all', + endpoint, queryParameters: queryParams, cancelToken: cancelToken, ); @@ -912,52 +931,41 @@ class PlexClient { return true; } - /// Search across all libraries using the hub search endpoint - /// Only returns movies and shows, filtering out seasons and episodes - Future> search(String query, {int limit = 10}) async { + /// Search across all libraries including individually shared items. + /// Uses /library/search (same endpoint as Plex Web) which finds shared content. + /// Only returns movies and shows, filtering out other types. + Future> search(String query, {int limit = 30}) async { final response = await _dio.get( - '/hubs/search', - queryParameters: {'query': query, 'limit': limit, 'includeCollections': 1}, + '/library/search', + queryParameters: { + 'query': query, + 'limit': limit, + 'searchTypes': 'movies,tv', + 'includeCollections': 1, + 'includeExternalMedia': 1, + }, ); final results = []; final container = _getMediaContainer(response); - if (container != null) { - if (container['Hub'] != null) { - // Each hub contains results of a specific type (movies, shows, etc.) - for (final hub in container['Hub'] as List) { - final hubType = hub['type'] as String?; + if (container == null) return results; - // Only include movie and show hubs - if (hubType != 'movie' && hubType != 'show') { - continue; - } + final searchResults = container['SearchResult'] as List?; + if (searchResults == null) return results; - // Hubs can contain either Metadata (for movies) or Directory (for shows) - if (hub['Metadata'] != null) { - for (final json in hub['Metadata'] as List) { - try { - results.add(_createTaggedMetadata(json)); - } catch (e) { - // Skip items that fail to parse - appLogger.w('Failed to parse search result', error: e); - appLogger.d('Problematic JSON: $json'); - } - } - } - if (hub['Directory'] != null) { - for (final json in hub['Directory'] as List) { - try { - results.add(_createTaggedMetadata(json)); - } catch (e) { - // Skip items that fail to parse - appLogger.w('Failed to parse search result', error: e); - appLogger.d('Problematic JSON: $json'); - } - } - } - } + for (final result in searchResults) { + try { + if (result is! Map) continue; + final metadata = result['Metadata']; + if (metadata is! Map) continue; + + final type = metadata['type'] as String?; + if (type != 'movie' && type != 'show') continue; + + results.add(_createTaggedMetadata(metadata)); + } catch (e) { + appLogger.w('Failed to parse search result', error: e); } } @@ -981,7 +989,7 @@ class PlexClient { final response = await _dio.get('/library/onDeck'); final sid = serverId; final sname = serverName; - return Isolate.run(() => _processOnDeckResponse(response.data as Map, sid, sname)); + return await Isolate.run(() => _processOnDeckResponse(response.data as Map, sid, sname)); } /// Get children of a metadata item (e.g., seasons for a show, episodes for a season) @@ -1369,6 +1377,7 @@ class PlexClient { /// Get available filters for a library section Future> getLibraryFilters(String sectionId) async { + if (sectionId == 'shared') return []; final response = await _dio.get('/library/sections/$sectionId/filters'); return _extractDirectoryList(response, PlexFilter.fromJson); } @@ -1398,6 +1407,12 @@ class PlexClient { /// If [libraryType] is provided (e.g., 'movie', 'show'), it's used for fallback /// sorts without needing to re-fetch the library sections list. Future> getLibrarySorts(String sectionId, {String? libraryType}) async { + if (sectionId == 'shared') { + return [ + PlexSort(key: 'titleSort', descKey: 'titleSort:desc', title: 'Title', defaultDirection: 'asc'), + PlexSort(key: 'taggingCreatedAt', descKey: 'taggingCreatedAt:desc', title: 'Date Shared', defaultDirection: 'desc'), + ]; + } try { // Use the dedicated sorts endpoint final response = await _dio.get('/library/sections/$sectionId/sorts'); @@ -1462,7 +1477,7 @@ class PlexClient { ); final sid = serverId; final sname = serverName; - return Isolate.run(() => _processHubResponse(response.data as Map, sid, sname)); + return await Isolate.run(() => _processHubResponse(response.data as Map, sid, sname)); } catch (e) { appLogger.e('Failed to get library hubs: $e'); } @@ -1477,7 +1492,7 @@ class PlexClient { final response = await _dio.get('/hubs', queryParameters: {'count': limit, 'includeGuids': 1}); final sid = serverId; final sname = serverName; - return Isolate.run(() => _processHubResponse(response.data as Map, sid, sname)); + return await Isolate.run(() => _processHubResponse(response.data as Map, sid, sname)); } catch (e) { appLogger.e('Failed to get global hubs: $e'); } diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart index d52a1c6d..55b89e98 100644 --- a/lib/services/storage_service.dart +++ b/lib/services/storage_service.dart @@ -191,13 +191,20 @@ class StorageService extends BaseSharedPreferencesService { return _getScopedString('$_prefixLibraryGrouping$sectionId'); } - // Library Tab (per-library, saves last selected tab index) - Future saveLibraryTab(String sectionId, int tabIndex) async { - await prefs.setInt('$_userPrefix$_prefixLibraryTab$sectionId', tabIndex); + // Library Tab (per-library, saves last selected tab name) + Future saveLibraryTab(String sectionId, String tabName) async { + await prefs.setString('$_userPrefix$_prefixLibraryTab$sectionId', tabName); } - int? getLibraryTab(String sectionId) { - return _getScopedInt('$_prefixLibraryTab$sectionId'); + String? getLibraryTab(String sectionId) { + final key = '$_userPrefix$_prefixLibraryTab$sectionId'; + // Handle migration from old int storage: try string first, fall back to removing stale int + try { + return prefs.getString(key); + } catch (_) { + prefs.remove(key); + return null; + } } // Hidden Libraries (stored as JSON array of library section IDs) diff --git a/lib/utils/content_utils.dart b/lib/utils/content_utils.dart index 153d2459..6ecd1b68 100644 --- a/lib/utils/content_utils.dart +++ b/lib/utils/content_utils.dart @@ -60,6 +60,8 @@ class ContentTypeHelper { return Symbols.music_note_rounded; case 'photo': return Symbols.photo_rounded; + case 'mixed': + return Symbols.share_rounded; default: return Symbols.folder_rounded; } diff --git a/lib/utils/media_navigation_helper.dart b/lib/utils/media_navigation_helper.dart index 4451c3ee..91510645 100644 --- a/lib/utils/media_navigation_helper.dart +++ b/lib/utils/media_navigation_helper.dart @@ -2,8 +2,10 @@ import 'package:flutter/material.dart'; import '../models/plex_metadata.dart'; import '../models/plex_playlist.dart'; import '../screens/collection_detail_screen.dart'; +import '../screens/main_screen.dart'; import '../screens/media_detail_screen.dart'; import '../screens/playlist/playlist_detail_screen.dart'; +import '../utils/global_key_utils.dart'; import 'video_player_navigation.dart'; /// Result of media navigation indicating what action was taken @@ -16,6 +18,9 @@ enum MediaNavigationResult { /// Item type not supported (e.g., music content) unsupported, + + /// Item is a library section — navigated to that library + librarySelected, } /// Navigates to the appropriate screen based on the item type. @@ -55,6 +60,17 @@ Future navigateToMediaItem( final metadata = item as PlexMetadata; + // Handle library section items (shared whole-library entries) + if (metadata.isLibrarySection) { + final sectionKey = metadata.librarySectionKey; + if (sectionKey != null && metadata.serverId != null) { + final libraryGlobalKey = buildGlobalKey(metadata.serverId!, sectionKey); + MainScreenFocusScope.of(context)?.selectLibrary?.call(libraryGlobalKey); + return MediaNavigationResult.librarySelected; + } + return MediaNavigationResult.unsupported; + } + switch (metadata.mediaType) { case PlexMediaType.collection: final result = await Navigator.push( diff --git a/lib/widgets/media_card.dart b/lib/widgets/media_card.dart index 3384f36e..f65b0ed7 100644 --- a/lib/widgets/media_card.dart +++ b/lib/widgets/media_card.dart @@ -148,6 +148,7 @@ class MediaCardState extends State { case MediaNavigationResult.listRefreshNeeded: widget.onListRefresh?.call(); case MediaNavigationResult.navigated: + case MediaNavigationResult.librarySelected: // Item refresh already handled by onRefresh callback in helper break; } diff --git a/lib/widgets/side_navigation_rail.dart b/lib/widgets/side_navigation_rail.dart index 8e4701e1..faf7b68b 100644 --- a/lib/widgets/side_navigation_rail.dart +++ b/lib/widgets/side_navigation_rail.dart @@ -333,6 +333,8 @@ class SideNavigationRailState extends State { return Symbols.music_note_rounded; case 'photo': return Symbols.photo_rounded; + case 'mixed': + return Symbols.share_rounded; default: return Symbols.folder_rounded; }