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:
@@ -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;
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user