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
This commit is contained in:
edde746
2026-03-15 21:36:17 +01:00
parent bbc7f0f665
commit 551be5bbef
15 changed files with 322 additions and 189 deletions
+5 -3
View File
@@ -240,13 +240,15 @@ class _FocusableWrapperState extends State<FocusableWrapper> 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;
+1 -1
View File
@@ -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<T extends StatefulWidget> on State<T>, SingleTickerProviderStateMixin<T> {
mixin TabNavigationMixin<T extends StatefulWidget> on State<T>, TickerProviderStateMixin<T> {
late TabController tabController;
/// When true, suppress auto-focus in tabs (used when navigating via tab bar).
+7
View File
@@ -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<String, dynamic> 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,
);
}
}
+12
View File
@@ -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;
+1 -1
View File
@@ -25,7 +25,7 @@ class DownloadsScreen extends StatefulWidget {
State<DownloadsScreen> createState() => DownloadsScreenState();
}
class DownloadsScreenState extends State<DownloadsScreen> with SingleTickerProviderStateMixin, TabNavigationMixin {
class DownloadsScreenState extends State<DownloadsScreen> with TickerProviderStateMixin, TabNavigationMixin {
// Focus nodes for tab chips
final _queueTabChipFocusNode = FocusNode(debugLabel: 'tab_chip_queue');
final _tvShowsTabChipFocusNode = FocusNode(debugLabel: 'tab_chip_tv_shows');
+138 -97
View File
@@ -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<LibraryTabType> _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<LibrariesScreen>
FocusableTab,
LibraryLoadable,
ItemUpdatable,
SingleTickerProviderStateMixin,
TickerProviderStateMixin,
TabNavigationMixin {
@override
PlexClient get client {
@@ -103,19 +111,15 @@ class _LibrariesScreenState extends State<LibrariesScreen>
/// Key for the library dropdown popup menu button
final _libraryDropdownKey = GlobalKey<PopupMenuButtonState<String>>();
// Focus nodes for tab chips
final _recommendedTabChipFocusNode = FocusNode(debugLabel: 'tab_chip_recommended');
final _browseTabChipFocusNode = FocusNode(debugLabel: 'tab_chip_browse');
final _collectionsTabChipFocusNode = FocusNode(debugLabel: 'tab_chip_collections');
final _playlistsTabChipFocusNode = FocusNode(debugLabel: 'tab_chip_playlists');
// Dynamic visible tabs and their focus nodes
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 => [
_recommendedTabChipFocusNode,
_browseTabChipFocusNode,
_collectionsTabChipFocusNode,
_playlistsTabChipFocusNode,
];
List<FocusNode> get tabChipFocusNodes => _tabFocusNodes;
// App bar action bar
final _actionBarKey = GlobalKey<FocusableActionBarState>();
@@ -176,12 +180,12 @@ class _LibrariesScreenState extends State<LibrariesScreen>
@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<LibrariesScreen>
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<LibrariesScreen>
/// 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<LibrariesScreen>
@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<LibrariesScreen>
setState(fn);
}
/// Rebuild tab infrastructure when the visible tab set changes.
void _updateVisibleTabs(List<LibraryTabType> 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<PlexLibrary> libraries) {
final uniqueServerIds = libraries.where((lib) => lib.serverId != null).map((lib) => lib.serverId).toSet();
@@ -366,6 +438,11 @@ class _LibrariesScreenState extends State<LibrariesScreen>
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<LibrariesScreen>
_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<LibrariesScreen>
// 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<LibrariesScreen>
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<LibrariesScreen>
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<LibrariesScreen>
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<LibrariesScreen>
// 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,
)),
],
),
),
@@ -302,8 +302,12 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
/// Focus the chips bar (for navigating from tab bar to content).
/// Called by libraries screen when pressing DOWN on tab bar.
void focusChipsBar() {
// Grouping chip is always visible (including in folder mode)
_groupingChipFocusNode.requestFocus();
if (widget.library.isShared) {
// Shared libraries have no grouping chip — focus sort instead
_sortChipFocusNode.requestFocus();
} else {
_groupingChipFocusNode.requestFocus();
}
}
/// Reset transient browse state before loading a different library.
@@ -796,9 +800,17 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
}
}
/// Navigate focus from grid up to the grouping chip
/// Navigate focus from grid up to the chips bar
void _navigateToChips() {
_groupingChipFocusNode.requestFocus();
if (!widget.library.isShared) {
_groupingChipFocusNode.requestFocus();
} else if (_isSortChipVisible) {
_sortChipFocusNode.requestFocus();
} else if (_isFiltersChipVisible) {
_filtersChipFocusNode.requestFocus();
} else {
_navigateToSidebar();
}
}
/// Navigate focus to the sidebar
@@ -834,6 +846,8 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
/// Fetch first characters for the current library/filter state
Future<void> _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<String, String>.from(_selectedFilters);
@@ -983,7 +997,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
serverId: widget.library.serverId,
onRefresh: updateItem,
firstItemFocusNode: firstItemFocusNode,
onNavigateUp: () => _groupingChipFocusNode.requestFocus(),
onNavigateUp: _navigateToChips,
),
),
],
@@ -1107,6 +1121,8 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
groupingNavigateRight = () => _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<PlexMetadata, LibraryBr
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// Grouping chip
FocusableFilterChip(
focusNode: _groupingChipFocusNode,
icon: Symbols.category_rounded,
label: _getGroupingLabel(_selectedGrouping),
onPressed: _showGroupingBottomSheet,
onNavigateDown: _navigateToGrid,
onNavigateUp: widget.onBack,
onNavigateLeft: _navigateToSidebar,
onNavigateRight: groupingNavigateRight,
onBack: widget.onBack,
),
const SizedBox(width: 8),
// Grouping chip (hidden for shared libraries — mixed content, no grouping)
if (!isShared) ...[
FocusableFilterChip(
focusNode: _groupingChipFocusNode,
icon: Symbols.category_rounded,
label: _getGroupingLabel(_selectedGrouping),
onPressed: _showGroupingBottomSheet,
onNavigateDown: _navigateToGrid,
onNavigateUp: widget.onBack,
onNavigateLeft: _navigateToSidebar,
onNavigateRight: groupingNavigateRight,
onBack: widget.onBack,
),
const SizedBox(width: 8),
],
// Filters chip
if (_isFiltersChipVisible)
FocusableFilterChip(
@@ -1138,7 +1156,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
onPressed: _showFiltersBottomSheet,
onNavigateDown: _navigateToGrid,
onNavigateUp: widget.onBack,
onNavigateLeft: () => _groupingChipFocusNode.requestFocus(),
onNavigateLeft: isShared ? _navigateToSidebar : () => _groupingChipFocusNode.requestFocus(),
onNavigateRight: _isSortChipVisible ? () => _sortChipFocusNode.requestFocus() : null,
onBack: widget.onBack,
),
@@ -1154,7 +1172,9 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
onNavigateUp: widget.onBack,
onNavigateLeft: _isFiltersChipVisible
? () => _filtersChipFocusNode.requestFocus()
: () => _groupingChipFocusNode.requestFocus(),
: isShared
? _navigateToSidebar
: () => _groupingChipFocusNode.requestFocus(),
onBack: widget.onBack,
),
],
+1 -1
View File
@@ -26,7 +26,7 @@ class LiveTvScreen extends StatefulWidget {
}
class _LiveTvScreenState extends State<LiveTvScreen>
with SingleTickerProviderStateMixin, TabNavigationMixin
with TickerProviderStateMixin, TabNavigationMixin
implements FocusableTab {
final _guideTabFocusNode = FocusNode(debugLabel: 'tab_chip_guide');
final _whatsOnTabFocusNode = FocusNode(debugLabel: 'tab_chip_whats_on');
+16 -8
View File
@@ -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<MainScreen> 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<MultiServerProvider>().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<MainScreen> with RouteAware, WindowListener
return [DownloadsScreen(key: _downloadsKey), SettingsScreen(key: _settingsKey)];
}
final hasLiveTv = context.read<MultiServerProvider>().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<MainScreen> with RouteAware, WindowListener
}
/// Whether the Live TV tab is currently visible
bool get _hasLiveTv {
try {
return context.read<MultiServerProvider>().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<NavigationTab> _getVisibleTabs(bool isOffline) {
@@ -1040,6 +1047,7 @@ class _MainScreenState extends State<MainScreen> with RouteAware, WindowListener
focusSidebar: _focusSidebar,
focusContent: _focusContent,
isSidebarFocused: _isSidebarFocused,
selectLibrary: _selectLibrary,
child: SideNavigationScope(
child: Stack(
children: [
+68 -53
View File
@@ -272,20 +272,35 @@ class PlexClient {
if (directories == null) continue;
for (final dir in directories) {
if (dir is! Map<String, dynamic>) continue;
try {
if (dir is! Map<String, dynamic>) 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<String, dynamic>.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<String, dynamic>.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<List<PlexMetadata>> 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<List<PlexMetadata>> 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 = <PlexMetadata>[];
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<String, dynamic>) 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<String, dynamic>, sid, sname));
return await Isolate.run(() => _processOnDeckResponse(response.data as Map<String, dynamic>, 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<List<PlexFilter>> 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<List<PlexSort>> 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<String, dynamic>, sid, sname));
return await Isolate.run(() => _processHubResponse(response.data as Map<String, dynamic>, 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<String, dynamic>, sid, sname));
return await Isolate.run(() => _processHubResponse(response.data as Map<String, dynamic>, sid, sname));
} catch (e) {
appLogger.e('Failed to get global hubs: $e');
}
+12 -5
View File
@@ -191,13 +191,20 @@ class StorageService extends BaseSharedPreferencesService {
return _getScopedString('$_prefixLibraryGrouping$sectionId');
}
// Library Tab (per-library, saves last selected tab index)
Future<void> saveLibraryTab(String sectionId, int tabIndex) async {
await prefs.setInt('$_userPrefix$_prefixLibraryTab$sectionId', tabIndex);
// Library Tab (per-library, saves last selected tab name)
Future<void> 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)
+2
View File
@@ -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;
}
+16
View File
@@ -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<MediaNavigationResult> 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<bool>(
+1
View File
@@ -148,6 +148,7 @@ class MediaCardState extends State<MediaCard> {
case MediaNavigationResult.listRefreshNeeded:
widget.onListRefresh?.call();
case MediaNavigationResult.navigated:
case MediaNavigationResult.librarySelected:
// Item refresh already handled by onRefresh callback in helper
break;
}
+2
View File
@@ -333,6 +333,8 @@ class SideNavigationRailState extends State<SideNavigationRail> {
return Symbols.music_note_rounded;
case 'photo':
return Symbols.photo_rounded;
case 'mixed':
return Symbols.share_rounded;
default:
return Symbols.folder_rounded;
}