refactor: navigation/offline handling

and other improvments
This commit is contained in:
edde746
2025-12-13 00:16:50 +01:00
parent f43fbd0951
commit 18c322f66a
42 changed files with 1891 additions and 1507 deletions
+26
View File
@@ -69,6 +69,32 @@ class AppDatabase extends _$AppDatabase {
.getSingleOrNull();
}
/// Get the latest actions for multiple items in a single query
///
/// Returns a map of globalKey -> latest action for each key.
/// Keys with no actions will not be present in the returned map.
Future<Map<String, OfflineWatchProgressItem>> getLatestWatchActionsForKeys(
Set<String> globalKeys,
) async {
if (globalKeys.isEmpty) return {};
// Query all actions for the given keys
final allActions =
await (select(offlineWatchProgress)
..where((t) => t.globalKey.isIn(globalKeys))
..orderBy([(t) => OrderingTerm.desc(t.updatedAt)]))
.get();
// Group by globalKey and take the latest (first due to ordering)
final result = <String, OfflineWatchProgressItem>{};
for (final action in allActions) {
// Only keep the first (latest) action for each key
result.putIfAbsent(action.globalKey, () => action);
}
return result;
}
/// Insert or update a progress action (merges with existing)
Future<void> upsertProgressAction({
required String serverId,
+35 -34
View File
@@ -1,54 +1,55 @@
import 'package:flutter/services.dart';
/// Shared sets for keyboard key categories.
final _dpadDirectionKeys = {
LogicalKeyboardKey.arrowUp,
LogicalKeyboardKey.arrowDown,
LogicalKeyboardKey.arrowLeft,
LogicalKeyboardKey.arrowRight,
};
final _selectKeys = {
LogicalKeyboardKey.select,
LogicalKeyboardKey.enter,
LogicalKeyboardKey.numpadEnter,
LogicalKeyboardKey.gameButtonA,
};
final _backKeys = {
LogicalKeyboardKey.escape,
LogicalKeyboardKey.goBack,
LogicalKeyboardKey.browserBack,
LogicalKeyboardKey.gameButtonB,
};
final _contextMenuKeys = {
LogicalKeyboardKey.contextMenu,
LogicalKeyboardKey.gameButtonX,
};
/// Extension methods for checking D-pad related keys.
extension DpadKeyExtension on LogicalKeyboardKey {
/// Whether this key is a D-pad directional key.
bool get isDpadDirection {
return this == LogicalKeyboardKey.arrowUp ||
this == LogicalKeyboardKey.arrowDown ||
this == LogicalKeyboardKey.arrowLeft ||
this == LogicalKeyboardKey.arrowRight;
}
bool get isDpadDirection => _dpadDirectionKeys.contains(this);
/// Whether this key is a select/activate key.
bool get isSelectKey {
return this == LogicalKeyboardKey.select ||
this == LogicalKeyboardKey.enter ||
this == LogicalKeyboardKey.numpadEnter ||
this == LogicalKeyboardKey.gameButtonA;
}
bool get isSelectKey => _selectKeys.contains(this);
/// Whether this key is a back/cancel key.
bool get isBackKey {
return this == LogicalKeyboardKey.escape ||
this == LogicalKeyboardKey.goBack ||
this == LogicalKeyboardKey.browserBack ||
this == LogicalKeyboardKey.gameButtonB;
}
bool get isBackKey => _backKeys.contains(this);
/// Whether this key is a context menu key.
bool get isContextMenuKey {
return this == LogicalKeyboardKey.contextMenu ||
this == LogicalKeyboardKey.gameButtonX;
}
bool get isContextMenuKey => _contextMenuKeys.contains(this);
/// Whether this key moves focus left.
bool get isLeftKey {
return this == LogicalKeyboardKey.arrowLeft;
}
bool get isLeftKey => this == LogicalKeyboardKey.arrowLeft;
/// Whether this key moves focus right.
bool get isRightKey {
return this == LogicalKeyboardKey.arrowRight;
}
bool get isRightKey => this == LogicalKeyboardKey.arrowRight;
/// Whether this key moves focus up.
bool get isUpKey {
return this == LogicalKeyboardKey.arrowUp;
}
bool get isUpKey => this == LogicalKeyboardKey.arrowUp;
/// Whether this key moves focus down.
bool get isDownKey {
return this == LogicalKeyboardKey.arrowDown;
}
bool get isDownKey => this == LogicalKeyboardKey.arrowDown;
}
+20
View File
@@ -1,3 +1,23 @@
mixin Refreshable {
void refresh();
}
/// Mixin for screens that support full refresh (clearing all cached data)
mixin FullRefreshable {
void fullRefresh();
}
/// Mixin for screens with focusable tab content
mixin FocusableTab {
void focusActiveTabIfReady();
}
/// Mixin for screens with focusable search input
mixin SearchInputFocusable {
void focusSearchInput();
}
/// Mixin for screens that can load a specific library by key
mixin LibraryLoadable {
void loadLibraryByKey(String libraryGlobalKey);
}
+46
View File
@@ -5,6 +5,34 @@ import 'plex_role.dart';
part 'plex_metadata.g.dart';
/// Media type enum for type-safe media type handling
enum PlexMediaType {
movie,
show,
season,
episode,
artist,
album,
track,
collection,
playlist,
clip,
photo,
unknown;
/// Whether this type represents video content
bool get isVideo => this == movie || this == episode || this == clip;
/// Whether this type is part of a show hierarchy
bool get isShowRelated => this == show || this == season || this == episode;
/// Whether this type represents music content
bool get isMusic => this == artist || this == album || this == track;
/// Whether this type can be played directly
bool get isPlayable => isVideo || this == track;
}
@JsonSerializable()
class PlexMetadata with MultiServerFields {
final String ratingKey;
@@ -61,6 +89,24 @@ class PlexMetadata with MultiServerFields {
/// Global unique identifier across all servers (serverId:ratingKey)
String get globalKey => serverId != null ? '$serverId:$ratingKey' : ratingKey;
/// Parsed media type enum for type-safe comparisons
PlexMediaType get mediaType {
return switch (type.toLowerCase()) {
'movie' => PlexMediaType.movie,
'show' => PlexMediaType.show,
'season' => PlexMediaType.season,
'episode' => PlexMediaType.episode,
'artist' => PlexMediaType.artist,
'album' => PlexMediaType.album,
'track' => PlexMediaType.track,
'collection' => PlexMediaType.collection,
'playlist' => PlexMediaType.playlist,
'clip' => PlexMediaType.clip,
'photo' => PlexMediaType.photo,
_ => PlexMediaType.unknown,
};
}
PlexMetadata({
required this.ratingKey,
required this.key,
+100
View File
@@ -0,0 +1,100 @@
import 'package:flutter/material.dart';
import 'package:plezy/widgets/app_icon.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../i18n/strings.g.dart';
/// Navigation tab identifiers
enum NavigationTabId {
discover,
libraries,
search,
downloads,
settings,
}
/// Represents a navigation tab with its configuration
class NavigationTab {
final NavigationTabId id;
final bool onlineOnly;
final IconData icon;
final String Function() getLabel;
const NavigationTab({
required this.id,
required this.onlineOnly,
required this.icon,
required this.getLabel,
});
NavigationDestination toDestination() {
return NavigationDestination(
icon: AppIcon(icon, fill: 1),
selectedIcon: AppIcon(icon, fill: 1),
label: getLabel(),
);
}
/// Get the index for a tab ID in the visible tabs list
static int indexFor(NavigationTabId id, {required bool isOffline}) {
final tabs = getVisibleTabs(isOffline: isOffline);
return tabs.indexWhere((tab) => tab.id == id);
}
/// Get tabs filtered by offline mode
static List<NavigationTab> getVisibleTabs({required bool isOffline}) {
return allNavigationTabs
.where((tab) => !isOffline || !tab.onlineOnly)
.toList();
}
/// Check if a visual index corresponds to a specific tab ID
static bool isTabAtIndex(
NavigationTabId id,
int index, {
required bool isOffline,
}) {
return indexFor(id, isOffline: isOffline) == index;
}
}
// Label getters (must be top-level for const constructor)
String _getHomeLabel() => t.navigation.home;
String _getLibrariesLabel() => t.navigation.libraries;
String _getSearchLabel() => t.navigation.search;
String _getDownloadsLabel() => t.navigation.downloads;
String _getSettingsLabel() => t.navigation.settings;
/// All navigation tabs in display order
const allNavigationTabs = [
NavigationTab(
id: NavigationTabId.discover,
onlineOnly: true,
icon: Symbols.home_rounded,
getLabel: _getHomeLabel,
),
NavigationTab(
id: NavigationTabId.libraries,
onlineOnly: true,
icon: Symbols.video_library_rounded,
getLabel: _getLibrariesLabel,
),
NavigationTab(
id: NavigationTabId.search,
onlineOnly: true,
icon: Symbols.search_rounded,
getLabel: _getSearchLabel,
),
NavigationTab(
id: NavigationTabId.downloads,
onlineOnly: false,
icon: Symbols.download_rounded,
getLabel: _getDownloadsLabel,
),
NavigationTab(
id: NavigationTabId.settings,
onlineOnly: false,
icon: Symbols.settings_rounded,
getLabel: _getSettingsLabel,
),
];
+11 -11
View File
@@ -26,16 +26,22 @@ class OfflineModeProvider extends ChangeNotifier {
/// Whether at least one Plex server is reachable
bool get hasServerConnection => _hasServerConnection;
/// Updates network and server connection flags
Future<void> _updateConnectionFlags() async {
final connectivityResult = await Connectivity().checkConnectivity();
_hasNetworkConnection = !connectivityResult.contains(
ConnectivityResult.none,
);
_hasServerConnection = _serverManager.onlineServerIds.isNotEmpty;
}
/// Initialize the provider and start monitoring
Future<void> initialize() async {
if (_isInitialized) return;
_isInitialized = true;
// Check initial connectivity
final connectivityResult = await Connectivity().checkConnectivity();
_hasNetworkConnection = !connectivityResult.contains(
ConnectivityResult.none,
);
await _updateConnectionFlags();
// Monitor connectivity changes
_connectivitySubscription = Connectivity().onConnectivityChanged.listen((
@@ -59,18 +65,12 @@ class OfflineModeProvider extends ChangeNotifier {
}
});
// Check initial server status
_hasServerConnection = _serverManager.onlineServerIds.isNotEmpty;
notifyListeners();
}
/// Force a refresh of connectivity status
Future<void> refresh() async {
final connectivityResult = await Connectivity().checkConnectivity();
_hasNetworkConnection = !connectivityResult.contains(
ConnectivityResult.none,
);
_hasServerConnection = _serverManager.onlineServerIds.isNotEmpty;
await _updateConnectionFlags();
notifyListeners();
}
+19 -2
View File
@@ -120,9 +120,16 @@ class OfflineWatchProvider extends ChangeNotifier {
final episodes = _getSortedEpisodes(showRatingKey);
if (episodes.isEmpty) return null;
// Batch fetch all watch statuses in a single query
final globalKeys = episodes.map((e) => e.globalKey).toSet();
final localStatuses =
await _syncService.getLocalWatchStatusesBatched(globalKeys);
// Find first unwatched episode
for (final episode in episodes) {
final watched = await isWatched(episode.globalKey);
final localStatus = localStatuses[episode.globalKey];
final watched =
localStatus ?? _downloadProvider.getMetadata(episode.globalKey)?.isWatched ?? false;
if (!watched) {
return episode;
}
@@ -182,15 +189,25 @@ class OfflineWatchProvider extends ChangeNotifier {
/// Get downloaded episodes for a show with their watch status.
///
/// Returns a list of (episode, isWatched) pairs.
/// Uses batched database query for efficiency.
Future<List<(PlexMetadata episode, bool isWatched)>>
getEpisodesWithWatchStatus(String showRatingKey) async {
final episodes = _downloadProvider.getDownloadedEpisodesForShow(
showRatingKey,
);
if (episodes.isEmpty) return [];
// Batch fetch all watch statuses in a single query
final globalKeys = episodes.map((e) => e.globalKey).toSet();
final localStatuses =
await _syncService.getLocalWatchStatusesBatched(globalKeys);
final results = <(PlexMetadata, bool)>[];
for (final episode in episodes) {
final watched = await isWatched(episode.globalKey);
final localStatus = localStatuses[episode.globalKey];
final watched =
localStatus ?? _downloadProvider.getMetadata(episode.globalKey)?.isWatched ?? false;
results.add((episode, watched));
}
+15 -3
View File
@@ -6,7 +6,8 @@ import '../../services/plex_client.dart';
import '../models/plex_metadata.dart';
import '../providers/multi_server_provider.dart';
import '../utils/provider_extensions.dart';
import '../utils/collection_playlist_play_helper.dart';
import '../services/play_queue_launcher.dart';
import '../models/plex_playlist.dart';
import '../utils/app_logger.dart';
import '../mixins/refreshable.dart';
import '../mixins/item_updatable.dart';
@@ -97,12 +98,23 @@ abstract class BaseMediaListDetailScreen<T extends StatefulWidget>
}
final client = _getClientForMediaItem();
final item = mediaItem;
await playCollectionOrPlaylist(
final launcher = PlayQueueLauncher(
context: context,
client: client,
item: mediaItem,
serverId: item is PlexMetadata
? item.serverId
: (item as PlexPlaylist).serverId,
serverName: item is PlexMetadata
? item.serverName
: (item as PlexPlaylist).serverName,
);
await launcher.launchFromCollectionOrPlaylist(
item: item,
shuffle: shuffle,
showLoadingIndicator: false,
);
}
+2 -2
View File
@@ -39,7 +39,7 @@ class DiscoverScreen extends StatefulWidget {
}
class _DiscoverScreenState extends State<DiscoverScreen>
with Refreshable, ItemUpdatable, SingleTickerProviderStateMixin {
with Refreshable, FullRefreshable, ItemUpdatable, SingleTickerProviderStateMixin {
static const Duration _heroAutoScrollDuration = Duration(seconds: 8);
@override
@@ -1184,7 +1184,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
final isEpisode = heroItem.type.toLowerCase() == 'episode';
final showName = heroItem.grandparentTitle ?? heroItem.title;
final screenWidth = MediaQuery.of(context).size.width;
final isLargeScreen = screenWidth > 800;
final isLargeScreen = ScreenBreakpoints.isWideTabletOrLarger(screenWidth);
// Determine content type label for chip
final contentTypeLabel = heroItem.type.toLowerCase() == 'movie'
+14 -9
View File
@@ -3,6 +3,7 @@ import 'package:provider/provider.dart';
import '../../providers/settings_provider.dart';
import '../../services/settings_service.dart' show ViewMode, LibraryDensity;
import '../../utils/grid_size_calculator.dart';
import '../../utils/layout_constants.dart';
/// A widget that automatically switches between grid and list view
/// based on user settings, providing a consistent layout pattern
@@ -20,10 +21,10 @@ class AdaptiveMediaGrid<T> extends StatelessWidget {
final VoidCallback? onRefresh;
/// Optional padding around the grid/list
final EdgeInsets padding;
final EdgeInsets? padding;
/// Child aspect ratio for grid items (width / height)
final double childAspectRatio;
final double? childAspectRatio;
/// Optional focus node for the first item (for programmatic focus)
final FocusNode? firstItemFocusNode;
@@ -36,8 +37,8 @@ class AdaptiveMediaGrid<T> extends StatelessWidget {
required this.items,
required this.itemBuilder,
this.onRefresh,
this.padding = const EdgeInsets.fromLTRB(8, 8, 8, 8),
this.childAspectRatio = 2 / 3.3,
this.padding,
this.childAspectRatio,
this.firstItemFocusNode,
this.onBack,
});
@@ -61,24 +62,28 @@ class AdaptiveMediaGrid<T> extends StatelessWidget {
ViewMode viewMode,
LibraryDensity density,
) {
final effectivePadding = padding ?? GridLayoutConstants.gridPadding;
final effectiveAspectRatio =
childAspectRatio ?? GridLayoutConstants.posterAspectRatio;
if (viewMode == ViewMode.list) {
return ListView.builder(
padding: padding,
padding: effectivePadding,
itemCount: items.length,
itemBuilder: (context, index) =>
itemBuilder(context, items[index], index),
);
} else {
return GridView.builder(
padding: padding,
padding: effectivePadding,
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: GridSizeCalculator.getMaxCrossAxisExtent(
context,
density,
),
childAspectRatio: childAspectRatio,
crossAxisSpacing: 0,
mainAxisSpacing: 0,
childAspectRatio: effectiveAspectRatio,
crossAxisSpacing: GridLayoutConstants.crossAxisSpacing,
mainAxisSpacing: GridLayoutConstants.mainAxisSpacing,
),
itemCount: items.length,
itemBuilder: (context, index) =>
+1 -1
View File
@@ -40,7 +40,7 @@ class LibrariesScreen extends StatefulWidget {
}
class _LibrariesScreenState extends State<LibrariesScreen>
with Refreshable, ItemUpdatable, SingleTickerProviderStateMixin {
with Refreshable, FullRefreshable, FocusableTab, LibraryLoadable, ItemUpdatable, SingleTickerProviderStateMixin {
@override
PlexClient get client {
final multiServerProvider = Provider.of<MultiServerProvider>(
@@ -21,7 +21,6 @@ import '../error_state_widget.dart';
import '../../../services/storage_service.dart';
import '../../../services/settings_service.dart' show ViewMode;
import '../../../mixins/item_updatable.dart';
import '../../../mixins/library_tab_state.dart';
import '../../../i18n/strings.g.dart';
import 'base_library_tab.dart';
@@ -45,7 +44,7 @@ class LibraryBrowseTab extends BaseLibraryTab<PlexMetadata> {
class _LibraryBrowseTabState
extends BaseLibraryTabState<PlexMetadata, LibraryBrowseTab>
with ItemUpdatable, LibraryTabStateMixin, LibraryTabFocusMixin {
with ItemUpdatable, LibraryTabFocusMixin {
@override
PlexClient get client => getClientForLibrary();
+41 -78
View File
@@ -10,6 +10,7 @@ import '../utils/provider_extensions.dart';
import '../utils/platform_detector.dart';
import '../main.dart';
import '../mixins/refreshable.dart';
import '../navigation/navigation_tabs.dart';
import '../providers/multi_server_provider.dart';
import '../providers/server_state_provider.dart';
import '../providers/hidden_libraries_provider.dart';
@@ -166,20 +167,22 @@ class _MainScreenState extends State<MainScreen> with RouteAware {
];
}
int _normalizeIndexForMode(int current, bool offline) {
if (offline) {
// Only two tabs exist offline: 0 = Downloads, 1 = Settings
if (current <= 0) return 0;
if (current == 1) return 1;
return 0;
}
/// Normalize tab index when switching between offline/online modes.
/// Preserves the current tab if it exists in the new mode, otherwise defaults to first tab.
int _normalizeIndexForMode(int currentIndex, bool wasOffline, bool isOffline) {
if (wasOffline == isOffline) return currentIndex;
// Map offline indices back to online equivalents when reconnecting
if (current == 0) return 3; // Downloads tab
if (current == 1) return 4; // Settings tab
if (current < 0) return 0;
if (current > 4) return 0;
return current;
final oldTabs = _getVisibleTabs(wasOffline);
final newTabs = _getVisibleTabs(isOffline);
// Get the tab ID at the current index (or first tab if out of bounds)
final currentTabId = currentIndex >= 0 && currentIndex < oldTabs.length
? oldTabs[currentIndex].id
: oldTabs.first.id;
// Find the same tab in the new mode's tab list
final newIndex = newTabs.indexWhere((tab) => tab.id == currentTabId);
return newIndex >= 0 ? newIndex : 0;
}
void _handleOfflineStatusChanged() {
@@ -187,11 +190,12 @@ class _MainScreenState extends State<MainScreen> with RouteAware {
if (newOffline == _isOffline) return;
final wasOffline = _isOffline;
setState(() {
_isOffline = newOffline;
_screens = _buildScreens(_isOffline);
_selectedLibraryGlobalKey = _isOffline ? null : _selectedLibraryGlobalKey;
_currentIndex = _normalizeIndexForMode(_currentIndex, _isOffline);
_currentIndex = _normalizeIndexForMode(_currentIndex, wasOffline, _isOffline);
});
// Refresh sidebar focus after rebuilding navigation
@@ -223,9 +227,8 @@ class _MainScreenState extends State<MainScreen> with RouteAware {
// When content regains focus while on Libraries, retry focusing the active tab
if (_currentIndex == 1) {
WidgetsBinding.instance.addPostFrameCallback((_) {
final librariesState = _librariesKey.currentState;
if (librariesState != null) {
(librariesState as dynamic).focusActiveTabIfReady();
if (_librariesKey.currentState case final FocusableTab focusable) {
focusable.focusActiveTabIfReady();
}
});
}
@@ -323,21 +326,18 @@ class _MainScreenState extends State<MainScreen> with RouteAware {
appLogger.d('Cleared all provider states for profile switch');
// Full refresh discover screen (reload all content for new profile)
final discoverState = _discoverKey.currentState;
if (discoverState != null) {
(discoverState as dynamic).fullRefresh();
if (_discoverKey.currentState case final FullRefreshable refreshable) {
refreshable.fullRefresh();
}
// Full refresh libraries screen (clear filters and reload for new profile)
final librariesState = _librariesKey.currentState;
if (librariesState != null) {
(librariesState as dynamic).fullRefresh();
if (_librariesKey.currentState case final FullRefreshable refreshable) {
refreshable.fullRefresh();
}
// Full refresh search screen (clear search for new profile)
final searchState = _searchKey.currentState;
if (searchState != null) {
(searchState as dynamic).fullRefresh();
if (_searchKey.currentState case final FullRefreshable refreshable) {
refreshable.fullRefresh();
}
}
@@ -356,16 +356,14 @@ class _MainScreenState extends State<MainScreen> with RouteAware {
}
// Ensure the libraries screen applies focus when brought into view
if (index == 1 && previousIndex != 1) {
final librariesState = _librariesKey.currentState;
if (librariesState != null) {
(librariesState as dynamic).focusActiveTabIfReady();
if (_librariesKey.currentState case final FocusableTab focusable) {
focusable.focusActiveTabIfReady();
}
}
// Focus search input when selecting Search tab
if (index == 2) {
final searchState = _searchKey.currentState;
if (searchState != null) {
(searchState as dynamic).focusSearchInput();
if (_searchKey.currentState case final SearchInputFocusable searchable) {
searchable.focusSearchInput();
}
}
}
@@ -377,57 +375,22 @@ class _MainScreenState extends State<MainScreen> with RouteAware {
_currentIndex = 1; // Switch to Libraries tab
});
// Tell LibrariesScreen to load this library
final librariesState = _librariesKey.currentState;
if (librariesState != null) {
(librariesState as dynamic).loadLibraryByKey(libraryGlobalKey);
(librariesState as dynamic).focusActiveTabIfReady();
if (_librariesKey.currentState case final LibraryLoadable loadable) {
loadable.loadLibraryByKey(libraryGlobalKey);
}
if (_librariesKey.currentState case final FocusableTab focusable) {
focusable.focusActiveTabIfReady();
}
}
/// Get navigation tabs filtered by offline mode
List<NavigationTab> _getVisibleTabs(bool isOffline) {
return NavigationTab.getVisibleTabs(isOffline: isOffline);
}
/// Build navigation destinations for bottom navigation bar.
List<NavigationDestination> _buildNavDestinations(bool isOffline) {
if (isOffline) {
return [
NavigationDestination(
icon: const AppIcon(Symbols.download_rounded, fill: 1),
selectedIcon: const AppIcon(Symbols.download_rounded, fill: 1),
label: t.navigation.downloads,
),
NavigationDestination(
icon: const AppIcon(Symbols.settings_rounded, fill: 1),
selectedIcon: const AppIcon(Symbols.settings_rounded, fill: 1),
label: t.navigation.settings,
),
];
}
return [
NavigationDestination(
icon: const AppIcon(Symbols.home_rounded, fill: 1),
selectedIcon: const AppIcon(Symbols.home_rounded, fill: 1),
label: t.navigation.home,
),
NavigationDestination(
icon: const AppIcon(Symbols.video_library_rounded, fill: 1),
selectedIcon: const AppIcon(Symbols.video_library_rounded, fill: 1),
label: t.navigation.libraries,
),
NavigationDestination(
icon: const AppIcon(Symbols.search_rounded, fill: 1),
selectedIcon: const AppIcon(Symbols.search_rounded, fill: 1),
label: t.navigation.search,
),
NavigationDestination(
icon: const AppIcon(Symbols.download_rounded, fill: 1),
selectedIcon: const AppIcon(Symbols.download_rounded, fill: 1),
label: t.navigation.downloads,
),
NavigationDestination(
icon: const AppIcon(Symbols.settings_rounded, fill: 1),
selectedIcon: const AppIcon(Symbols.settings_rounded, fill: 1),
label: t.navigation.settings,
),
];
return _getVisibleTabs(isOffline).map((tab) => tab.toDestination()).toList();
}
@override
+2 -1
View File
@@ -21,7 +21,8 @@ class SearchScreen extends StatefulWidget {
State<SearchScreen> createState() => _SearchScreenState();
}
class _SearchScreenState extends State<SearchScreen> with Refreshable {
class _SearchScreenState extends State<SearchScreen>
with Refreshable, FullRefreshable, SearchInputFocusable {
final _searchController = TextEditingController();
final _searchFocusNode = FocusNode(debugLabel: 'SearchInput');
List<PlexMetadata> _searchResults = [];
+70 -35
View File
@@ -85,48 +85,25 @@ class DataAggregationService {
return _cachedLibrariesByServer!;
}
final clients = _serverManager.onlineClients;
if (clients.isEmpty) {
appLogger.w('No online servers available for fetching libraries');
return {};
}
appLogger.d('Fetching libraries from ${clients.length} servers');
final libraryFutures = clients.entries.map((entry) async {
final serverId = entry.key;
final client = entry.value;
try {
final libraries = await client.getLibraries();
appLogger.d(
'Fetched ${libraries.length} libraries for server $serverId',
);
return MapEntry(serverId, libraries);
} catch (e) {
appLogger.e(
'Failed to fetch libraries from server $serverId',
error: e,
);
return MapEntry(serverId, <PlexLibrary>[]);
}
});
final libraryResults = await Future.wait(libraryFutures);
final librariesByServer = Map.fromEntries(libraryResults);
final totalLibraries = libraryResults.fold<int>(
0,
(sum, entry) => sum + entry.value.length,
final librariesByServer = await _perServerGrouped<PlexLibrary>(
operationName: 'fetching libraries',
operation: (serverId, client, server) async {
return await client.getLibraries();
},
);
// Cache the results
_cachedLibrariesByServer = librariesByServer;
_librariesCacheTime = DateTime.now();
appLogger.d(
'Fetched $totalLibraries libraries from ${clients.length} servers',
final totalLibraries = librariesByServer.values.fold<int>(
0,
(sum, libs) => sum + libs.length,
);
appLogger.d(
'Fetched $totalLibraries libraries from ${librariesByServer.length} servers',
);
return librariesByServer;
}
@@ -368,4 +345,62 @@ class DataAggregationService {
return allResults;
}
/// Higher-order helper for per-server fan-out operations that groups results by server
///
/// Similar to [_perServer] but returns a Map with results grouped by serverId
/// instead of flattening into a single list.
///
/// Type parameter `T` is the item type returned by the operation
/// [operationName] is used for logging (e.g., "fetching libraries")
/// [operation] is the async function to run per server, returning `List<T>`
Future<Map<String, List<T>>> _perServerGrouped<T>({
required String operationName,
required Future<List<T>> Function(
String serverId,
PlexClient client,
PlexServer? server,
)
operation,
}) async {
final clients = _serverManager.onlineClients;
if (clients.isEmpty) {
appLogger.w('No online servers available for $operationName');
return {};
}
appLogger.d('$operationName from ${clients.length} servers');
// Execute operation on all servers in parallel
final futures = clients.entries.map((entry) async {
final serverId = entry.key;
final client = entry.value;
final server = _serverManager.getServer(serverId);
final sw = Stopwatch()..start();
try {
final result = await operation(serverId, client, server);
appLogger.d(
'$operationName for server $serverId completed in ${sw.elapsedMilliseconds}ms with ${result.length} items',
);
return MapEntry(serverId, result);
} catch (e, stackTrace) {
appLogger.e(
'Failed $operationName from server $serverId',
error: e,
stackTrace: stackTrace,
);
_serverManager.updateServerStatus(serverId, false);
appLogger.d(
'$operationName for server $serverId failed after ${sw.elapsedMilliseconds}ms',
);
return MapEntry(serverId, <T>[]);
}
});
final results = await Future.wait(futures);
return Map.fromEntries(results);
}
}
+61 -76
View File
@@ -126,10 +126,9 @@ class DownloadStorageService {
// Default path logic
final baseDir = await _getBaseAppDir();
_baseDownloadsDir = Directory(path.join(baseDir.path, 'downloads'));
if (!await _baseDownloadsDir!.exists()) {
await _baseDownloadsDir!.create(recursive: true);
}
_baseDownloadsDir = await _ensureDirectoryExists(
Directory(path.join(baseDir.path, 'downloads')),
);
return _baseDownloadsDir!;
}
@@ -142,11 +141,11 @@ class DownloadStorageService {
final parent = customDir.parent;
final artworkDir = Directory(path.join(parent.path, 'artwork'));
try {
if (!await artworkDir.exists()) {
await artworkDir.create(recursive: true);
// Validate writeability for custom artwork path
if (await isDirectoryWritable(artworkDir)) {
_artworkDirectoryPath = artworkDir.path;
return artworkDir;
}
_artworkDirectoryPath = artworkDir.path;
return artworkDir;
} catch (e) {
// Fall through to default if we can't create artwork dir
}
@@ -154,10 +153,9 @@ class DownloadStorageService {
// Default: Get the app base directory directly (not downloads directory)
final baseDir = await _getBaseAppDir();
final artworkDir = Directory(path.join(baseDir.path, 'artwork'));
if (!await artworkDir.exists()) {
await artworkDir.create(recursive: true);
}
final artworkDir = await _ensureDirectoryExists(
Directory(path.join(baseDir.path, 'artwork')),
);
// Cache the path for synchronous access
_artworkDirectoryPath = artworkDir.path;
return artworkDir;
@@ -202,11 +200,9 @@ class DownloadStorageService {
/// Get directory for a specific media item
Future<Directory> getMediaDirectory(String serverId, String ratingKey) async {
final baseDir = await getDownloadsDirectory();
final mediaDir = Directory(path.join(baseDir.path, serverId, ratingKey));
if (!await mediaDir.exists()) {
await mediaDir.create(recursive: true);
}
return mediaDir;
return _ensureDirectoryExists(
Directory(path.join(baseDir.path, serverId, ratingKey)),
);
}
/// Get video file path
@@ -267,25 +263,46 @@ class DownloadStorageService {
.trim();
}
/// Get movie directory: downloads/Movies/{Movie Name} ({Year})/
Future<Directory> getMovieDirectory(PlexMetadata movie) async {
final baseDir = await getDownloadsDirectory();
final movieName = _sanitizeFileName(movie.title);
final year = movie.year;
final movieFolder = year != null ? '$movieName ($year)' : movieName;
final dir = Directory(path.join(baseDir.path, 'Movies', movieFolder));
/// Ensure a directory exists, creating it if necessary
Future<Directory> _ensureDirectoryExists(Directory dir) async {
if (!await dir.exists()) {
await dir.create(recursive: true);
}
return dir;
}
/// Format a media title with optional year: "Title (YYYY)" or "Title"
String _formatTitleWithYear(String title, int? year) {
final sanitized = _sanitizeFileName(title);
return year != null ? '$sanitized ($year)' : sanitized;
}
/// Get the folder name for a movie: "Movie Name (YYYY)"
String _getMovieFolderName(PlexMetadata movie) {
return _formatTitleWithYear(movie.title, movie.year);
}
/// Get the folder name for a TV show: "Show Name (YYYY)"
/// [showYear]: Pass explicitly for episodes (episode.year may differ from show's year)
String _getShowFolderName(PlexMetadata metadata, {int? showYear}) {
final title = metadata.grandparentTitle ?? metadata.title;
final year = showYear ?? metadata.year;
return _formatTitleWithYear(title, year);
}
/// Get movie directory: downloads/Movies/{Movie Name} ({Year})/
Future<Directory> getMovieDirectory(PlexMetadata movie) async {
final baseDir = await getDownloadsDirectory();
final movieFolder = _getMovieFolderName(movie);
return _ensureDirectoryExists(
Directory(path.join(baseDir.path, 'Movies', movieFolder)),
);
}
/// Get movie video file path: .../Movie Name (YYYY)/Movie Name (YYYY).{ext}
Future<String> getMovieVideoPath(PlexMetadata movie, String extension) async {
final movieDir = await getMovieDirectory(movie);
final movieName = _sanitizeFileName(movie.title);
final year = movie.year;
final fileName = year != null ? '$movieName ($year)' : movieName;
final fileName = _getMovieFolderName(movie);
return path.join(movieDir.path, '$fileName.$extension');
}
@@ -306,18 +323,10 @@ class DownloadStorageService {
int? showYear,
}) async {
final baseDir = await getDownloadsDirectory();
// For episodes, use grandparentTitle; for shows, use title
final showName = _sanitizeFileName(
metadata.grandparentTitle ?? metadata.title,
final showFolder = _getShowFolderName(metadata, showYear: showYear);
return _ensureDirectoryExists(
Directory(path.join(baseDir.path, 'TV Shows', showFolder)),
);
// Use explicit showYear if provided, otherwise fall back to metadata.year
final year = showYear ?? metadata.year;
final showFolder = year != null ? '$showName ($year)' : showName;
final dir = Directory(path.join(baseDir.path, 'TV Shows', showFolder));
if (!await dir.exists()) {
await dir.create(recursive: true);
}
return dir;
}
/// Get show artwork path: downloads/TV Shows/{Show}/poster.jpg
@@ -338,11 +347,9 @@ class DownloadStorageService {
}) async {
final showDir = await getShowDirectory(metadata, showYear: showYear);
final seasonNum = (metadata.parentIndex ?? 0).toString().padLeft(2, '0');
final dir = Directory(path.join(showDir.path, 'Season $seasonNum'));
if (!await dir.exists()) {
await dir.create(recursive: true);
}
return dir;
return _ensureDirectoryExists(
Directory(path.join(showDir.path, 'Season $seasonNum')),
);
}
/// Get season artwork path: .../Season XX/poster.jpg
@@ -394,13 +401,9 @@ class DownloadStorageService {
int? showYear,
}) async {
final base = await _getEpisodeBasePath(episode, showYear: showYear);
final subsDir = Directory(
path.join(base.seasonDirPath, '${base.fileName}_subs'),
return _ensureDirectoryExists(
Directory(path.join(base.seasonDirPath, '${base.fileName}_subs')),
);
if (!await subsDir.exists()) {
await subsDir.create(recursive: true);
}
return subsDir;
}
/// Get episode subtitle path
@@ -421,14 +424,10 @@ class DownloadStorageService {
/// Get subtitles directory for movie
Future<Directory> getMovieSubtitlesDirectory(PlexMetadata movie) async {
final movieDir = await getMovieDirectory(movie);
final movieName = _sanitizeFileName(movie.title);
final year = movie.year;
final baseName = year != null ? '$movieName ($year)' : movieName;
final subsDir = Directory(path.join(movieDir.path, '${baseName}_subs'));
if (!await subsDir.exists()) {
await subsDir.create(recursive: true);
}
return subsDir;
final baseName = _getMovieFolderName(movie);
return _ensureDirectoryExists(
Directory(path.join(movieDir.path, '${baseName}_subs')),
);
}
/// Get movie subtitle path
@@ -544,13 +543,9 @@ class DownloadStorageService {
/// Files are downloaded here first, then copied to SAF if using SAF mode
Future<Directory> getCacheDownloadDirectory() async {
final cacheDir = await getApplicationDocumentsDirectory();
final downloadCache = Directory(
path.join(cacheDir.path, '.download_cache'),
return _ensureDirectoryExists(
Directory(path.join(cacheDir.path, '.download_cache')),
);
if (!await downloadCache.exists()) {
await downloadCache.create(recursive: true);
}
return downloadCache;
}
/// Get temporary file path for downloading (before copying to SAF)
@@ -636,10 +631,7 @@ class DownloadStorageService {
/// Get path components for SAF based on media type
/// Returns list of directory names to create under the SAF base
List<String> getMovieSafPathComponents(PlexMetadata movie) {
final movieName = _sanitizeFileName(movie.title);
final year = movie.year;
final movieFolder = year != null ? '$movieName ($year)' : movieName;
return ['Movies', movieFolder];
return ['Movies', _getMovieFolderName(movie)];
}
/// Get path components for episode SAF storage
@@ -647,21 +639,14 @@ class DownloadStorageService {
PlexMetadata episode, {
int? showYear,
}) {
final showName = _sanitizeFileName(
episode.grandparentTitle ?? episode.title,
);
final year = showYear ?? episode.year;
final showFolder = year != null ? '$showName ($year)' : showName;
final showFolder = _getShowFolderName(episode, showYear: showYear);
final seasonNum = (episode.parentIndex ?? 0).toString().padLeft(2, '0');
return ['TV Shows', showFolder, 'Season $seasonNum'];
}
/// Get SAF file name for a movie
String getMovieSafFileName(PlexMetadata movie, String extension) {
final movieName = _sanitizeFileName(movie.title);
final year = movie.year;
final fileName = year != null ? '$movieName ($year)' : movieName;
return '$fileName.$extension';
return '${_getMovieFolderName(movie)}.$extension';
}
/// Get SAF file name for an episode
+14 -2
View File
@@ -16,15 +16,19 @@ class MediaControlsManager {
/// Stream of control events from OS media controls
Stream<dynamic> get controlEvents => OsMediaControls.controlEvents;
/// Throttled playback state update (1 second interval, leading edge only)
/// Throttled playback state update (1 second interval, leading + trailing)
late final Throttle _throttledUpdate;
/// Cached control enabled state to avoid redundant platform calls
bool? _lastCanGoNext;
bool? _lastCanGoPrevious;
MediaControlsManager() {
_throttledUpdate = throttle(
_doUpdatePlaybackState,
const Duration(seconds: 1),
leading: true,
trailing: false,
trailing: true, // Send final position at end of throttle window
);
}
@@ -117,6 +121,14 @@ class MediaControlsManager {
bool canGoNext = false,
bool canGoPrevious = false,
}) async {
// Skip if unchanged (avoid redundant platform calls)
if (canGoNext == _lastCanGoNext && canGoPrevious == _lastCanGoPrevious) {
return;
}
_lastCanGoNext = canGoNext;
_lastCanGoPrevious = canGoPrevious;
try {
final controls = <MediaControl>[];
if (canGoPrevious) controls.add(MediaControl.previous);
+60 -84
View File
@@ -66,6 +66,62 @@ class MultiServerManager {
/// Check if a server is online
bool isServerOnline(String serverId) => _serverStatus[serverId] ?? false;
/// Creates and initializes a PlexClient for a given server
///
/// Handles finding working connection, loading cached endpoint,
/// creating config, and building client with failover support.
Future<PlexClient> _createClientForServer({
required PlexServer server,
required String clientIdentifier,
}) async {
final serverId = server.clientIdentifier;
// Find best working connection
PlexConnection? workingConnection;
await for (final connection in server.findBestWorkingConnection()) {
workingConnection = connection;
break;
}
if (workingConnection == null) {
throw Exception('No working connection found');
}
final baseUrl = workingConnection.uri;
// Get storage and load cached endpoint for this server
final storage = await StorageService.getInstance();
final cachedEndpoint = storage.getServerEndpoint(serverId);
// Create PlexClient with failover support
final prioritizedEndpoints = server.prioritizedEndpointUrls(
preferredFirst: cachedEndpoint ?? baseUrl,
);
final config = await PlexConfig.create(
baseUrl: baseUrl,
token: server.accessToken,
clientIdentifier: clientIdentifier,
);
final client = PlexClient(
config,
serverId: serverId,
serverName: server.name,
prioritizedEndpoints: prioritizedEndpoints,
onEndpointChanged: (newUrl) async {
await storage.saveServerEndpoint(serverId, newUrl);
appLogger.i(
'Updated endpoint for ${server.name} after failover: $newUrl',
);
},
);
// Save the initial endpoint
await storage.saveServerEndpoint(serverId, baseUrl);
return client;
}
/// Connect to all available servers in parallel
/// Returns the number of successfully connected servers
Future<int> connectToAllServers(
@@ -93,52 +149,11 @@ class MultiServerManager {
try {
appLogger.d('Attempting connection to server: ${server.name}');
// Find best working connection for this server
PlexConnection? workingConnection;
await for (final connection in server.findBestWorkingConnection()) {
workingConnection = connection;
// Use first working connection (could wait for optimal, but that's slower)
break;
}
if (workingConnection == null) {
throw Exception('No working connection found');
}
final baseUrl = workingConnection.uri;
appLogger.d('Connected to ${server.name} at $baseUrl');
// Get storage and load cached endpoint for this server
final storage = await StorageService.getInstance();
final cachedEndpoint = storage.getServerEndpoint(serverId);
// Create PlexClient with the working connection and failover support
final prioritizedEndpoints = server.prioritizedEndpointUrls(
preferredFirst: cachedEndpoint ?? baseUrl,
);
final config = await PlexConfig.create(
baseUrl: baseUrl,
token: server.accessToken,
final client = await _createClientForServer(
server: server,
clientIdentifier: effectiveClientId,
);
final client = PlexClient(
config,
serverId: serverId,
serverName: server.name,
prioritizedEndpoints: prioritizedEndpoints,
onEndpointChanged: (newUrl) async {
await storage.saveServerEndpoint(serverId, newUrl);
appLogger.i(
'Updated endpoint for ${server.name} after failover: $newUrl',
);
},
);
// Save the initial endpoint
await storage.saveServerEndpoint(serverId, baseUrl);
// Store the client and server info
_clients[serverId] = client;
_servers[serverId] = server;
@@ -204,50 +219,11 @@ class MultiServerManager {
try {
appLogger.d('Adding server: ${server.name}');
// Find best working connection
PlexConnection? workingConnection;
await for (final connection in server.findBestWorkingConnection()) {
workingConnection = connection;
break;
}
if (workingConnection == null) {
throw Exception('No working connection found');
}
final baseUrl = workingConnection.uri;
// Get storage and load cached endpoint for this server
final storage = await StorageService.getInstance();
final cachedEndpoint = storage.getServerEndpoint(serverId);
// Create PlexClient with failover support
final prioritizedEndpoints = server.prioritizedEndpointUrls(
preferredFirst: cachedEndpoint ?? baseUrl,
);
final config = await PlexConfig.create(
baseUrl: baseUrl,
token: server.accessToken,
final client = await _createClientForServer(
server: server,
clientIdentifier: effectiveClientId,
);
final client = PlexClient(
config,
serverId: serverId,
serverName: server.name,
prioritizedEndpoints: prioritizedEndpoints,
onEndpointChanged: (newUrl) async {
await storage.saveServerEndpoint(serverId, newUrl);
appLogger.i(
'Updated endpoint for ${server.name} after failover: $newUrl',
);
},
);
// Save the initial endpoint
await storage.saveServerEndpoint(serverId, baseUrl);
// Store
_clients[serverId] = client;
_servers[serverId] = server;
+111 -50
View File
@@ -21,6 +21,7 @@ class OfflineWatchSyncService extends ChangeNotifier {
OfflineModeProvider? _offlineModeProvider;
VoidCallback? _offlineModeListener;
bool _isSyncing = false;
bool _isBidirectionalSyncing = false;
DateTime? _lastSyncTime;
bool _hasPerformedStartupSync = false;
@@ -75,28 +76,39 @@ class OfflineWatchSyncService extends ChangeNotifier {
///
/// Push always happens immediately. Pull respects [minSyncInterval] unless [force] is true.
Future<void> _performBidirectionalSync({bool force = false}) async {
// Prevent overlapping bidirectional syncs
if (_isBidirectionalSyncing) {
appLogger.d('Bidirectional sync already in progress, skipping');
return;
}
if (_serverManager.onlineClients.isEmpty) {
appLogger.d('Skipping watch sync - no connected servers available yet');
return;
}
// Always push local changes to server (never throttle outbound sync)
await syncPendingItems();
_isBidirectionalSyncing = true;
try {
// Always push local changes to server (never throttle outbound sync)
await syncPendingItems();
// Only throttle the pull from server
if (!force && _lastSyncTime != null) {
final elapsed = DateTime.now().difference(_lastSyncTime!);
if (elapsed < minSyncInterval) {
appLogger.d(
'Skipping server pull - last sync was ${elapsed.inMinutes}m ago (min: ${minSyncInterval.inMinutes}m)',
);
return;
// Only throttle the pull from server
if (!force && _lastSyncTime != null) {
final elapsed = DateTime.now().difference(_lastSyncTime!);
if (elapsed < minSyncInterval) {
appLogger.d(
'Skipping server pull - last sync was ${elapsed.inMinutes}m ago (min: ${minSyncInterval.inMinutes}m)',
);
return;
}
}
}
// Pull latest states from server
await syncWatchStatesFromServer();
_lastSyncTime = DateTime.now();
// Pull latest states from server
await syncWatchStatesFromServer();
_lastSyncTime = DateTime.now();
} finally {
_isBidirectionalSyncing = false;
}
}
/// Called when app becomes active - syncs if interval has passed.
@@ -217,6 +229,40 @@ class OfflineWatchSyncService extends ChangeNotifier {
}
}
/// Get local watch statuses for multiple items in a single database query.
///
/// Returns a map of globalKey -> watch status (true/false/null).
/// More efficient than calling getLocalWatchStatus multiple times.
Future<Map<String, bool?>> getLocalWatchStatusesBatched(
Set<String> globalKeys,
) async {
if (globalKeys.isEmpty) return {};
final actions = await _database.getLatestWatchActionsForKeys(globalKeys);
final result = <String, bool?>{};
for (final key in globalKeys) {
final action = actions[key];
if (action == null) {
result[key] = null;
continue;
}
switch (action.actionType) {
case 'watched':
result[key] = true;
case 'unwatched':
result[key] = false;
case 'progress':
result[key] = action.shouldMarkWatched;
default:
result[key] = null;
}
}
return result;
}
/// Get the local view offset (resume position) for a media item.
///
/// Returns the locally tracked position, or null if none exists.
@@ -240,6 +286,7 @@ class OfflineWatchSyncService extends ChangeNotifier {
/// Sync all pending items to their respective servers.
///
/// Called automatically when connectivity is restored, or manually.
/// Actions are batched by server to reduce connectivity lookups.
Future<void> syncPendingItems() async {
if (_isSyncing) {
appLogger.d('Sync already in progress, skipping');
@@ -259,6 +306,9 @@ class OfflineWatchSyncService extends ChangeNotifier {
appLogger.i('Syncing ${pendingActions.length} pending watch actions');
// First pass: handle retry limit exceeded and group by server
final actionsByServer = <String, List<OfflineWatchProgressItem>>{};
for (final action in pendingActions) {
// Delete items that have exceeded retry limit
if (action.syncAttempts >= maxSyncAttempts) {
@@ -270,43 +320,54 @@ class OfflineWatchSyncService extends ChangeNotifier {
continue;
}
// Get the client for this server
final client = _serverManager.getClient(action.serverId);
if (client == null) {
// Check if this server still exists in the app
if (_serverManager.getServer(action.serverId) == null) {
appLogger.w(
'Deleting action ${action.id} - server ${action.serverId} no longer exists',
);
await _database.deleteWatchAction(action.id);
} else {
appLogger.w(
'No client available for server ${action.serverId}, will retry',
);
await _database.updateSyncAttempt(
action.id,
'Server not available',
);
}
continue;
}
// Check if server is online
if (!_serverManager.isServerOnline(action.serverId)) {
appLogger.d('Server ${action.serverId} is offline, skipping');
continue;
}
try {
await _syncAction(client, action);
// Success - delete the action from queue
await _database.deleteWatchAction(action.id);
appLogger.d(
'Successfully synced action ${action.id}: ${action.actionType} for ${action.ratingKey}',
// Check if server still exists
if (_serverManager.getServer(action.serverId) == null) {
appLogger.w(
'Deleting action ${action.id} - server ${action.serverId} no longer exists',
);
} catch (e) {
appLogger.w('Failed to sync action ${action.id}: $e');
await _database.updateSyncAttempt(action.id, e.toString());
await _database.deleteWatchAction(action.id);
continue;
}
actionsByServer.putIfAbsent(action.serverId, () => []).add(action);
}
// Second pass: process each server's actions with single connectivity check
for (final entry in actionsByServer.entries) {
final serverId = entry.key;
final actions = entry.value;
await _withOnlineClient(serverId, (client) async {
for (final action in actions) {
try {
await _syncAction(client, action);
// Success - delete the action from queue
await _database.deleteWatchAction(action.id);
appLogger.d(
'Successfully synced action ${action.id}: ${action.actionType} for ${action.ratingKey}',
);
} catch (e) {
appLogger.w('Failed to sync action ${action.id}: $e');
await _database.updateSyncAttempt(action.id, e.toString());
}
}
});
// If _withOnlineClient returned null (server offline), mark actions for retry
if (_serverManager.getClient(serverId) == null ||
!_serverManager.isServerOnline(serverId)) {
for (final action in actions) {
// Only update if we haven't already processed it
final stillPending = await _database.getLatestWatchAction(
'${action.serverId}:${action.ratingKey}',
);
if (stillPending != null && stillPending.id == action.id) {
await _database.updateSyncAttempt(
action.id,
'Server not available',
);
}
}
}
}
} finally {
+17 -23
View File
@@ -152,9 +152,9 @@ class PlayQueueLauncher {
required PlexMetadata metadata,
bool showLoadingIndicator = true,
}) async {
final itemType = metadata.type.toLowerCase();
final mediaType = metadata.mediaType;
if (itemType != 'show' && itemType != 'season') {
if (mediaType != PlexMediaType.show && mediaType != PlexMediaType.season) {
return PlayQueueError(
Exception('Shuffle play only works for shows and seasons'),
);
@@ -166,7 +166,7 @@ class PlayQueueLauncher {
execute: () async {
// Determine the rating key for the play queue
String showRatingKey;
if (itemType == 'show') {
if (mediaType == PlexMediaType.show) {
showRatingKey = metadata.ratingKey;
} else {
// For seasons, we need the show's rating key
@@ -244,24 +244,18 @@ class PlayQueueLauncher {
required String action,
required Future<PlayQueueResult> Function() execute,
}) async {
// Show loading indicator
if (showLoading && context.mounted) {
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => const Center(child: CircularProgressIndicator()),
);
}
try {
// Show loading indicator
if (showLoading && context.mounted) {
showDialog(
context: context,
barrierDismissible: false,
builder: (context) =>
const Center(child: CircularProgressIndicator()),
);
}
final result = await execute();
// Close loading indicator
if (showLoading && context.mounted && Navigator.canPop(context)) {
Navigator.pop(context);
}
// Handle empty queue result
if (result is PlayQueueEmpty && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
@@ -273,11 +267,6 @@ class PlayQueueLauncher {
} catch (e) {
appLogger.e('Failed to $action', error: e);
// Close loading indicator if it's still open
if (showLoading && context.mounted && Navigator.canPop(context)) {
Navigator.pop(context);
}
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
@@ -289,6 +278,11 @@ class PlayQueueLauncher {
}
return PlayQueueError(e);
} finally {
// Close loading indicator (guaranteed cleanup)
if (showLoading && context.mounted && Navigator.canPop(context)) {
Navigator.pop(context);
}
}
}
}
@@ -17,6 +17,11 @@ class PlaybackInitializationService {
PlaybackInitializationService({required this.client, this.database});
/// Format a video path as a URL (adds file:// prefix for file paths)
String _formatVideoUrl(String path) {
return path.contains('://') ? path : 'file://$path';
}
/// Check if content is available offline and return local path
///
/// Returns the local file path if the video is downloaded and completed.
@@ -50,27 +55,22 @@ class PlaybackInitializationService {
final storageService = DownloadStorageService.instance;
final storedPath = downloadedItem.videoFilePath!;
// Check if this is a SAF URI (content://)
if (storageService.isSafUri(storedPath)) {
// SAF URIs are already valid for media players
appLogger.d('Found offline video (SAF): $storedPath');
return storedPath;
// Get readable path (handles both SAF URIs and file paths)
final readablePath = await storageService.getReadablePath(storedPath);
// For file paths (not SAF), verify the file exists
if (!storageService.isSafUri(storedPath)) {
final file = File(readablePath);
if (!await file.exists()) {
appLogger.w(
'Offline video file not found: $readablePath (stored as: $storedPath)',
);
return null;
}
}
// Convert relative path to absolute path (handles both old absolute and new relative paths)
final absolutePath = await storageService.ensureAbsolutePath(storedPath);
// Verify file exists
final file = File(absolutePath);
if (!await file.exists()) {
appLogger.w(
'Offline video file not found: $absolutePath (stored as: $storedPath)',
);
return null;
}
appLogger.d('Found offline video: $absolutePath');
return absolutePath;
appLogger.d('Found offline video: $readablePath');
return readablePath;
} catch (e) {
appLogger.w('Error checking offline video path', error: e);
return null;
@@ -113,12 +113,10 @@ class PlaybackInitializationService {
playbackData.mediaInfo,
);
// Return result with local file path (file:// prefix for file paths, keep SAF URIs as-is)
// Return result with local file path
return PlaybackInitializationResult(
availableVersions: playbackData.availableVersions,
videoUrl: offlineVideoPath.contains('://')
? offlineVideoPath
: 'file://$offlineVideoPath',
videoUrl: _formatVideoUrl(offlineVideoPath),
mediaInfo: playbackData.mediaInfo,
externalSubtitles: externalSubtitles,
isOffline: true,
@@ -131,9 +129,7 @@ class PlaybackInitializationService {
);
return PlaybackInitializationResult(
availableVersions: [],
videoUrl: offlineVideoPath.contains('://')
? offlineVideoPath
: 'file://$offlineVideoPath',
videoUrl: _formatVideoUrl(offlineVideoPath),
mediaInfo: null,
externalSubtitles: const [],
isOffline: true,
@@ -62,6 +62,11 @@ class PlaybackProgressTracker {
return;
}
// Send initial progress immediately (don't wait for first timer tick)
if (player.state.playing) {
_sendProgress('playing');
}
_progressTimer = Timer.periodic(updateInterval, (timer) {
if (player.state.playing) {
_sendProgress('playing');
+265 -197
View File
@@ -535,6 +535,121 @@ class PlexClient {
return null;
}
/// Fetch metadata with cache support for offline mode and network fallback.
///
/// Returns the raw response data (Map) or null if not available.
/// Used by playback methods to share caching logic.
Future<Map<String, dynamic>?> _fetchMetadataWithCache(
String ratingKey, {
Map<String, dynamic>? queryParams,
}) async {
final cacheKey = '/library/metadata/$ratingKey';
// Offline mode: cache only
if (_offlineMode) {
return await _cache.get(serverId, cacheKey);
}
// Online: try network first
try {
final response = await _dio.get(
'/library/metadata/$ratingKey',
queryParameters: queryParams,
);
// Cache at base endpoint
if (response.data != null) {
await _cache.put(serverId, cacheKey, response.data);
}
return response.data;
} catch (e) {
// Network failed - try cache as fallback
appLogger.w('Network request failed for metadata, trying cache', error: e);
return await _cache.get(serverId, cacheKey);
}
}
/// Get first metadata JSON from response data
Map<String, dynamic>? _getFirstMetadataJsonFromData(Map<String, dynamic>? data) {
if (data == null) return null;
final container = data['MediaContainer'];
if (container != null &&
container['Metadata'] != null &&
(container['Metadata'] as List).isNotEmpty) {
return container['Metadata'][0];
}
return null;
}
/// Parse audio and subtitle tracks from a stream list
({List<PlexAudioTrack> audio, List<PlexSubtitleTrack> subtitles}) _parseStreams(
List<dynamic>? streams,
) {
final audioTracks = <PlexAudioTrack>[];
final subtitleTracks = <PlexSubtitleTrack>[];
if (streams == null) return (audio: audioTracks, subtitles: subtitleTracks);
for (var stream in streams) {
final streamType = stream['streamType'] as int?;
if (streamType == 2) {
// Audio track
audioTracks.add(
PlexAudioTrack(
id: stream['id'] as int,
index: stream['index'] as int?,
codec: stream['codec'] as String?,
language: stream['language'] as String?,
languageCode: stream['languageCode'] as String?,
title: stream['title'] as String?,
displayTitle: stream['displayTitle'] as String?,
channels: stream['channels'] as int?,
selected: stream['selected'] == 1,
),
);
} else if (streamType == 3) {
// Subtitle track
subtitleTracks.add(
PlexSubtitleTrack(
id: stream['id'] as int,
index: stream['index'] as int?,
codec: stream['codec'] as String?,
language: stream['language'] as String?,
languageCode: stream['languageCode'] as String?,
title: stream['title'] as String?,
displayTitle: stream['displayTitle'] as String?,
selected: stream['selected'] == 1,
forced: stream['forced'] == 1,
key: stream['key'] as String?,
),
);
}
}
return (audio: audioTracks, subtitles: subtitleTracks);
}
/// Parse chapters from metadata JSON
List<PlexChapter> _parseChapters(Map<String, dynamic>? metadataJson) {
if (metadataJson == null || metadataJson['Chapter'] == null) {
return [];
}
final chapterList = metadataJson['Chapter'] as List<dynamic>;
return chapterList.map((chapter) {
return PlexChapter(
id: chapter['id'] as int,
index: chapter['index'] as int?,
startTimeOffset: chapter['startTimeOffset'] as int?,
endTimeOffset: chapter['endTimeOffset'] as int?,
title: chapter['tag'] as String? ?? chapter['title'] as String?,
thumb: chapter['thumb'] as String?,
);
}).toList();
}
/// Set per-media language preferences (audio and subtitle)
/// For TV shows, use grandparentRatingKey to set preference for the entire series
/// For movies, use the movie's ratingKey
@@ -811,9 +926,10 @@ class PlexClient {
/// Get video URL for direct playback
/// [mediaIndex] specifies which Media item to use (defaults to 0 - first version)
/// Uses cache for offline mode support and network fallback.
Future<String?> getVideoUrl(String ratingKey, {int mediaIndex = 0}) async {
final response = await _dio.get('/library/metadata/$ratingKey');
final metadataJson = _getFirstMetadataJson(response);
final data = await _fetchMetadataWithCache(ratingKey);
final metadataJson = _getFirstMetadataJsonFromData(data);
if (metadataJson != null &&
metadataJson['Media'] != null &&
@@ -1002,12 +1118,13 @@ class PlexClient {
/// Get detailed media info including chapters and tracks
/// [mediaIndex] specifies which Media item to use (defaults to 0 - first version)
/// Uses cache for offline mode support and network fallback.
Future<PlexMediaInfo?> getMediaInfo(
String ratingKey, {
int mediaIndex = 0,
}) async {
final response = await _dio.get('/library/metadata/$ratingKey');
final metadataJson = _getFirstMetadataJson(response);
final data = await _fetchMetadataWithCache(ratingKey);
final metadataJson = _getFirstMetadataJsonFromData(data);
if (metadataJson != null &&
metadataJson['Media'] != null &&
@@ -1025,70 +1142,15 @@ class PlexClient {
final partKey = part['key'] as String?;
if (partKey != null) {
// Parse streams (audio and subtitle tracks)
final streams = part['Stream'] as List<dynamic>? ?? [];
final audioTracks = <PlexAudioTrack>[];
final subtitleTracks = <PlexSubtitleTrack>[];
for (var stream in streams) {
final streamType = stream['streamType'] as int?;
if (streamType == 2) {
// Audio track
audioTracks.add(
PlexAudioTrack(
id: stream['id'] as int,
index: stream['index'] as int?,
codec: stream['codec'] as String?,
language: stream['language'] as String?,
languageCode: stream['languageCode'] as String?,
title: stream['title'] as String?,
displayTitle: stream['displayTitle'] as String?,
channels: stream['channels'] as int?,
selected: stream['selected'] == 1,
),
);
} else if (streamType == 3) {
// Subtitle track
subtitleTracks.add(
PlexSubtitleTrack(
id: stream['id'] as int,
index: stream['index'] as int?,
codec: stream['codec'] as String?,
language: stream['language'] as String?,
languageCode: stream['languageCode'] as String?,
title: stream['title'] as String?,
displayTitle: stream['displayTitle'] as String?,
selected: stream['selected'] == 1,
forced: stream['forced'] == 1,
key: stream['key'] as String?,
),
);
}
}
// Parse chapters
final chapters = <PlexChapter>[];
if (metadataJson['Chapter'] != null) {
final chapterList = metadataJson['Chapter'] as List<dynamic>;
for (var chapter in chapterList) {
chapters.add(
PlexChapter(
id: chapter['id'] as int,
index: chapter['index'] as int?,
startTimeOffset: chapter['startTimeOffset'] as int?,
endTimeOffset: chapter['endTimeOffset'] as int?,
title: chapter['title'] as String?,
thumb: chapter['thumb'] as String?,
),
);
}
}
// Parse streams using helper
final streams = _parseStreams(part['Stream'] as List<dynamic>?);
// Parse chapters using helper
final chapters = _parseChapters(metadataJson);
return PlexMediaInfo(
videoUrl: '${config.baseUrl}$partKey?X-Plex-Token=${config.token}',
audioTracks: audioTracks,
subtitleTracks: subtitleTracks,
audioTracks: streams.audio,
subtitleTracks: streams.subtitles,
chapters: chapters,
);
}
@@ -1100,9 +1162,10 @@ class PlexClient {
/// Get all available media versions for a media item
/// Returns a list of PlexMediaVersion objects representing different quality/format options
/// Uses cache for offline mode support and network fallback.
Future<List<PlexMediaVersion>> getMediaVersions(String ratingKey) async {
final response = await _dio.get('/library/metadata/$ratingKey');
final metadataJson = _getFirstMetadataJson(response);
final data = await _fetchMetadataWithCache(ratingKey);
final metadataJson = _getFirstMetadataJsonFromData(data);
if (metadataJson != null &&
metadataJson['Media'] != null &&
@@ -1121,12 +1184,13 @@ class PlexClient {
/// Get consolidated video playback data (URL, media info, and versions) in a single API call
/// This method combines the functionality of getVideoUrl(), getMediaInfo(), and getMediaVersions()
/// to reduce redundant API calls during video playback initialization.
/// Uses cache for offline mode support and network fallback.
Future<PlexVideoPlaybackData> getVideoPlaybackData(
String ratingKey, {
int mediaIndex = 0,
}) async {
final response = await _dio.get('/library/metadata/$ratingKey');
final metadataJson = _getFirstMetadataJson(response);
final data = await _fetchMetadataWithCache(ratingKey);
final metadataJson = _getFirstMetadataJsonFromData(data);
String? videoUrl;
PlexMediaInfo? mediaInfo;
@@ -1158,71 +1222,16 @@ class PlexClient {
// Get video URL
videoUrl = '${config.baseUrl}$partKey?X-Plex-Token=${config.token}';
// Parse streams (audio and subtitle tracks) for media info
final streams = part['Stream'] as List<dynamic>? ?? [];
final audioTracks = <PlexAudioTrack>[];
final subtitleTracks = <PlexSubtitleTrack>[];
for (var stream in streams) {
final streamType = stream['streamType'] as int?;
if (streamType == 2) {
// Audio track
audioTracks.add(
PlexAudioTrack(
id: stream['id'] as int,
index: stream['index'] as int?,
codec: stream['codec'] as String?,
language: stream['language'] as String?,
languageCode: stream['languageCode'] as String?,
title: stream['title'] as String?,
displayTitle: stream['displayTitle'] as String?,
channels: stream['channels'] as int?,
selected: stream['selected'] == 1,
),
);
} else if (streamType == 3) {
// Subtitle track
subtitleTracks.add(
PlexSubtitleTrack(
id: stream['id'] as int,
index: stream['index'] as int?,
codec: stream['codec'] as String?,
language: stream['language'] as String?,
languageCode: stream['languageCode'] as String?,
title: stream['title'] as String?,
displayTitle: stream['displayTitle'] as String?,
selected: stream['selected'] == 1,
forced: stream['forced'] == 1,
key: stream['key'] as String?,
),
);
}
}
// Parse chapters
final chapters = <PlexChapter>[];
if (metadataJson['Chapter'] != null) {
final chapterList = metadataJson['Chapter'] as List<dynamic>;
for (var chapter in chapterList) {
chapters.add(
PlexChapter(
id: chapter['id'] as int,
index: chapter['index'] as int?,
startTimeOffset: chapter['startTimeOffset'] as int?,
endTimeOffset: chapter['endTimeOffset'] as int?,
title: chapter['title'] as String?,
thumb: chapter['thumb'] as String?,
),
);
}
}
// Parse streams using helper
final streams = _parseStreams(part['Stream'] as List<dynamic>?);
// Parse chapters using helper
final chapters = _parseChapters(metadataJson);
// Create media info
mediaInfo = PlexMediaInfo(
videoUrl: videoUrl,
audioTracks: audioTracks,
subtitleTracks: subtitleTracks,
audioTracks: streams.audio,
subtitleTracks: streams.subtitles,
chapters: chapters,
partId: part['id'] as int?,
);
@@ -1238,10 +1247,11 @@ class PlexClient {
}
/// Get file information for a media item
/// Uses cache for offline mode support and network fallback.
Future<PlexFileInfo?> getFileInfo(String ratingKey) async {
try {
final response = await _dio.get('/library/metadata/$ratingKey');
final metadataJson = _getFirstMetadataJson(response);
final data = await _fetchMetadataWithCache(ratingKey);
final metadataJson = _getFirstMetadataJsonFromData(data);
if (metadataJson != null &&
metadataJson['Media'] != null &&
@@ -1384,7 +1394,13 @@ class PlexClient {
}
/// Get available sort options for a library section
Future<List<PlexSort>> getLibrarySorts(String sectionId) async {
///
/// 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 {
try {
// Use the dedicated sorts endpoint
final response = await _dio.get('/library/sections/$sectionId/sorts');
@@ -1397,78 +1413,56 @@ class PlexClient {
}
// Fallback: return common sort options if API doesn't provide them
return _getFallbackSorts(sectionId);
return _getFallbackSorts(libraryType);
} catch (e) {
appLogger.e('Failed to get library sorts: $e');
// Return fallback sort options on error
return _getFallbackSorts(sectionId);
return _getFallbackSorts(libraryType);
}
}
Future<List<PlexSort>> _getFallbackSorts(String sectionId) async {
try {
// Get library type to determine which sorts to include
final librariesResponse = await _dio.get('/library/sections');
final libraries = _extractDirectoryList(
librariesResponse,
PlexLibrary.fromJson,
/// Build fallback sort options based on library type.
///
/// If [libraryType] is null, returns generic sorts without the show-specific options.
List<PlexSort> _getFallbackSorts(String? libraryType) {
final fallbackSorts = <PlexSort>[
PlexSort(key: 'titleSort', title: 'Title', defaultDirection: 'asc'),
PlexSort(
key: 'addedAt',
descKey: 'addedAt:desc',
title: 'Date Added',
defaultDirection: 'desc',
),
];
// Add "Latest Episode Air Date" only for TV show libraries
if (libraryType?.toLowerCase() == 'show') {
fallbackSorts.add(
PlexSort(
key: 'episode.originallyAvailableAt',
descKey: 'episode.originallyAvailableAt:desc',
title: 'Latest Episode Air Date',
defaultDirection: 'desc',
),
);
final library = libraries.firstWhere(
(lib) => lib.key == sectionId,
orElse: () => libraries.first,
);
final fallbackSorts = <PlexSort>[
PlexSort(key: 'titleSort', title: 'Title', defaultDirection: 'asc'),
PlexSort(
key: 'addedAt',
descKey: 'addedAt:desc',
title: 'Date Added',
defaultDirection: 'desc',
),
];
// Add "Latest Episode Air Date" only for TV show libraries
if (library.type.toLowerCase() == 'show') {
fallbackSorts.add(
PlexSort(
key: 'episode.originallyAvailableAt',
descKey: 'episode.originallyAvailableAt:desc',
title: 'Latest Episode Air Date',
defaultDirection: 'desc',
),
);
}
fallbackSorts.addAll([
PlexSort(
key: 'originallyAvailableAt',
descKey: 'originallyAvailableAt:desc',
title: 'Release Date',
defaultDirection: 'desc',
),
PlexSort(
key: 'rating',
descKey: 'rating:desc',
title: 'Rating',
defaultDirection: 'desc',
),
]);
return fallbackSorts;
} catch (e) {
appLogger.e('Failed to get fallback sorts: $e');
// Return minimal fallback options
return [
PlexSort(key: 'titleSort', title: 'Title', defaultDirection: 'asc'),
PlexSort(
key: 'addedAt',
descKey: 'addedAt:desc',
title: 'Date Added',
defaultDirection: 'desc',
),
];
}
fallbackSorts.addAll([
PlexSort(
key: 'originallyAvailableAt',
descKey: 'originallyAvailableAt:desc',
title: 'Release Date',
defaultDirection: 'desc',
),
PlexSort(
key: 'rating',
descKey: 'rating:desc',
title: 'Rating',
defaultDirection: 'desc',
),
]);
return fallbackSorts;
}
/// Get library hubs (recommendations for a specific library section)
@@ -2208,6 +2202,80 @@ class PlexClient {
await _dio.get('/library/sections/$sectionId/analyze');
}
// ============================================================================
// Library Statistics Methods
// ============================================================================
/// Get total item count for a library section efficiently.
/// Uses X-Plex-Container-Size: 1 to get totalSize with minimal data transfer.
Future<int> getLibraryTotalCount(String sectionId) async {
try {
final response = await _dio.get(
'/library/sections/$sectionId/all',
queryParameters: {
'X-Plex-Container-Start': 0,
'X-Plex-Container-Size': 1,
},
);
final container = _getMediaContainer(response);
// Try totalSize first, fall back to size if not available
return container?['totalSize'] as int? ??
container?['size'] as int? ??
0;
} catch (e) {
appLogger.e('Failed to get library total count: $e');
return 0;
}
}
/// Get total episode count for a TV show library.
/// Uses the allLeaves endpoint to count all episodes.
Future<int> getLibraryEpisodeCount(String sectionId) async {
try {
final response = await _dio.get(
'/library/sections/$sectionId/allLeaves',
queryParameters: {
'X-Plex-Container-Start': 0,
'X-Plex-Container-Size': 1,
},
);
final container = _getMediaContainer(response);
return container?['totalSize'] as int? ??
container?['size'] as int? ??
0;
} catch (e) {
appLogger.e('Failed to get library episode count: $e');
return 0;
}
}
/// Get watch history count for a time period.
/// [since] - Optional DateTime to filter history from this date onwards.
/// Returns the total count of items watched.
Future<int> getWatchHistoryCount({DateTime? since}) async {
try {
final queryParams = <String, dynamic>{
'X-Plex-Container-Start': 0,
'X-Plex-Container-Size': 1,
};
if (since != null) {
final epochSeconds = since.millisecondsSinceEpoch ~/ 1000;
queryParams['viewedAt>'] = epochSeconds;
}
final response = await _dio.get(
'/status/sessions/history/all',
queryParameters: queryParams,
);
final container = _getMediaContainer(response);
return container?['totalSize'] as int? ??
container?['size'] as int? ??
0;
} catch (e) {
appLogger.e('Failed to get watch history count: $e');
return 0;
}
}
Future<void> _handleEndpointSwitch(String newBaseUrl) async {
if (config.baseUrl == newBaseUrl) {
return;
+224 -286
View File
@@ -60,17 +60,23 @@ class SettingsService extends BaseSharedPreferencesService {
);
}
/// Generic helper to get an enum value from preferences
T _getEnumValue<T extends Enum>(String key, List<T> values, T defaultValue) {
final stored = prefs.getString(key);
if (stored == null) return defaultValue;
return values.firstWhere(
(v) => v.name == stored,
orElse: () => defaultValue,
);
}
// Theme Mode
Future<void> setThemeMode(ThemeMode mode) async {
await prefs.setString(_keyThemeMode, mode.name);
}
ThemeMode getThemeMode() {
final modeString = prefs.getString(_keyThemeMode);
return ThemeMode.values.firstWhere(
(mode) => mode.name == modeString,
orElse: () => ThemeMode.system,
);
return _getEnumValue(_keyThemeMode, ThemeMode.values, ThemeMode.system);
}
// Debug Logging
@@ -135,11 +141,7 @@ class SettingsService extends BaseSharedPreferencesService {
}
LibraryDensity getLibraryDensity() {
final densityString = prefs.getString(_keyLibraryDensity);
return LibraryDensity.values.firstWhere(
(density) => density.name == densityString,
orElse: () => LibraryDensity.normal,
);
return _getEnumValue(_keyLibraryDensity, LibraryDensity.values, LibraryDensity.normal);
}
// View Mode
@@ -148,11 +150,7 @@ class SettingsService extends BaseSharedPreferencesService {
}
ViewMode getViewMode() {
final modeString = prefs.getString(_keyViewMode);
return ViewMode.values.firstWhere(
(mode) => mode.name == modeString,
orElse: () => ViewMode.grid,
);
return _getEnumValue(_keyViewMode, ViewMode.values, ViewMode.grid);
}
// Use Season Poster
@@ -360,19 +358,17 @@ class SettingsService extends BaseSharedPreferencesService {
final jsonString = prefs.getString(_keyKeyboardShortcuts);
if (jsonString == null) return getDefaultKeyboardShortcuts();
try {
final decoded = json.decode(jsonString) as Map<String, dynamic>;
final shortcuts = decoded.map(
(key, value) => MapEntry(key, value.toString()),
);
final decoded = _decodeJsonStringToMap(jsonString);
if (decoded.isEmpty) return getDefaultKeyboardShortcuts();
// Merge with defaults to ensure all keys exist
final defaults = getDefaultKeyboardShortcuts();
defaults.addAll(shortcuts);
return defaults;
} catch (e) {
return getDefaultKeyboardShortcuts();
}
final shortcuts = decoded.map(
(key, value) => MapEntry(key, value.toString()),
);
// Merge with defaults to ensure all keys exist
final defaults = getDefaultKeyboardShortcuts();
defaults.addAll(shortcuts);
return defaults;
}
Future<void> setKeyboardShortcut(String action, String key) async {
@@ -459,6 +455,15 @@ class SettingsService extends BaseSharedPreferencesService {
}
// Helper methods for HotKey serialization
static const _modifierMap = <String, HotKeyModifier>{
'alt': HotKeyModifier.alt,
'control': HotKeyModifier.control,
'shift': HotKeyModifier.shift,
'meta': HotKeyModifier.meta,
'capsLock': HotKeyModifier.capsLock,
'fn': HotKeyModifier.fn,
};
Map<String, dynamic> _serializeHotKey(HotKey hotKey) {
return {
'key': hotKey.key.toString(),
@@ -472,24 +477,7 @@ class SettingsService extends BaseSharedPreferencesService {
final modifierNames = (data['modifiers'] as List<dynamic>).cast<String>();
final modifiers = modifierNames
.map((name) {
switch (name) {
case 'alt':
return HotKeyModifier.alt;
case 'control':
return HotKeyModifier.control;
case 'shift':
return HotKeyModifier.shift;
case 'meta':
return HotKeyModifier.meta;
case 'capsLock':
return HotKeyModifier.capsLock;
case 'fn':
return HotKeyModifier.fn;
default:
return null;
}
})
.map((name) => _modifierMap[name])
.where((m) => m != null)
.cast<HotKeyModifier>()
.toList();
@@ -507,255 +495,205 @@ class SettingsService extends BaseSharedPreferencesService {
return null;
}
// Unified map for USB HID codes to PhysicalKeyboardKey
static const _usbHidKeyMap = <String, PhysicalKeyboardKey>{
// Special keys
'0007002c': PhysicalKeyboardKey.space,
'0007002a': PhysicalKeyboardKey.backspace,
'0007004c': PhysicalKeyboardKey.delete,
'00070028': PhysicalKeyboardKey.enter,
'00070029': PhysicalKeyboardKey.escape,
'0007002b': PhysicalKeyboardKey.tab,
'00070039': PhysicalKeyboardKey.capsLock,
// Arrow keys
'00070050': PhysicalKeyboardKey.arrowLeft,
'00070052': PhysicalKeyboardKey.arrowUp,
'0007004f': PhysicalKeyboardKey.arrowRight,
'00070051': PhysicalKeyboardKey.arrowDown,
// Navigation keys
'0007004a': PhysicalKeyboardKey.home,
'0007004d': PhysicalKeyboardKey.end,
'0007004b': PhysicalKeyboardKey.pageUp,
'0007004e': PhysicalKeyboardKey.pageDown,
// Symbol keys
'0007002d': PhysicalKeyboardKey.equal,
'0007002e': PhysicalKeyboardKey.minus,
// Function keys
'0007003a': PhysicalKeyboardKey.f1,
'0007003b': PhysicalKeyboardKey.f2,
'0007003c': PhysicalKeyboardKey.f3,
'0007003d': PhysicalKeyboardKey.f4,
'0007003e': PhysicalKeyboardKey.f5,
'0007003f': PhysicalKeyboardKey.f6,
'00070040': PhysicalKeyboardKey.f7,
'00070041': PhysicalKeyboardKey.f8,
'00070042': PhysicalKeyboardKey.f9,
'00070043': PhysicalKeyboardKey.f10,
'00070044': PhysicalKeyboardKey.f11,
'00070045': PhysicalKeyboardKey.f12,
// Number keys
'00070027': PhysicalKeyboardKey.digit0,
'0007001e': PhysicalKeyboardKey.digit1,
'0007001f': PhysicalKeyboardKey.digit2,
'00070020': PhysicalKeyboardKey.digit3,
'00070021': PhysicalKeyboardKey.digit4,
'00070022': PhysicalKeyboardKey.digit5,
'00070023': PhysicalKeyboardKey.digit6,
'00070024': PhysicalKeyboardKey.digit7,
'00070025': PhysicalKeyboardKey.digit8,
'00070026': PhysicalKeyboardKey.digit9,
// Letter keys
'00070004': PhysicalKeyboardKey.keyA,
'00070005': PhysicalKeyboardKey.keyB,
'00070006': PhysicalKeyboardKey.keyC,
'00070007': PhysicalKeyboardKey.keyD,
'00070008': PhysicalKeyboardKey.keyE,
'00070009': PhysicalKeyboardKey.keyF,
'0007000a': PhysicalKeyboardKey.keyG,
'0007000b': PhysicalKeyboardKey.keyH,
'0007000c': PhysicalKeyboardKey.keyI,
'0007000d': PhysicalKeyboardKey.keyJ,
'0007000e': PhysicalKeyboardKey.keyK,
'0007000f': PhysicalKeyboardKey.keyL,
'00070010': PhysicalKeyboardKey.keyM,
'00070011': PhysicalKeyboardKey.keyN,
'00070012': PhysicalKeyboardKey.keyO,
'00070013': PhysicalKeyboardKey.keyP,
'00070014': PhysicalKeyboardKey.keyQ,
'00070015': PhysicalKeyboardKey.keyR,
'00070016': PhysicalKeyboardKey.keyS,
'00070017': PhysicalKeyboardKey.keyT,
'00070018': PhysicalKeyboardKey.keyU,
'00070019': PhysicalKeyboardKey.keyV,
'0007001a': PhysicalKeyboardKey.keyW,
'0007001b': PhysicalKeyboardKey.keyX,
'0007001c': PhysicalKeyboardKey.keyY,
'0007001d': PhysicalKeyboardKey.keyZ,
};
// Map for pattern-based key name matching (lowercase keys for case-insensitive matching)
static const _keyNameMap = <String, PhysicalKeyboardKey>{
'space': PhysicalKeyboardKey.space,
'backspace': PhysicalKeyboardKey.backspace,
'delete': PhysicalKeyboardKey.delete,
'enter': PhysicalKeyboardKey.enter,
'escape': PhysicalKeyboardKey.escape,
'tab': PhysicalKeyboardKey.tab,
'capslock': PhysicalKeyboardKey.capsLock,
'arrowleft': PhysicalKeyboardKey.arrowLeft,
'arrowup': PhysicalKeyboardKey.arrowUp,
'arrowright': PhysicalKeyboardKey.arrowRight,
'arrowdown': PhysicalKeyboardKey.arrowDown,
'home': PhysicalKeyboardKey.home,
'end': PhysicalKeyboardKey.end,
'pageup': PhysicalKeyboardKey.pageUp,
'pagedown': PhysicalKeyboardKey.pageDown,
'equal': PhysicalKeyboardKey.equal,
'minus': PhysicalKeyboardKey.minus,
};
// Function keys map
static const _functionKeyMap = <String, PhysicalKeyboardKey>{
'f1': PhysicalKeyboardKey.f1,
'f2': PhysicalKeyboardKey.f2,
'f3': PhysicalKeyboardKey.f3,
'f4': PhysicalKeyboardKey.f4,
'f5': PhysicalKeyboardKey.f5,
'f6': PhysicalKeyboardKey.f6,
'f7': PhysicalKeyboardKey.f7,
'f8': PhysicalKeyboardKey.f8,
'f9': PhysicalKeyboardKey.f9,
'f10': PhysicalKeyboardKey.f10,
'f11': PhysicalKeyboardKey.f11,
'f12': PhysicalKeyboardKey.f12,
};
// Digit keys map
static const _digitKeyMap = <String, PhysicalKeyboardKey>{
'digit0': PhysicalKeyboardKey.digit0,
'digit1': PhysicalKeyboardKey.digit1,
'digit2': PhysicalKeyboardKey.digit2,
'digit3': PhysicalKeyboardKey.digit3,
'digit4': PhysicalKeyboardKey.digit4,
'digit5': PhysicalKeyboardKey.digit5,
'digit6': PhysicalKeyboardKey.digit6,
'digit7': PhysicalKeyboardKey.digit7,
'digit8': PhysicalKeyboardKey.digit8,
'digit9': PhysicalKeyboardKey.digit9,
};
// Letter keys map
static const _letterKeyMap = <String, PhysicalKeyboardKey>{
'keya': PhysicalKeyboardKey.keyA,
'keyb': PhysicalKeyboardKey.keyB,
'keyc': PhysicalKeyboardKey.keyC,
'keyd': PhysicalKeyboardKey.keyD,
'keye': PhysicalKeyboardKey.keyE,
'keyf': PhysicalKeyboardKey.keyF,
'keyg': PhysicalKeyboardKey.keyG,
'keyh': PhysicalKeyboardKey.keyH,
'keyi': PhysicalKeyboardKey.keyI,
'keyj': PhysicalKeyboardKey.keyJ,
'keyk': PhysicalKeyboardKey.keyK,
'keyl': PhysicalKeyboardKey.keyL,
'keym': PhysicalKeyboardKey.keyM,
'keyn': PhysicalKeyboardKey.keyN,
'keyo': PhysicalKeyboardKey.keyO,
'keyp': PhysicalKeyboardKey.keyP,
'keyq': PhysicalKeyboardKey.keyQ,
'keyr': PhysicalKeyboardKey.keyR,
'keys': PhysicalKeyboardKey.keyS,
'keyt': PhysicalKeyboardKey.keyT,
'keyu': PhysicalKeyboardKey.keyU,
'keyv': PhysicalKeyboardKey.keyV,
'keyw': PhysicalKeyboardKey.keyW,
'keyx': PhysicalKeyboardKey.keyX,
'keyy': PhysicalKeyboardKey.keyY,
'keyz': PhysicalKeyboardKey.keyZ,
};
// Helper method to find PhysicalKeyboardKey by string representation
PhysicalKeyboardKey? _findKeyByString(String keyString) {
// Handle exact string matches first for better performance
const keyMap = {
'PhysicalKeyboardKey#0002c': PhysicalKeyboardKey.space,
'PhysicalKeyboardKey#7002a': PhysicalKeyboardKey.backspace,
'PhysicalKeyboardKey#7004c': PhysicalKeyboardKey.delete,
'PhysicalKeyboardKey#70028': PhysicalKeyboardKey.enter,
'PhysicalKeyboardKey#70029': PhysicalKeyboardKey.escape,
'PhysicalKeyboardKey#7002b': PhysicalKeyboardKey.tab,
'PhysicalKeyboardKey#7004a': PhysicalKeyboardKey.home,
'PhysicalKeyboardKey#7004d': PhysicalKeyboardKey.end,
'PhysicalKeyboardKey#7004b': PhysicalKeyboardKey.pageUp,
'PhysicalKeyboardKey#7004e': PhysicalKeyboardKey.pageDown,
'PhysicalKeyboardKey#70050': PhysicalKeyboardKey.arrowLeft,
'PhysicalKeyboardKey#70052': PhysicalKeyboardKey.arrowUp,
'PhysicalKeyboardKey#7004f': PhysicalKeyboardKey.arrowRight,
'PhysicalKeyboardKey#70051': PhysicalKeyboardKey.arrowDown,
};
final normalized = keyString.toLowerCase();
// Check exact matches first
if (keyMap.containsKey(keyString)) {
return keyMap[keyString];
}
// Alternative approach: extract USB HID usage code from the toString() output
// Try extracting USB HID code from toString() output
// Format: PhysicalKeyboardKey#ec9ed(usbHidUsage: "0x0007002c", debugName: "Space")
try {
final usbHidMatch = RegExp(
r'usbHidUsage: "0x([0-9a-fA-F]+)"',
).firstMatch(keyString);
if (usbHidMatch != null) {
final usbHidCode = usbHidMatch.group(1)!.toLowerCase();
// Map USB HID codes to PhysicalKeyboardKey objects
const usbHidMap = {
'0007002c': PhysicalKeyboardKey.space,
'0007002a': PhysicalKeyboardKey.backspace,
'0007004c': PhysicalKeyboardKey.delete,
'00070028': PhysicalKeyboardKey.enter,
'00070029': PhysicalKeyboardKey.escape,
'0007002b': PhysicalKeyboardKey.tab,
'00070039': PhysicalKeyboardKey.capsLock,
// Function keys
'0007003a': PhysicalKeyboardKey.f1,
'0007003b': PhysicalKeyboardKey.f2,
'0007003c': PhysicalKeyboardKey.f3,
'0007003d': PhysicalKeyboardKey.f4,
'0007003e': PhysicalKeyboardKey.f5,
'0007003f': PhysicalKeyboardKey.f6,
'00070040': PhysicalKeyboardKey.f7,
'00070041': PhysicalKeyboardKey.f8,
'00070042': PhysicalKeyboardKey.f9,
'00070043': PhysicalKeyboardKey.f10,
'00070044': PhysicalKeyboardKey.f11,
'00070045': PhysicalKeyboardKey.f12,
// Number keys
'00070027': PhysicalKeyboardKey.digit0,
'0007001e': PhysicalKeyboardKey.digit1,
'0007001f': PhysicalKeyboardKey.digit2,
'00070020': PhysicalKeyboardKey.digit3,
'00070021': PhysicalKeyboardKey.digit4,
'00070022': PhysicalKeyboardKey.digit5,
'00070023': PhysicalKeyboardKey.digit6,
'00070024': PhysicalKeyboardKey.digit7,
'00070025': PhysicalKeyboardKey.digit8,
'00070026': PhysicalKeyboardKey.digit9,
// Letter keys
'00070004': PhysicalKeyboardKey.keyA,
'00070005': PhysicalKeyboardKey.keyB,
'00070006': PhysicalKeyboardKey.keyC,
'00070007': PhysicalKeyboardKey.keyD,
'00070008': PhysicalKeyboardKey.keyE,
'00070009': PhysicalKeyboardKey.keyF,
'0007000a': PhysicalKeyboardKey.keyG,
'0007000b': PhysicalKeyboardKey.keyH,
'0007000c': PhysicalKeyboardKey.keyI,
'0007000d': PhysicalKeyboardKey.keyJ,
'0007000e': PhysicalKeyboardKey.keyK,
'0007000f': PhysicalKeyboardKey.keyL,
'00070010': PhysicalKeyboardKey.keyM,
'00070011': PhysicalKeyboardKey.keyN,
'00070012': PhysicalKeyboardKey.keyO,
'00070013': PhysicalKeyboardKey.keyP,
'00070014': PhysicalKeyboardKey.keyQ,
'00070015': PhysicalKeyboardKey.keyR,
'00070016': PhysicalKeyboardKey.keyS,
'00070017': PhysicalKeyboardKey.keyT,
'00070018': PhysicalKeyboardKey.keyU,
'00070019': PhysicalKeyboardKey.keyV,
'0007001a': PhysicalKeyboardKey.keyW,
'0007001b': PhysicalKeyboardKey.keyX,
'0007001c': PhysicalKeyboardKey.keyY,
'0007001d': PhysicalKeyboardKey.keyZ,
// Arrow keys
'00070050': PhysicalKeyboardKey.arrowLeft,
'00070052': PhysicalKeyboardKey.arrowUp,
'0007004f': PhysicalKeyboardKey.arrowRight,
'00070051': PhysicalKeyboardKey.arrowDown,
// Other common keys
'0007002d': PhysicalKeyboardKey.equal,
'0007002e': PhysicalKeyboardKey.minus,
'0007004a': PhysicalKeyboardKey.home,
'0007004d': PhysicalKeyboardKey.end,
'0007004b': PhysicalKeyboardKey.pageUp,
'0007004e': PhysicalKeyboardKey.pageDown,
};
if (usbHidMap.containsKey(usbHidCode)) {
return usbHidMap[usbHidCode];
}
}
} catch (e) {
// Ignore parsing errors
final usbHidMatch = RegExp(r'usbhidusage: "0x([0-9a-f]+)"').firstMatch(normalized);
if (usbHidMatch != null) {
final usbHidCode = usbHidMatch.group(1)!;
final key = _usbHidKeyMap[usbHidCode];
if (key != null) return key;
}
// Fall back to contains() checks for partial matches
if (keyString.contains('space')) {
return PhysicalKeyboardKey.space;
} else if (keyString.contains('arrowUp')) {
return PhysicalKeyboardKey.arrowUp;
} else if (keyString.contains('arrowDown')) {
return PhysicalKeyboardKey.arrowDown;
} else if (keyString.contains('arrowLeft')) {
return PhysicalKeyboardKey.arrowLeft;
} else if (keyString.contains('arrowRight')) {
return PhysicalKeyboardKey.arrowRight;
} else if (keyString.contains('equal')) {
return PhysicalKeyboardKey.equal;
} else if (keyString.contains('minus')) {
return PhysicalKeyboardKey.minus;
} else if (keyString.contains('escape')) {
return PhysicalKeyboardKey.escape;
} else if (keyString.contains('enter')) {
return PhysicalKeyboardKey.enter;
} else if (keyString.contains('tab')) {
return PhysicalKeyboardKey.tab;
} else if (keyString.contains('backspace')) {
return PhysicalKeyboardKey.backspace;
} else if (keyString.contains('delete')) {
return PhysicalKeyboardKey.delete;
} else if (keyString.contains('home')) {
return PhysicalKeyboardKey.home;
} else if (keyString.contains('end')) {
return PhysicalKeyboardKey.end;
} else if (keyString.contains('pageUp')) {
return PhysicalKeyboardKey.pageUp;
} else if (keyString.contains('pageDown')) {
return PhysicalKeyboardKey.pageDown;
} else {
// Try function keys F1-F12
for (int i = 1; i <= 12; i++) {
if (keyString.contains('f$i') || keyString.contains('F$i')) {
switch (i) {
case 1:
return PhysicalKeyboardKey.f1;
case 2:
return PhysicalKeyboardKey.f2;
case 3:
return PhysicalKeyboardKey.f3;
case 4:
return PhysicalKeyboardKey.f4;
case 5:
return PhysicalKeyboardKey.f5;
case 6:
return PhysicalKeyboardKey.f6;
case 7:
return PhysicalKeyboardKey.f7;
case 8:
return PhysicalKeyboardKey.f8;
case 9:
return PhysicalKeyboardKey.f9;
case 10:
return PhysicalKeyboardKey.f10;
case 11:
return PhysicalKeyboardKey.f11;
case 12:
return PhysicalKeyboardKey.f12;
}
}
// Try direct name matches
for (final entry in _keyNameMap.entries) {
if (normalized.contains(entry.key)) {
return entry.value;
}
// Try number keys 0-9
for (int i = 0; i <= 9; i++) {
if (keyString.contains('digit$i') || keyString.contains('Digit$i')) {
switch (i) {
case 0:
return PhysicalKeyboardKey.digit0;
case 1:
return PhysicalKeyboardKey.digit1;
case 2:
return PhysicalKeyboardKey.digit2;
case 3:
return PhysicalKeyboardKey.digit3;
case 4:
return PhysicalKeyboardKey.digit4;
case 5:
return PhysicalKeyboardKey.digit5;
case 6:
return PhysicalKeyboardKey.digit6;
case 7:
return PhysicalKeyboardKey.digit7;
case 8:
return PhysicalKeyboardKey.digit8;
case 9:
return PhysicalKeyboardKey.digit9;
}
}
}
// Try letter keys A-Z (both upper and lower case patterns)
const letterKeys = {
'A': PhysicalKeyboardKey.keyA,
'B': PhysicalKeyboardKey.keyB,
'C': PhysicalKeyboardKey.keyC,
'D': PhysicalKeyboardKey.keyD,
'E': PhysicalKeyboardKey.keyE,
'F': PhysicalKeyboardKey.keyF,
'G': PhysicalKeyboardKey.keyG,
'H': PhysicalKeyboardKey.keyH,
'I': PhysicalKeyboardKey.keyI,
'J': PhysicalKeyboardKey.keyJ,
'K': PhysicalKeyboardKey.keyK,
'L': PhysicalKeyboardKey.keyL,
'M': PhysicalKeyboardKey.keyM,
'N': PhysicalKeyboardKey.keyN,
'O': PhysicalKeyboardKey.keyO,
'P': PhysicalKeyboardKey.keyP,
'Q': PhysicalKeyboardKey.keyQ,
'R': PhysicalKeyboardKey.keyR,
'S': PhysicalKeyboardKey.keyS,
'T': PhysicalKeyboardKey.keyT,
'U': PhysicalKeyboardKey.keyU,
'V': PhysicalKeyboardKey.keyV,
'W': PhysicalKeyboardKey.keyW,
'X': PhysicalKeyboardKey.keyX,
'Y': PhysicalKeyboardKey.keyY,
'Z': PhysicalKeyboardKey.keyZ,
};
for (final entry in letterKeys.entries) {
if (keyString.contains('key${entry.key}') ||
keyString.contains('Key${entry.key}')) {
return entry.value;
}
}
return null;
}
// Try function keys (check longer patterns first to avoid f1 matching f10)
for (final entry in _functionKeyMap.entries) {
if (normalized.contains(entry.key)) {
return entry.value;
}
}
// Try digit keys
for (final entry in _digitKeyMap.entries) {
if (normalized.contains(entry.key)) {
return entry.value;
}
}
// Try letter keys
for (final entry in _letterKeyMap.entries) {
if (normalized.contains(entry.key)) {
return entry.value;
}
}
return null;
}
// Media Version Preferences
+73 -50
View File
@@ -22,6 +22,33 @@ class StorageService extends BaseSharedPreferencesService {
static const String _keyEnabledServers = 'enabled_servers';
static const String _keyServerOrder = 'server_order';
// Key prefixes for per-id storage
static const String _prefixServerEndpoint = 'server_endpoint_';
static const String _prefixLibraryFilters = 'library_filters_';
static const String _prefixLibrarySort = 'library_sort_';
static const String _prefixLibraryGrouping = 'library_grouping_';
static const String _prefixLibraryTab = 'library_tab_';
// Key groups for bulk clearing
static const List<String> _credentialKeys = [
_keyServerUrl,
_keyToken,
_keyPlexToken,
_keyServerData,
_keyClientId,
_keyUserProfile,
_keyCurrentUserUUID,
_keyHomeUsersCache,
_keyHomeUsersCacheExpiry,
];
static const List<String> _libraryPreferenceKeys = [
_keySelectedLibraryIndex,
_keyLibraryFilters,
_keyLibraryOrder,
_keyHiddenLibraries,
];
StorageService._();
static Future<StorageService> getInstance() async {
@@ -50,16 +77,16 @@ class StorageService extends BaseSharedPreferencesService {
// Per-Server Endpoint URL (for multi-server connection caching)
Future<void> saveServerEndpoint(String serverId, String url) async {
await prefs.setString('server_endpoint_$serverId', url);
await prefs.setString('$_prefixServerEndpoint$serverId', url);
LogRedactionManager.registerServerUrl(url);
}
String? getServerEndpoint(String serverId) {
return prefs.getString('server_endpoint_$serverId');
return prefs.getString('$_prefixServerEndpoint$serverId');
}
Future<void> clearServerEndpoint(String serverId) async {
await prefs.remove('server_endpoint_$serverId');
await prefs.remove('$_prefixServerEndpoint$serverId');
}
// Server Access Token
@@ -93,8 +120,7 @@ class StorageService extends BaseSharedPreferencesService {
// Server Data (full PlexServer object as JSON)
Future<void> saveServerData(Map<String, dynamic> serverJson) async {
final jsonString = json.encode(serverJson);
await prefs.setString(_keyServerData, jsonString);
await _setJsonMap(_keyServerData, serverJson);
}
Map<String, dynamic>? getServerData() {
@@ -131,15 +157,7 @@ class StorageService extends BaseSharedPreferencesService {
// Clear all credentials
Future<void> clearCredentials() async {
await Future.wait([
prefs.remove(_keyServerUrl),
prefs.remove(_keyToken),
prefs.remove(_keyPlexToken),
prefs.remove(_keyServerData),
prefs.remove(_keyClientId),
prefs.remove(_keyUserProfile),
prefs.remove(_keyCurrentUserUUID),
prefs.remove(_keyHomeUsersCache),
prefs.remove(_keyHomeUsersCacheExpiry),
..._credentialKeys.map((k) => prefs.remove(k)),
clearMultiServerData(),
]);
LogRedactionManager.clearTrackedValues();
@@ -172,16 +190,17 @@ class StorageService extends BaseSharedPreferencesService {
Map<String, String> filters, {
String? sectionId,
}) async {
final jsonString = json.encode(filters);
final key = sectionId != null
? 'library_filters_$sectionId'
? '$_prefixLibraryFilters$sectionId'
: _keyLibraryFilters;
// Note: using Map<String, String> which json.encode handles correctly
final jsonString = json.encode(filters);
await prefs.setString(key, jsonString);
}
Map<String, String> getLibraryFilters({String? sectionId}) {
final scopedKey = sectionId != null
? 'library_filters_$sectionId'
? '$_prefixLibraryFilters$sectionId'
: _keyLibraryFilters;
// Prefer per-library filters when available
@@ -202,36 +221,34 @@ class StorageService extends BaseSharedPreferencesService {
bool descending = false,
}) async {
final sortData = {'key': sortKey, 'descending': descending};
await prefs.setString('library_sort_$sectionId', json.encode(sortData));
await _setJsonMap('$_prefixLibrarySort$sectionId', sortData);
}
Map<String, dynamic>? getLibrarySort(String sectionId) {
return _readJsonMap('library_sort_$sectionId', legacyStringOk: true);
return _readJsonMap('$_prefixLibrarySort$sectionId', legacyStringOk: true);
}
// Library Grouping (per-library, e.g., 'movies', 'shows', 'seasons', 'episodes')
Future<void> saveLibraryGrouping(String sectionId, String grouping) async {
await prefs.setString('library_grouping_$sectionId', grouping);
await prefs.setString('$_prefixLibraryGrouping$sectionId', grouping);
}
String? getLibraryGrouping(String sectionId) {
return prefs.getString('library_grouping_$sectionId');
return prefs.getString('$_prefixLibraryGrouping$sectionId');
}
// Library Tab (per-library, saves last selected tab index)
Future<void> saveLibraryTab(String sectionId, int tabIndex) async {
await prefs.setInt('library_tab_$sectionId', tabIndex);
await prefs.setInt('$_prefixLibraryTab$sectionId', tabIndex);
}
int? getLibraryTab(String sectionId) {
return prefs.getInt('library_tab_$sectionId');
return prefs.getInt('$_prefixLibraryTab$sectionId');
}
// Hidden Libraries (stored as JSON array of library section IDs)
Future<void> saveHiddenLibraries(Set<String> libraryKeys) async {
final list = libraryKeys.toList();
final jsonString = json.encode(list);
await prefs.setString(_keyHiddenLibraries, jsonString);
await _setStringList(_keyHiddenLibraries, libraryKeys.toList());
}
Set<String> getHiddenLibraries() {
@@ -249,30 +266,24 @@ class StorageService extends BaseSharedPreferencesService {
// Clear library preferences
Future<void> clearLibraryPreferences() async {
await Future.wait([
prefs.remove(_keySelectedLibraryIndex),
prefs.remove(_keyLibraryFilters),
prefs.remove(_keyLibraryOrder),
prefs.remove(_keyHiddenLibraries),
..._libraryPreferenceKeys.map((k) => prefs.remove(k)),
_clearKeysWithPrefix(_prefixLibrarySort),
_clearKeysWithPrefix(_prefixLibraryFilters),
_clearKeysWithPrefix(_prefixLibraryGrouping),
_clearKeysWithPrefix(_prefixLibraryTab),
]);
// Also clear all library sort preferences
final keys = prefs.getKeys();
final sortKeys = keys.where((key) => key.startsWith('library_sort_'));
await Future.wait(sortKeys.map((key) => prefs.remove(key)));
}
// Library Order (stored as JSON list of library keys)
Future<void> saveLibraryOrder(List<String> libraryKeys) async {
final jsonString = json.encode(libraryKeys);
await prefs.setString(_keyLibraryOrder, jsonString);
await _setStringList(_keyLibraryOrder, libraryKeys);
}
List<String>? getLibraryOrder() => _getStringList(_keyLibraryOrder);
// User Profile (stored as JSON string)
Future<void> saveUserProfile(Map<String, dynamic> profileJson) async {
final jsonString = json.encode(profileJson);
await prefs.setString(_keyUserProfile, jsonString);
await _setJsonMap(_keyUserProfile, profileJson);
}
Map<String, dynamic>? getUserProfile() {
@@ -290,8 +301,7 @@ class StorageService extends BaseSharedPreferencesService {
// Home Users Cache (stored as JSON string with expiry)
Future<void> saveHomeUsersCache(Map<String, dynamic> homeData) async {
final jsonString = json.encode(homeData);
await prefs.setString(_keyHomeUsersCache, jsonString);
await _setJsonMap(_keyHomeUsersCache, homeData);
// Set cache expiry to 1 hour from now
final expiry = DateTime.now()
@@ -370,24 +380,17 @@ class StorageService extends BaseSharedPreferencesService {
/// Clear all multi-server data
Future<void> clearMultiServerData() async {
// Clear all server endpoint caches
final keys = prefs.getKeys();
final endpointKeys = keys.where(
(key) => key.startsWith('server_endpoint_'),
);
await Future.wait([
clearServersList(),
clearEnabledServers(),
clearServerOrder(),
...endpointKeys.map((key) => prefs.remove(key)),
_clearKeysWithPrefix(_prefixServerEndpoint),
]);
}
/// Server Order (stored as JSON list of server IDs)
Future<void> saveServerOrder(List<String> serverIds) async {
final jsonString = json.encode(serverIds);
await prefs.setString(_keyServerOrder, jsonString);
await _setStringList(_keyServerOrder, serverIds);
}
List<String>? getServerOrder() => _getStringList(_keyServerOrder);
@@ -446,4 +449,24 @@ class StorageService extends BaseSharedPreferencesService {
return {};
}
}
/// Remove all keys matching a prefix
Future<void> _clearKeysWithPrefix(String prefix) async {
final keys = prefs.getKeys().where((k) => k.startsWith(prefix));
await Future.wait(keys.map((k) => prefs.remove(k)));
}
// Public JSON helpers for reducing boilerplate
/// Save a JSON-encodable map to storage
Future<void> _setJsonMap(String key, Map<String, dynamic> data) async {
final jsonString = json.encode(data);
await prefs.setString(key, jsonString);
}
/// Save a string list as JSON array
Future<void> _setStringList(String key, List<String> list) async {
final jsonString = json.encode(list);
await prefs.setString(key, jsonString);
}
}
+66 -38
View File
@@ -18,6 +18,62 @@ class TrackSelectionService {
required this.metadata,
});
/// Build list of preferred languages from a user profile
List<String> _buildPreferredLanguages(PlexUserProfile profile, {required bool isAudio}) {
final primary = isAudio
? profile.defaultAudioLanguage
: profile.defaultSubtitleLanguage;
final list = isAudio
? profile.defaultAudioLanguages
: profile.defaultSubtitleLanguages;
final result = <String>[];
if (primary != null && primary.isNotEmpty) {
result.add(primary);
}
if (list != null) {
result.addAll(list);
}
return result;
}
/// Find a track by preferred language with variation lookup and logging
T? _findTrackByPreferredLanguage<T>(
List<T> tracks,
String preferredLanguage,
String? Function(T) getLanguage,
String Function(T) getDescription,
String trackType,
) {
final languageVariations = LanguageCodes.getVariations(preferredLanguage);
appLogger.d(
'Checking language variations for "$preferredLanguage": ${languageVariations.join(", ")}',
);
return _findTrackByLanguageVariations<T>(
tracks,
preferredLanguage,
languageVariations,
getLanguage,
getDescription,
trackType,
);
}
/// Apply a filter to tracks, falling back to original if filter produces empty result
List<T> _applyFilterWithFallback<T>(
List<T> tracks,
List<T> Function(List<T>) filter,
String filterDescription,
) {
final filtered = filter(tracks);
if (filtered.isNotEmpty) {
return filtered;
}
appLogger.d('No tracks match $filterDescription, using all tracks');
return tracks;
}
/// Generic track matching for audio and subtitle tracks
/// Returns the best matching track based on hierarchical criteria:
/// 1. Exact match (id + title + language)
@@ -98,15 +154,7 @@ class TrackSelectionService {
return null;
}
// Build list of preferred languages
final preferredLanguages = <String>[];
if (profile.defaultAudioLanguage != null &&
profile.defaultAudioLanguage!.isNotEmpty) {
preferredLanguages.add(profile.defaultAudioLanguage!);
}
if (profile.defaultAudioLanguages != null) {
preferredLanguages.addAll(profile.defaultAudioLanguages!);
}
final preferredLanguages = _buildPreferredLanguages(profile, isAudio: true);
if (preferredLanguages.isEmpty) {
appLogger.d('Cannot use profile: No defaultAudioLanguage(s) specified');
@@ -117,16 +165,9 @@ class TrackSelectionService {
// Try to find track matching any preferred language
for (final preferredLanguage in preferredLanguages) {
// Get all possible language code variations (e.g., "en" → ["en", "eng"])
final languageVariations = LanguageCodes.getVariations(preferredLanguage);
appLogger.d(
'Checking language variations for "$preferredLanguage": ${languageVariations.join(", ")}',
);
final match = _findTrackByLanguageVariations<AudioTrack>(
final match = _findTrackByPreferredLanguage<AudioTrack>(
availableTracks,
preferredLanguage,
languageVariations,
(t) => t.language,
(t) => t.title ?? 'Track ${t.id}',
'audio track',
@@ -211,15 +252,7 @@ class TrackSelectionService {
// Mode 2: Always enabled (or continuing from mode 1 with foreign audio)
appLogger.d('Selecting subtitle track based on preferences');
// Build list of preferred languages
final preferredLanguages = <String>[];
if (profile.defaultSubtitleLanguage != null &&
profile.defaultSubtitleLanguage!.isNotEmpty) {
preferredLanguages.add(profile.defaultSubtitleLanguage!);
}
if (profile.defaultSubtitleLanguages != null) {
preferredLanguages.addAll(profile.defaultSubtitleLanguages!);
}
final preferredLanguages = _buildPreferredLanguages(profile, isAudio: false);
if (preferredLanguages.isEmpty) {
appLogger.d(
@@ -230,7 +263,7 @@ class TrackSelectionService {
appLogger.d('Preferred languages: ${preferredLanguages.join(", ")}');
// Apply filtering based on preferences
// Apply filtering with fallback to original tracks if filter produces empty result
var candidateTracks = availableTracks;
// Filter by SDH (defaultSubtitleAccessibility: 0-3)
@@ -246,22 +279,17 @@ class TrackSelectionService {
);
// If no candidates after filtering, relax filters
if (candidateTracks.isEmpty) {
appLogger.d('No tracks match strict filters, relaxing filters');
candidateTracks = availableTracks;
}
candidateTracks = _applyFilterWithFallback(
availableTracks,
(_) => candidateTracks,
'strict filters',
);
// Try to find track matching any preferred language
for (final preferredLanguage in preferredLanguages) {
final languageVariations = LanguageCodes.getVariations(preferredLanguage);
appLogger.d(
'Checking language variations for "$preferredLanguage": ${languageVariations.join(", ")}',
);
final match = _findTrackByLanguageVariations<SubtitleTrack>(
final match = _findTrackByPreferredLanguage<SubtitleTrack>(
candidateTracks,
preferredLanguage,
languageVariations,
(t) => t.language,
(t) => t.title ?? 'Track ${t.id}',
'subtitle',
+33 -21
View File
@@ -63,15 +63,20 @@ class UpdateService {
await prefs.setString(_keyLastCheckTime, DateTime.now().toIso8601String());
}
/// Check for updates on GitHub (manual check, ignores cooldown)
/// Returns a map with update info, or null if no update or error
static Future<Map<String, dynamic>?> checkForUpdates({
bool silent = false,
/// Internal method that performs the actual update check
/// [respectCooldown] - if true, checks cooldown and updates last check time
static Future<Map<String, dynamic>?> _performUpdateCheck({
required bool respectCooldown,
}) async {
if (!isUpdateCheckEnabled) {
return null;
}
// Check cooldown if requested
if (respectCooldown && !await shouldCheckForUpdates()) {
return null;
}
try {
final packageInfo = await PackageInfo.fromPlatform();
final currentVersion = packageInfo.version;
@@ -94,12 +99,21 @@ class UpdateService {
final hasUpdate = _isNewerVersion(cleanVersion, currentVersion);
if (hasUpdate) {
// Check if this version was skipped (always check, regardless of silent mode)
// Check if this version was skipped
final skippedVersion = await getSkippedVersion();
if (skippedVersion == cleanVersion) {
// Update last check time even when skipped (if respecting cooldown)
if (respectCooldown) {
await _updateLastCheckTime();
}
return null;
}
// Update last check time on success (if respecting cooldown)
if (respectCooldown) {
await _updateLastCheckTime();
}
return {
'hasUpdate': true,
'currentVersion': currentVersion,
@@ -111,6 +125,11 @@ class UpdateService {
};
}
}
// Update last check time even when no update (if respecting cooldown)
if (respectCooldown) {
await _updateLastCheckTime();
}
} catch (e) {
_logger.e('Failed to check for updates: $e');
}
@@ -118,25 +137,18 @@ class UpdateService {
return null;
}
/// Check for updates on GitHub (manual check, ignores cooldown)
/// Returns a map with update info, or null if no update or error
static Future<Map<String, dynamic>?> checkForUpdates({
bool silent = false,
}) async {
return _performUpdateCheck(respectCooldown: false);
}
/// Check for updates on startup (respects cooldown and skipped versions)
/// Returns update info if available, null otherwise
static Future<Map<String, dynamic>?> checkForUpdatesOnStartup() async {
if (!isUpdateCheckEnabled) {
return null;
}
// Check cooldown
if (!await shouldCheckForUpdates()) {
return null;
}
// Perform the check
final updateInfo = await checkForUpdates(silent: true);
// Update last check time
await _updateLastCheckTime();
return updateInfo;
return _performUpdateCheck(respectCooldown: true);
}
/// Parse version string into list of integers
@@ -1,32 +0,0 @@
import 'package:flutter/material.dart';
import '../services/plex_client.dart';
import '../services/play_queue_launcher.dart';
import '../models/plex_metadata.dart';
import '../models/plex_playlist.dart';
/// Helper function to play a collection or playlist.
///
/// This is a convenience wrapper around [PlayQueueLauncher.launchFromCollectionOrPlaylist].
Future<void> playCollectionOrPlaylist({
required BuildContext context,
required PlexClient client,
required dynamic item, // PlexMetadata (collection) or PlexPlaylist
required bool shuffle,
}) async {
final launcher = PlayQueueLauncher(
context: context,
client: client,
serverId: item is PlexMetadata
? item.serverId
: (item as PlexPlaylist).serverId,
serverName: item is PlexMetadata
? item.serverName
: (item as PlexPlaylist).serverName,
);
await launcher.launchFromCollectionOrPlaylist(
item: item,
shuffle: shuffle,
showLoadingIndicator: false, // Caller typically handles loading UI
);
}
-51
View File
@@ -1,51 +0,0 @@
import 'package:flutter/material.dart';
import '../services/settings_service.dart';
/// Calculates the max cross-axis extent for grid items, accounting for outer padding.
double getMaxCrossAxisExtentWithPadding(
BuildContext context,
LibraryDensity density,
double horizontalPadding,
) {
final screenWidth = MediaQuery.of(context).size.width;
final availableWidth = screenWidth - horizontalPadding;
if (screenWidth >= 900) {
// Wide screens (desktop/large tablet landscape): Responsive division
double divisor;
double maxItemWidth;
switch (density) {
case LibraryDensity.comfortable:
divisor = 6.5;
maxItemWidth = 280;
break;
case LibraryDensity.normal:
divisor = 8.0;
maxItemWidth = 200;
break;
case LibraryDensity.compact:
divisor = 10.0;
maxItemWidth = 160;
break;
}
return (availableWidth / divisor).clamp(0, maxItemWidth);
} else if (screenWidth >= 600) {
// Medium screens (tablets): Fixed 4-5-6 items
int targetItemCount = switch (density) {
LibraryDensity.comfortable => 4,
LibraryDensity.normal => 5,
LibraryDensity.compact => 6,
};
return availableWidth / targetItemCount;
} else {
// Small screens (phones): Fixed 2-3-4 items
int targetItemCount = switch (density) {
LibraryDensity.comfortable => 2,
LibraryDensity.normal => 3,
LibraryDensity.compact => 4,
};
return availableWidth / targetItemCount;
}
}
+51
View File
@@ -36,6 +36,57 @@ class GridSizeCalculator {
}
}
/// Calculates the max cross-axis extent accounting for outer padding.
///
/// Uses responsive strategies:
/// - Wide screens (>=900px): Divisor-based calculation with max item width
/// - Medium screens (600-899px): Fixed item count (4-6 items based on density)
/// - Small screens (<600px): Fixed item count (2-4 items based on density)
static double getMaxCrossAxisExtentWithPadding(
BuildContext context,
LibraryDensity density,
double horizontalPadding,
) {
final screenWidth = MediaQuery.of(context).size.width;
final availableWidth = screenWidth - horizontalPadding;
if (ScreenBreakpoints.isWideTabletOrLarger(screenWidth)) {
// Wide screens (desktop/large tablet landscape): Responsive division
double divisor;
double maxItemWidth;
switch (density) {
case LibraryDensity.comfortable:
divisor = 6.5;
maxItemWidth = 280;
case LibraryDensity.normal:
divisor = 8.0;
maxItemWidth = 200;
case LibraryDensity.compact:
divisor = 10.0;
maxItemWidth = 160;
}
return (availableWidth / divisor).clamp(0, maxItemWidth);
} else if (ScreenBreakpoints.isTablet(screenWidth)) {
// Medium screens (tablets): Fixed 4-5-6 items
int targetItemCount = switch (density) {
LibraryDensity.comfortable => 4,
LibraryDensity.normal => 5,
LibraryDensity.compact => 6,
};
return availableWidth / targetItemCount;
} else {
// Small screens (phones): Fixed 2-3-4 items
int targetItemCount = switch (density) {
LibraryDensity.comfortable => 2,
LibraryDensity.normal => 3,
LibraryDensity.compact => 4,
};
return availableWidth / targetItemCount;
}
}
/// Returns whether the current screen is a desktop-sized screen
static bool isDesktop(BuildContext context) {
return MediaQuery.of(context).size.width > desktopBreakpoint;
+37 -3
View File
@@ -1,14 +1,45 @@
import 'package:flutter/widgets.dart';
/// Layout and sizing constants used throughout the application
/// Screen width breakpoints for responsive design
class ScreenBreakpoints {
/// Breakpoint for tablet devices (600px)
static const double tablet = 600;
/// Breakpoint for mobile devices (< 600px)
static const double mobile = 600;
/// Breakpoint for wide tablets / small desktops (900px)
/// Used for intermediate responsive layouts
static const double wideTablet = 900;
/// Breakpoint for desktop devices (1200px)
static const double desktop = 1200;
/// Breakpoint for large desktop devices (1600px)
static const double largeDesktop = 1600;
// Legacy alias for backward compatibility
static const double tablet = mobile;
/// Whether width is mobile-sized (< 600px)
static bool isMobile(double width) => width < mobile;
/// Whether width is tablet-sized (600px - 1199px)
static bool isTablet(double width) => width >= mobile && width < desktop;
/// Whether width is wide tablet (900px - 1199px)
/// Useful for layouts that need more columns than phone but less than desktop
static bool isWideTablet(double width) => width >= wideTablet && width < desktop;
/// Whether width is desktop-sized (1200px - 1599px)
static bool isDesktop(double width) => width >= desktop && width < largeDesktop;
/// Whether width is large desktop-sized (>= 1600px)
static bool isLargeDesktop(double width) => width >= largeDesktop;
/// Whether width is desktop or larger (>= 1200px)
static bool isDesktopOrLarger(double width) => width >= desktop;
/// Whether width is wide tablet or larger (>= 900px)
static bool isWideTabletOrLarger(double width) => width >= wideTablet;
}
/// Grid layout constants
@@ -31,7 +62,10 @@ class GridLayoutConstants {
/// Default aspect ratio for media cards (poster)
static const double posterAspectRatio = 2 / 3.3;
/// Grid spacing
/// Grid spacing (edge-to-edge cards)
static const double crossAxisSpacing = 0;
static const double mainAxisSpacing = 0;
/// Standard grid padding
static EdgeInsets get gridPadding => const EdgeInsets.fromLTRB(8, 0, 8, 8);
}
+34 -18
View File
@@ -1,7 +1,12 @@
import 'dart:async';
/// Notifier for triggering refreshes of library tabs
/// Singleton pattern for global access
/// Types of library refresh events
enum LibraryRefreshType { collections, playlists }
/// Notifier for triggering refreshes of library tabs.
///
/// Singleton pattern with reinitializable state. The controller is lazily
/// created and automatically recreated if disposed and later accessed.
class LibraryRefreshNotifier {
static final LibraryRefreshNotifier _instance =
LibraryRefreshNotifier._internal();
@@ -10,30 +15,41 @@ class LibraryRefreshNotifier {
LibraryRefreshNotifier._internal();
// Stream controllers for different tab types
final _collectionsController = StreamController<void>.broadcast();
final _playlistsController = StreamController<void>.broadcast();
/// Unified stream controller (lazily created, reinitializable)
StreamController<LibraryRefreshType>? _controller;
// Streams that tabs can listen to
Stream<void> get collectionsStream => _collectionsController.stream;
Stream<void> get playlistsStream => _playlistsController.stream;
/// Ensure controller exists (creates if null or closed)
StreamController<LibraryRefreshType> get _ensureController {
if (_controller == null || _controller!.isClosed) {
_controller = StreamController<LibraryRefreshType>.broadcast();
}
return _controller!;
}
// Methods to trigger refreshes
/// Unified stream of all refresh events
Stream<LibraryRefreshType> get stream => _ensureController.stream;
/// Stream for collections tab (backward compatible)
Stream<void> get collectionsStream =>
stream.where((t) => t == LibraryRefreshType.collections).map((_) {});
/// Stream for playlists tab (backward compatible)
Stream<void> get playlistsStream =>
stream.where((t) => t == LibraryRefreshType.playlists).map((_) {});
/// Notify that collections have changed
void notifyCollectionsChanged() {
if (!_collectionsController.isClosed) {
_collectionsController.add(null);
}
_ensureController.add(LibraryRefreshType.collections);
}
/// Notify that playlists have changed
void notifyPlaylistsChanged() {
if (!_playlistsController.isClosed) {
_playlistsController.add(null);
}
_ensureController.add(LibraryRefreshType.playlists);
}
// Cleanup
/// Dispose controller (can be reinitialized later by accessing stream)
void dispose() {
_collectionsController.close();
_playlistsController.close();
_controller?.close();
_controller = null;
}
}
+67 -23
View File
@@ -1,24 +1,38 @@
import 'dart:collection';
class LogRedactionManager {
static final Set<String> _tokens = <String>{};
static final Set<String> _urls = <String>{};
static final Set<String> _customValues = <String>{};
// Size limits for bounded sets (FIFO eviction when exceeded)
static const int _maxTokens = 50;
static const int _maxUrls = 20;
static const int _maxCustomValues = 50;
// Use LinkedHashSet for FIFO ordering
static final Set<String> _tokens = LinkedHashSet<String>();
static final Set<String> _urls = LinkedHashSet<String>();
static final Set<String> _customValues = LinkedHashSet<String>();
static final RegExp _ipv4Pattern = RegExp(
r'\b(\d{1,3})([.-])(\d{1,3})\2(\d{1,3})\2(\d{1,3})\b',
);
static final RegExp _ipv4HostPattern = RegExp(r'^\d{1,3}([.-]\d{1,3}){3}$');
// Combined regex for single-pass redaction (rebuilt on set changes)
static RegExp? _combinedPattern;
/// Register a server access token or Plex.tv token for redaction.
static void registerToken(String? token) {
final normalized = _normalize(token);
if (normalized == null) return;
_tokens.add(normalized);
_addWithLimit(_tokens, normalized, _maxTokens);
// Tokens often appear URL encoded in query params.
final encoded = Uri.encodeQueryComponent(normalized);
if (encoded != normalized) {
_tokens.add(encoded);
_addWithLimit(_tokens, encoded, _maxTokens);
}
_rebuildCombinedPattern();
}
/// Register the server/base URL currently in use.
@@ -42,26 +56,29 @@ class LogRedactionManager {
: normalized;
if (strippedSlash.isNotEmpty) {
_urls.add(strippedSlash);
_urls.add('$strippedSlash/'); // Include trailing slash variant.
_addWithLimit(_urls, strippedSlash, _maxUrls);
_addWithLimit(_urls, '$strippedSlash/', _maxUrls);
}
// Capture origin and host-level strings as well to cover most cases.
if (uri != null && uri.host.isNotEmpty) {
final origin =
'${uri.scheme.isEmpty ? 'https' : uri.scheme}://${uri.host}${uri.hasPort ? ':${uri.port}' : ''}';
_urls.add(origin);
_addWithLimit(_urls, origin, _maxUrls);
if (origin.endsWith('/')) {
_urls.add(origin.substring(0, origin.length - 1));
_addWithLimit(_urls, origin.substring(0, origin.length - 1), _maxUrls);
}
}
_rebuildCombinedPattern();
}
/// Register other sensitive values that need redaction.
static void registerCustomValue(String? value) {
final normalized = _normalize(value);
if (normalized == null) return;
_customValues.add(normalized);
_addWithLimit(_customValues, normalized, _maxCustomValues);
_rebuildCombinedPattern();
}
/// Reset any tracked sensitive values (e.g., on logout).
@@ -69,32 +86,59 @@ class LogRedactionManager {
_tokens.clear();
_urls.clear();
_customValues.clear();
_combinedPattern = null;
}
/// Redact known sensitive values from the provided message.
static String redact(String message) {
var redacted = message;
redacted = redacted.replaceAllMapped(
// Pass 1: IPv4 addresses (regex pattern)
var redacted = message.replaceAllMapped(
_ipv4Pattern,
(match) => _maskIpv4(match.group(1)!, match.group(2)!, match.group(5)!),
);
for (final url in _urls) {
redacted = redacted.replaceAll(url, _maskUrlPreview(url));
}
for (final token in _tokens) {
redacted = redacted.replaceAll(token, '[REDACTED_TOKEN]');
}
for (final custom in _customValues) {
redacted = redacted.replaceAll(custom, '[REDACTED]');
// Pass 2: All tracked values in single pass
if (_combinedPattern != null) {
redacted = redacted.replaceAllMapped(_combinedPattern!, (match) {
final value = match.group(0)!;
if (_tokens.contains(value)) return '[REDACTED_TOKEN]';
if (_urls.contains(value)) return _maskUrlPreview(value);
return '[REDACTED]';
});
}
return redacted;
}
/// Rebuild the combined regex pattern from all tracked values.
static void _rebuildCombinedPattern() {
final allLiterals = [
..._tokens.map(RegExp.escape),
..._urls.map(RegExp.escape),
..._customValues.map(RegExp.escape),
];
if (allLiterals.isEmpty) {
_combinedPattern = null;
return;
}
// Sort by length descending so longer matches are preferred
allLiterals.sort((a, b) => b.length.compareTo(a.length));
_combinedPattern = RegExp(allLiterals.join('|'));
}
/// Add value to set with FIFO eviction if limit exceeded.
static void _addWithLimit(Set<String> set, String value, int maxSize) {
if (set.contains(value)) return; // Already tracked
// Evict oldest entries if at capacity
while (set.length >= maxSize) {
set.remove(set.first);
}
set.add(value);
}
static String? _normalize(String? value) {
if (value == null) return null;
final trimmed = value.trim();
+81 -26
View File
@@ -1,24 +1,45 @@
import 'package:flutter/material.dart';
import '../models/plex_metadata.dart';
import '../models/plex_playlist.dart';
import '../screens/collection_detail_screen.dart';
import '../screens/media_detail_screen.dart';
import '../screens/season_detail_screen.dart';
import '../screens/playlist/playlist_detail_screen.dart';
import 'video_player_navigation.dart';
/// Result of media navigation indicating what action was taken
enum MediaNavigationResult {
/// Navigation completed successfully
navigated,
/// Navigation completed, parent list should be refreshed (e.g., collection deleted)
listRefreshNeeded,
/// Item type not supported (e.g., music content)
unsupported,
}
/// Navigates to the appropriate screen based on the item type.
///
/// For episodes, starts playback directly via video player.
/// For seasons, navigates to season detail screen.
/// For playlists, navigates to playlist detail screen.
/// For collections, navigates to collection detail screen.
/// For other types (shows, movies), navigates to media detail screen.
/// For music types (artist, album, track), returns [MediaNavigationResult.unsupported].
///
/// The [onRefresh] callback is invoked with the item's ratingKey after
/// returning from the detail screen, allowing the caller to refresh state.
Future<void> navigateToMediaItem(
///
/// Set [isOffline] to true for downloaded content without server access.
///
/// Returns a [MediaNavigationResult] indicating what action was taken:
/// - [MediaNavigationResult.navigated]: Navigation completed, item refresh handled
/// - [MediaNavigationResult.listRefreshNeeded]: Caller should refresh entire list
/// - [MediaNavigationResult.unsupported]: Item type not supported, caller should handle
Future<MediaNavigationResult> navigateToMediaItem(
BuildContext context,
dynamic item, {
void Function(String)? onRefresh,
bool isOffline = false,
}) async {
// Handle playlists
if (item is PlexPlaylist) {
@@ -28,33 +49,67 @@ Future<void> navigateToMediaItem(
builder: (context) => PlaylistDetailScreen(playlist: item),
),
);
return;
return MediaNavigationResult.navigated;
}
final itemType = (item as PlexMetadata).type.toLowerCase();
final metadata = item as PlexMetadata;
// For episodes, start playback directly
if (itemType == 'episode') {
final result = await navigateToVideoPlayer(context, metadata: item);
if (result == true) {
onRefresh?.call(item.ratingKey);
}
} else if (itemType == 'season') {
await Navigator.push(
context,
MaterialPageRoute(builder: (context) => SeasonDetailScreen(season: item)),
);
onRefresh?.call(item.ratingKey);
} else {
// For all other types (shows, movies), show detail screen
final result = await Navigator.push<bool>(
context,
MaterialPageRoute(
builder: (context) => MediaDetailScreen(metadata: item),
),
);
if (result == true) {
onRefresh?.call(item.ratingKey);
}
switch (metadata.mediaType) {
case PlexMediaType.collection:
final result = await Navigator.push<bool>(
context,
MaterialPageRoute(
builder: (context) => CollectionDetailScreen(collection: metadata),
),
);
// If collection was deleted, signal that list refresh is needed
if (result == true) {
return MediaNavigationResult.listRefreshNeeded;
}
return MediaNavigationResult.navigated;
case PlexMediaType.artist:
case PlexMediaType.album:
case PlexMediaType.track:
// Music types not supported
return MediaNavigationResult.unsupported;
case PlexMediaType.episode:
// For episodes, start playback directly
final result = await navigateToVideoPlayer(
context,
metadata: metadata,
isOffline: isOffline,
);
if (result == true) {
onRefresh?.call(metadata.ratingKey);
}
return MediaNavigationResult.navigated;
case PlexMediaType.season:
await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => SeasonDetailScreen(season: metadata),
),
);
onRefresh?.call(metadata.ratingKey);
return MediaNavigationResult.navigated;
default:
// For all other types (shows, movies), show detail screen
final result = await Navigator.push<bool>(
context,
MaterialPageRoute(
builder: (context) => MediaDetailScreen(
metadata: metadata,
isOffline: isOffline,
),
),
);
if (result == true) {
onRefresh?.call(metadata.ratingKey);
}
return MediaNavigationResult.navigated;
}
}
+18 -9
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import '../services/settings_service.dart';
import 'grid_size_calculator.dart';
import 'layout_constants.dart';
/// Builds an adaptive Sliver widget that switches between grid and list
/// based on the current view mode setting.
@@ -13,14 +14,22 @@ Widget buildAdaptiveMediaSliverBuilder<T>({
required Widget Function(BuildContext context, T item, int index) itemBuilder,
required ViewMode viewMode,
required LibraryDensity density,
EdgeInsets padding = const EdgeInsets.all(16),
double childAspectRatio = 2 / 3.3,
double crossAxisSpacing = 8,
double mainAxisSpacing = 8,
EdgeInsets? padding,
double? childAspectRatio,
double? crossAxisSpacing,
double? mainAxisSpacing,
}) {
final effectivePadding = padding ?? GridLayoutConstants.gridPadding;
final effectiveAspectRatio =
childAspectRatio ?? GridLayoutConstants.posterAspectRatio;
final effectiveCrossAxisSpacing =
crossAxisSpacing ?? GridLayoutConstants.crossAxisSpacing;
final effectiveMainAxisSpacing =
mainAxisSpacing ?? GridLayoutConstants.mainAxisSpacing;
if (viewMode == ViewMode.list) {
return SliverPadding(
padding: padding,
padding: effectivePadding,
sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
final item = items[index];
@@ -30,16 +39,16 @@ Widget buildAdaptiveMediaSliverBuilder<T>({
);
} else {
return SliverPadding(
padding: padding,
padding: effectivePadding,
sliver: SliverGrid(
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: GridSizeCalculator.getMaxCrossAxisExtent(
context,
density,
),
childAspectRatio: childAspectRatio,
crossAxisSpacing: crossAxisSpacing,
mainAxisSpacing: mainAxisSpacing,
childAspectRatio: effectiveAspectRatio,
crossAxisSpacing: effectiveCrossAxisSpacing,
mainAxisSpacing: effectiveMainAxisSpacing,
),
delegate: SliverChildBuilderDelegate((context, index) {
final item = items[index];
+3 -13
View File
@@ -24,23 +24,13 @@ class SmartDeletionHandler {
});
try {
// Start deletion
await provider.deleteDownload(globalKey);
} finally {
deletionComplete = true;
// Close dialog if shown
if (dialogShown && context.mounted) {
// Close dialog if shown (with canPop guard to prevent double-pop)
if (dialogShown && context.mounted && Navigator.canPop(context)) {
Navigator.of(context).pop();
}
} catch (e) {
deletionComplete = true;
// Close dialog if shown
if (dialogShown && context.mounted) {
Navigator.of(context).pop();
}
rethrow; // Let caller handle error
}
}
-1
View File
@@ -36,7 +36,6 @@ class FocusedScrollScaffold extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Focus(
autofocus: true,
onKeyEvent: (_, event) => handleBackKeyNavigation(context, event),
child: Scaffold(
body: CustomScrollView(
+4 -3
View File
@@ -3,6 +3,7 @@ import 'package:plezy/widgets/app_icon.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:flutter/services.dart';
import '../focus/dpad_navigator.dart';
import '../utils/layout_constants.dart';
import '../focus/locked_hub_controller.dart';
import '../models/plex_hub.dart';
import '../screens/hub_detail_screen.dart';
@@ -309,11 +310,11 @@ class HubSectionState extends State<HubSection> {
builder: (context, constraints) {
// Responsive card width based on screen size
final screenWidth = constraints.maxWidth;
final cardWidth = screenWidth > 1600
final cardWidth = ScreenBreakpoints.isLargeDesktop(screenWidth)
? 220.0
: screenWidth > 1200
: ScreenBreakpoints.isDesktop(screenWidth)
? 200.0
: screenWidth > 800
: ScreenBreakpoints.isWideTablet(screenWidth)
? 190.0
: 160.0;
+37 -99
View File
@@ -11,13 +11,9 @@ import '../services/download_storage_service.dart';
import '../providers/settings_provider.dart';
import '../services/settings_service.dart';
import '../utils/provider_extensions.dart';
import '../utils/video_player_navigation.dart';
import '../utils/content_rating_formatter.dart';
import '../utils/duration_formatter.dart';
import '../screens/media_detail_screen.dart';
import '../screens/season_detail_screen.dart';
import '../screens/playlist/playlist_detail_screen.dart';
import '../screens/collection_detail_screen.dart';
import '../utils/media_navigation_helper.dart';
import '../theme/theme_helper.dart';
import '../i18n/strings.g.dart';
import 'media_context_menu.dart';
@@ -74,30 +70,29 @@ class MediaCardState extends State<MediaCard> {
String _buildSemanticLabel() {
final item = widget.item;
final itemType = item.type.toLowerCase();
// Build base label based on type
String baseLabel;
if (itemType == 'episode') {
final episodeInfo = item.parentIndex != null && item.index != null
? 'S${item.parentIndex} E${item.index}'
: '';
baseLabel = t.accessibility.mediaCardEpisode(
title: item.displayTitle,
episodeInfo: episodeInfo,
);
} else if (itemType == 'season') {
final seasonInfo = item.parentIndex != null
? 'Season ${item.parentIndex}'
: '';
baseLabel = t.accessibility.mediaCardSeason(
title: item.displayTitle,
seasonInfo: seasonInfo,
);
} else if (itemType == 'movie') {
baseLabel = t.accessibility.mediaCardMovie(title: item.displayTitle);
} else {
baseLabel = t.accessibility.mediaCardShow(title: item.displayTitle);
switch (item.mediaType) {
case PlexMediaType.episode:
final episodeInfo = item.parentIndex != null && item.index != null
? 'S${item.parentIndex} E${item.index}'
: '';
baseLabel = t.accessibility.mediaCardEpisode(
title: item.displayTitle,
episodeInfo: episodeInfo,
);
case PlexMediaType.season:
final seasonInfo =
item.parentIndex != null ? 'Season ${item.parentIndex}' : '';
baseLabel = t.accessibility.mediaCardSeason(
title: item.displayTitle,
seasonInfo: seasonInfo,
);
case PlexMediaType.movie:
baseLabel = t.accessibility.mediaCardMovie(title: item.displayTitle);
default:
baseLabel = t.accessibility.mediaCardShow(title: item.displayTitle);
}
// Add watched status
@@ -122,85 +117,28 @@ class MediaCardState extends State<MediaCard> {
return;
}
// Handle playlists
if (widget.item is PlexPlaylist) {
await Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
PlaylistDetailScreen(playlist: widget.item as PlexPlaylist),
),
);
return;
}
final result = await navigateToMediaItem(
context,
widget.item,
onRefresh: widget.onRefresh,
isOffline: widget.isOffline,
);
final itemType = widget.item.type.toLowerCase();
if (!mounted) return;
// Handle collections
if (itemType == 'collection') {
final result = await Navigator.push<bool>(
context,
MaterialPageRoute(
builder: (context) => CollectionDetailScreen(collection: widget.item),
),
);
// If collection was deleted, refresh the parent list
if (result == true && mounted) {
widget.onListRefresh?.call();
}
return;
}
// Music content is not yet supported
if (itemType == 'artist' || itemType == 'album' || itemType == 'track') {
if (context.mounted) {
switch (result) {
case MediaNavigationResult.unsupported:
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(t.messages.musicNotSupported),
duration: const Duration(seconds: 2),
),
);
}
return;
}
// For episodes, start playback directly
if (itemType == 'episode') {
final result = await navigateToVideoPlayer(
context,
metadata: widget.item,
isOffline: widget.isOffline,
);
// Refresh parent screen if result indicates it's needed
if (result == true) {
widget.onRefresh?.call(widget.item.ratingKey);
}
} else if (itemType == 'season') {
// For seasons, show season detail screen
await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => SeasonDetailScreen(season: widget.item),
),
);
// Season screen doesn't return a refresh flag, but we can refresh anyway
widget.onRefresh?.call(widget.item.ratingKey);
} else {
// For all other types (shows, movies), show detail screen
final result = await Navigator.push<bool>(
context,
MaterialPageRoute(
builder: (context) => MediaDetailScreen(
metadata: widget.item,
isOffline: widget.isOffline,
),
),
);
// Refresh parent screen if result indicates it's needed
if (result == true) {
widget.onRefresh?.call(widget.item.ratingKey);
}
case MediaNavigationResult.listRefreshNeeded:
widget.onListRefresh?.call();
case MediaNavigationResult.navigated:
// Item refresh already handled by onRefresh callback in helper
break;
}
}
@@ -479,7 +417,7 @@ class _MediaCardList extends StatelessWidget {
final metadata = item as PlexMetadata;
// For collections, show item count
if (metadata.type.toLowerCase() == 'collection') {
if (metadata.mediaType == PlexMediaType.collection) {
final count = metadata.childCount ?? metadata.leafCount;
if (count != null && count > 0) {
parts.add(t.playlists.itemCount(count: count));
@@ -765,7 +703,7 @@ class _MediaCardHelpers {
PlexMetadata metadata,
) {
// For collections, show item count
if (metadata.type.toLowerCase() == 'collection') {
if (metadata.mediaType == PlexMediaType.collection) {
final count = metadata.childCount ?? metadata.leafCount;
if (count != null && count > 0) {
return Text(
+41 -27
View File
@@ -13,7 +13,6 @@ import '../providers/offline_mode_provider.dart';
import '../providers/offline_watch_provider.dart';
import '../utils/provider_extensions.dart';
import '../utils/app_logger.dart';
import '../utils/collection_playlist_play_helper.dart';
import '../utils/library_refresh_notifier.dart';
import '../screens/media_detail_screen.dart';
import '../screens/season_detail_screen.dart';
@@ -132,8 +131,8 @@ class MediaContextMenuState extends State<MediaContextMenu> {
final isPlaylist = widget.item is PlexPlaylist;
final metadata = isPlaylist ? null : widget.item as PlexMetadata;
final itemType = isPlaylist ? 'playlist' : (metadata!.type.toLowerCase());
final isCollection = itemType == 'collection';
final mediaType = isPlaylist ? null : metadata!.mediaType;
final isCollection = mediaType == PlexMediaType.collection;
final isPartiallyWatched =
!isPlaylist &&
@@ -226,7 +225,8 @@ class MediaContextMenuState extends State<MediaContextMenu> {
}
// Go to Series (for episodes and seasons)
if ((itemType == 'episode' || itemType == 'season') &&
if ((mediaType == PlexMediaType.episode ||
mediaType == PlexMediaType.season) &&
metadata.grandparentTitle != null) {
menuActions.add(
_MenuAction(
@@ -238,7 +238,7 @@ class MediaContextMenuState extends State<MediaContextMenu> {
}
// Go to Season (for episodes)
if (itemType == 'episode' && metadata.parentTitle != null) {
if (mediaType == PlexMediaType.episode && metadata.parentTitle != null) {
menuActions.add(
_MenuAction(
value: 'season',
@@ -249,7 +249,8 @@ class MediaContextMenuState extends State<MediaContextMenu> {
}
// Shuffle Play (for shows and seasons)
if (itemType == 'show' || itemType == 'season') {
if (mediaType == PlexMediaType.show ||
mediaType == PlexMediaType.season) {
menuActions.add(
_MenuAction(
value: 'shuffle_play',
@@ -260,7 +261,8 @@ class MediaContextMenuState extends State<MediaContextMenu> {
}
// File Info (for episodes and movies)
if (itemType == 'episode' || itemType == 'movie') {
if (mediaType == PlexMediaType.episode ||
mediaType == PlexMediaType.movie) {
menuActions.add(
_MenuAction(
value: 'fileinfo',
@@ -271,10 +273,10 @@ class MediaContextMenuState extends State<MediaContextMenu> {
}
// Download options (for episodes, movies, shows, and seasons)
if (itemType == 'episode' ||
itemType == 'movie' ||
itemType == 'show' ||
itemType == 'season') {
if (mediaType == PlexMediaType.episode ||
mediaType == PlexMediaType.movie ||
mediaType == PlexMediaType.show ||
mediaType == PlexMediaType.season) {
final downloadProvider = Provider.of<DownloadProvider>(
context,
listen: false,
@@ -304,10 +306,10 @@ class MediaContextMenuState extends State<MediaContextMenu> {
}
// Add to... (for episodes, movies, shows, and seasons)
if (itemType == 'episode' ||
itemType == 'movie' ||
itemType == 'show' ||
itemType == 'season') {
if (mediaType == PlexMediaType.episode ||
mediaType == PlexMediaType.movie ||
mediaType == PlexMediaType.show ||
mediaType == PlexMediaType.season) {
menuActions.add(
_MenuAction(
value: 'add_to',
@@ -1118,14 +1120,7 @@ class MediaContextMenuState extends State<MediaContextMenu> {
bool isCollection,
bool isPlaylist,
) async {
final client = _getClientForItem();
await playCollectionOrPlaylist(
context: context,
client: client,
item: widget.item,
shuffle: false,
);
await _launchCollectionOrPlaylist(context, shuffle: false);
}
/// Handle shuffle action for collections and playlists
@@ -1134,13 +1129,32 @@ class MediaContextMenuState extends State<MediaContextMenu> {
bool isCollection,
bool isPlaylist,
) async {
final client = _getClientForItem();
await _launchCollectionOrPlaylist(context, shuffle: true);
}
await playCollectionOrPlaylist(
/// Launch playback for collection or playlist
Future<void> _launchCollectionOrPlaylist(
BuildContext context, {
required bool shuffle,
}) async {
final client = _getClientForItem();
final item = widget.item;
final launcher = PlayQueueLauncher(
context: context,
client: client,
item: widget.item,
shuffle: true,
serverId: item is PlexMetadata
? item.serverId
: (item as PlexPlaylist).serverId,
serverName: item is PlexMetadata
? item.serverName
: (item as PlexPlaylist).serverName,
);
await launcher.launchFromCollectionOrPlaylist(
item: item,
shuffle: shuffle,
showLoadingIndicator: false,
);
}
+10 -15
View File
@@ -1,24 +1,15 @@
import 'package:flutter/material.dart';
import '../services/settings_service.dart';
import '../utils/grid_size_calculator.dart';
import '../utils/grid_cross_axis_extent.dart';
import '../utils/layout_constants.dart';
/// Shared grid delegate configuration for media item grids
/// Maintains consistent spacing (2/3.3 aspect ratio, 0 spacing) across all media grids
class MediaGridDelegate {
/// Standard aspect ratio for media cards (poster aspect)
static const double aspectRatio = 2 / 3.3;
/// Standard cross-axis spacing between grid items
static const double crossAxisSpacing = 0;
/// Standard main-axis spacing between grid items
static const double mainAxisSpacing = 0;
/// Creates a standard grid delegate for media items
///
/// Uses [GridSizeCalculator.getMaxCrossAxisExtent] by default.
/// Set [usePaddingAware] to true to use [getMaxCrossAxisExtentWithPadding] instead.
/// Set [usePaddingAware] to true to use [GridSizeCalculator.getMaxCrossAxisExtentWithPadding] instead.
static SliverGridDelegateWithMaxCrossAxisExtent createDelegate({
required BuildContext context,
required LibraryDensity density,
@@ -26,14 +17,18 @@ class MediaGridDelegate {
double horizontalPadding = 16,
}) {
final maxCrossAxisExtent = usePaddingAware
? getMaxCrossAxisExtentWithPadding(context, density, horizontalPadding)
? GridSizeCalculator.getMaxCrossAxisExtentWithPadding(
context,
density,
horizontalPadding,
)
: GridSizeCalculator.getMaxCrossAxisExtent(context, density);
return SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: maxCrossAxisExtent,
childAspectRatio: aspectRatio,
crossAxisSpacing: crossAxisSpacing,
mainAxisSpacing: mainAxisSpacing,
childAspectRatio: GridLayoutConstants.posterAspectRatio,
crossAxisSpacing: GridLayoutConstants.crossAxisSpacing,
mainAxisSpacing: GridLayoutConstants.mainAxisSpacing,
);
}
}
+154 -156
View File
@@ -8,6 +8,7 @@ import 'package:provider/provider.dart';
import '../focus/dpad_navigator.dart';
import '../models/plex_library.dart';
import '../navigation/navigation_tabs.dart';
import '../providers/hidden_libraries_provider.dart';
import '../providers/multi_server_provider.dart';
import '../services/fullscreen_state_manager.dart';
@@ -15,6 +16,58 @@ import '../services/storage_service.dart';
import '../theme/theme_helper.dart';
import '../i18n/strings.g.dart';
/// Tracks focus state for a set of named items, avoiding repeated boilerplate
class _FocusStateTracker {
final Map<String, FocusNode> _nodes = {};
final Set<String> _focused = {};
final VoidCallback _onChanged;
_FocusStateTracker(this._onChanged);
/// Get or create a focus node for the given key
FocusNode get(String key, {String? debugLabel}) {
return _nodes.putIfAbsent(key, () {
final node = FocusNode(debugLabel: debugLabel ?? 'nav_$key');
node.addListener(() {
final wasFocused = _focused.contains(key);
if (node.hasFocus && !wasFocused) {
_focused.add(key);
_onChanged();
} else if (!node.hasFocus && wasFocused) {
_focused.remove(key);
_onChanged();
}
});
return node;
});
}
/// Check if a key is currently focused
bool isFocused(String key) => _focused.contains(key);
/// Check if a node exists for the given key
FocusNode? nodeFor(String key) => _nodes[key];
/// Dispose all nodes
void dispose() {
for (final node in _nodes.values) {
node.dispose();
}
_nodes.clear();
_focused.clear();
}
/// Remove nodes not in the given set of valid keys (prunes stale nodes)
void pruneExcept(Set<String> validKeys) {
final toRemove = _nodes.keys.where((k) => !validKeys.contains(k)).toList();
for (final key in toRemove) {
_nodes[key]?.dispose();
_nodes.remove(key);
_focused.remove(key);
}
}
}
/// Reusable navigation rail item widget that handles focus, selection, and interaction
class NavigationRailItem extends StatelessWidget {
final IconData icon;
@@ -123,169 +176,103 @@ class SideNavigationRailState extends State<SideNavigationRail> {
List<PlexLibrary> _libraries = [];
bool _isLoadingLibraries = true;
// Focus nodes for main nav items
late FocusNode _homeFocusNode;
late FocusNode _librariesFocusNode;
late FocusNode _searchFocusNode;
late FocusNode _downloadsFocusNode;
late FocusNode _settingsFocusNode;
// Focus keys for main nav items
static const _kHome = 'home';
static const _kLibraries = 'libraries';
static const _kSearch = 'search';
static const _kDownloads = 'downloads';
static const _kSettings = 'settings';
// Focus state tracking
bool _isHomeFocused = false;
bool _isLibrariesFocused = false;
bool _isSearchFocused = false;
bool _isDownloadsFocused = false;
bool _isSettingsFocused = false;
// Map to store library item focus nodes and states
final Map<String, FocusNode> _libraryFocusNodes = {};
final Set<String> _focusedLibraryKeys = {};
// Unified focus state tracker for all nav items (main + libraries)
late final _FocusStateTracker _focusTracker;
@override
void initState() {
super.initState();
_homeFocusNode = FocusNode(debugLabel: 'nav_home');
_librariesFocusNode = FocusNode(debugLabel: 'nav_libraries');
_searchFocusNode = FocusNode(debugLabel: 'nav_search');
_downloadsFocusNode = FocusNode(debugLabel: 'nav_downloads');
_settingsFocusNode = FocusNode(debugLabel: 'nav_settings');
_homeFocusNode.addListener(
() => _onFocusChange(_homeFocusNode, () {
setState(() => _isHomeFocused = _homeFocusNode.hasFocus);
}),
);
_librariesFocusNode.addListener(
() => _onFocusChange(_librariesFocusNode, () {
setState(() => _isLibrariesFocused = _librariesFocusNode.hasFocus);
}),
);
_searchFocusNode.addListener(
() => _onFocusChange(_searchFocusNode, () {
setState(() => _isSearchFocused = _searchFocusNode.hasFocus);
}),
);
_downloadsFocusNode.addListener(
() => _onFocusChange(_downloadsFocusNode, () {
setState(() => _isDownloadsFocused = _downloadsFocusNode.hasFocus);
}),
);
_settingsFocusNode.addListener(
() => _onFocusChange(_settingsFocusNode, () {
setState(() => _isSettingsFocused = _settingsFocusNode.hasFocus);
}),
);
_focusTracker = _FocusStateTracker(() {
if (mounted) setState(() {});
});
_loadLibraries();
}
void _onFocusChange(FocusNode node, VoidCallback updateState) {
if (mounted) updateState();
}
@override
void dispose() {
_homeFocusNode.dispose();
_librariesFocusNode.dispose();
_searchFocusNode.dispose();
_downloadsFocusNode.dispose();
_settingsFocusNode.dispose();
for (final node in _libraryFocusNodes.values) {
node.dispose();
}
_focusTracker.dispose();
super.dispose();
}
/// Get or create a focus node for a library item
FocusNode _getLibraryFocusNode(String globalKey) {
return _libraryFocusNodes.putIfAbsent(globalKey, () {
final node = FocusNode(debugLabel: 'nav_library_$globalKey');
node.addListener(() {
if (mounted) {
setState(() {
if (node.hasFocus) {
_focusedLibraryKeys.add(globalKey);
} else {
_focusedLibraryKeys.remove(globalKey);
}
});
}
});
return node;
});
}
/// Focus the currently selected nav item
void focusActiveItem() {
if (widget.selectedLibraryKey != null) {
// A library is selected - focus that library item
final node = _libraryFocusNodes[widget.selectedLibraryKey];
node?.requestFocus();
_focusTracker.nodeFor(widget.selectedLibraryKey!)?.requestFocus();
} else {
// Focus main nav item based on selectedIndex
switch (widget.selectedIndex) {
case 0:
_homeFocusNode.requestFocus();
break;
case 1:
_librariesFocusNode.requestFocus();
break;
case 2:
_searchFocusNode.requestFocus();
break;
case 3:
_downloadsFocusNode.requestFocus();
break;
case 4:
_settingsFocusNode.requestFocus();
break;
}
final key = switch (widget.selectedIndex) {
0 => _kHome,
1 => _kLibraries,
2 => _kSearch,
3 => _kDownloads,
4 => _kSettings,
_ => null,
};
if (key != null) _focusTracker.nodeFor(key)?.requestFocus();
}
}
Future<void> _loadLibraries() async {
final multiServerProvider = context.read<MultiServerProvider>();
/// Fetch, filter, and order libraries (pure logic, no state changes)
Future<List<PlexLibrary>> _resolveLibraries(
MultiServerProvider provider,
StorageService storage,
) async {
if (!provider.hasConnectedServers) return [];
if (!multiServerProvider.hasConnectedServers) {
setState(() {
_isLoadingLibraries = false;
});
return;
final libraries =
await provider.aggregationService.getLibrariesFromAllServers();
// Filter out unsupported library types (music)
var filtered = libraries.where((lib) => lib.type != 'artist').toList();
// Apply saved order
final savedOrder = storage.getLibraryOrder();
if (savedOrder == null || savedOrder.isEmpty) return filtered;
final libraryMap = {for (var lib in filtered) lib.globalKey: lib};
final ordered = <PlexLibrary>[];
for (final key in savedOrder) {
final lib = libraryMap.remove(key);
if (lib != null) ordered.add(lib);
}
ordered.addAll(libraryMap.values); // New libraries not in saved order
return ordered;
}
/// Build the set of valid focus keys (main nav + current libraries)
Set<String> _buildValidFocusKeys(List<PlexLibrary> libraries) {
return {
_kHome,
_kLibraries,
_kSearch,
_kDownloads,
_kSettings,
...libraries.map((lib) => lib.globalKey),
};
}
Future<void> _loadLibraries() async {
final provider = context.read<MultiServerProvider>();
final storage = await StorageService.getInstance();
try {
final libraries = await multiServerProvider.aggregationService
.getLibrariesFromAllServers();
// Filter out music libraries (not supported)
var filteredLibraries = libraries
.where((lib) => lib.type != 'artist')
.toList();
// Apply saved library order
final storage = await StorageService.getInstance();
final savedOrder = storage.getLibraryOrder();
if (savedOrder != null && savedOrder.isNotEmpty) {
final libraryMap = {
for (var lib in filteredLibraries) lib.globalKey: lib,
};
final orderedLibraries = <PlexLibrary>[];
for (final key in savedOrder) {
if (libraryMap.containsKey(key)) {
orderedLibraries.add(libraryMap[key]!);
libraryMap.remove(key);
}
}
// Add any new libraries not in saved order
orderedLibraries.addAll(libraryMap.values);
filteredLibraries = orderedLibraries;
}
final libraries = await _resolveLibraries(provider, storage);
if (mounted) {
setState(() {
_libraries = filteredLibraries;
_libraries = libraries;
_isLoadingLibraries = false;
});
// Prune stale library focus nodes
_focusTracker.pruneExcept(_buildValidFocusKeys(libraries));
}
} catch (e) {
if (mounted) {
@@ -371,9 +358,9 @@ class SideNavigationRailState extends State<SideNavigationRail> {
selectedIcon: Symbols.home_rounded,
label: Translations.of(context).navigation.home,
isSelected: widget.selectedIndex == 0,
isFocused: _isHomeFocused,
isFocused: _focusTracker.isFocused(_kHome),
onTap: () => widget.onDestinationSelected(0),
focusNode: _homeFocusNode,
focusNode: _focusTracker.get(_kHome),
),
const SizedBox(height: 8),
@@ -389,44 +376,54 @@ class SideNavigationRailState extends State<SideNavigationRail> {
selectedIcon: Symbols.search_rounded,
label: Translations.of(context).navigation.search,
isSelected: widget.selectedIndex == 2,
isFocused: _isSearchFocused,
isFocused: _focusTracker.isFocused(_kSearch),
onTap: () => widget.onDestinationSelected(2),
focusNode: _searchFocusNode,
focusNode: _focusTracker.get(_kSearch),
),
const SizedBox(height: 8),
],
// Downloads (index 0 in offline mode, 3 in online mode)
// Downloads
_buildNavItem(
icon: Symbols.download_rounded,
selectedIcon: Symbols.download_rounded,
label: Translations.of(context).navigation.downloads,
isSelected: widget.isOfflineMode
? widget.selectedIndex == 0
: widget.selectedIndex == 3,
isFocused: _isDownloadsFocused,
onTap: () => widget.onDestinationSelected(
widget.isOfflineMode ? 0 : 3,
isSelected: NavigationTab.isTabAtIndex(
NavigationTabId.downloads,
widget.selectedIndex,
isOffline: widget.isOfflineMode,
),
focusNode: _downloadsFocusNode,
isFocused: _focusTracker.isFocused(_kDownloads),
onTap: () => widget.onDestinationSelected(
NavigationTab.indexFor(
NavigationTabId.downloads,
isOffline: widget.isOfflineMode,
),
),
focusNode: _focusTracker.get(_kDownloads),
),
const SizedBox(height: 8),
// Settings (index 1 in offline mode, 4 in online mode)
// Settings
_buildNavItem(
icon: Symbols.settings_rounded,
selectedIcon: Symbols.settings_rounded,
label: Translations.of(context).navigation.settings,
isSelected: widget.isOfflineMode
? widget.selectedIndex == 1
: widget.selectedIndex == 4,
isFocused: _isSettingsFocused,
onTap: () => widget.onDestinationSelected(
widget.isOfflineMode ? 1 : 4,
isSelected: NavigationTab.isTabAtIndex(
NavigationTabId.settings,
widget.selectedIndex,
isOffline: widget.isOfflineMode,
),
focusNode: _settingsFocusNode,
isFocused: _focusTracker.isFocused(_kSettings),
onTap: () => widget.onDestinationSelected(
NavigationTab.indexFor(
NavigationTabId.settings,
isOffline: widget.isOfflineMode,
),
),
focusNode: _focusTracker.get(_kSettings),
),
],
),
@@ -472,13 +469,14 @@ class SideNavigationRailState extends State<SideNavigationRail> {
Widget _buildLibrariesSection(List<PlexLibrary> visibleLibraries, dynamic t) {
final isLibrariesSelected =
widget.selectedIndex == 1 && widget.selectedLibraryKey == null;
final isLibrariesFocused = _focusTracker.isFocused(_kLibraries);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Libraries header with expand/collapse
Focus(
focusNode: _librariesFocusNode,
focusNode: _focusTracker.get(_kLibraries),
onKeyEvent: (node, event) {
if (event is! KeyDownEvent) return KeyEventResult.ignored;
if (event.logicalKey.isSelectKey) {
@@ -506,7 +504,7 @@ class SideNavigationRailState extends State<SideNavigationRail> {
decoration: BoxDecoration(
color: isLibrariesSelected
? t.text.withValues(alpha: 0.1)
: _isLibrariesFocused
: isLibrariesFocused
? t.text.withValues(alpha: 0.08)
: null,
borderRadius: BorderRadius.circular(12),
@@ -615,8 +613,8 @@ class SideNavigationRailState extends State<SideNavigationRail> {
final isSelected =
widget.selectedIndex == 1 &&
widget.selectedLibraryKey == library.globalKey;
final isFocused = _focusedLibraryKeys.contains(library.globalKey);
final focusNode = _getLibraryFocusNode(library.globalKey);
final isFocused = _focusTracker.isFocused(library.globalKey);
final focusNode = _focusTracker.get(library.globalKey);
return NavigationRailItem(
icon: _getLibraryIcon(library.type),