refactor: navigation/offline handling
and other improvments
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
];
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user