fix: eliminate cross-app consistency drift

This commit is contained in:
edde746
2026-07-13 23:13:53 +02:00
parent ec249d2ed4
commit d5f3e581df
142 changed files with 4751 additions and 1075 deletions
+4 -6
View File
@@ -159,6 +159,10 @@ class _AlphaJumpBarState extends State<AlphaJumpBar> {
}
});
if (selectResult != KeyEventResult.ignored) return selectResult;
if (widget.onBack != null) {
final backResult = handleBackKeyAction(event, widget.onBack!);
if (backResult != KeyEventResult.ignored) return backResult;
}
if (event is! KeyDownEvent && event is! KeyRepeatEvent) {
return KeyEventResult.ignored;
@@ -182,12 +186,6 @@ class _AlphaJumpBarState extends State<AlphaJumpBar> {
widget.onNavigateLeft?.call();
return KeyEventResult.handled;
}
if (event.logicalKey == LogicalKeyboardKey.escape ||
event.logicalKey == LogicalKeyboardKey.goBack ||
event.logicalKey == LogicalKeyboardKey.gameButtonB) {
widget.onBack?.call();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
@@ -14,6 +14,8 @@ class SliverErrorState extends StatelessWidget {
final VoidCallback? onActionNavigateLeft;
final VoidCallback? onActionNavigateRight;
final VoidCallback? onActionBack;
final bool actionAutofocus;
final bool actionUseBackgroundFocus;
const SliverErrorState({
super.key,
@@ -26,6 +28,8 @@ class SliverErrorState extends StatelessWidget {
this.onActionNavigateLeft,
this.onActionNavigateRight,
this.onActionBack,
this.actionAutofocus = false,
this.actionUseBackgroundFocus = false,
});
@override
@@ -34,13 +38,15 @@ class SliverErrorState extends StatelessWidget {
message: message,
icon: Symbols.error_outline_rounded,
onRetry: onRetry,
retryLabel: retryLabel ?? t.common.retry,
retryLabel: retryLabel,
actionFocusNode: actionFocusNode,
onActionNavigateUp: onActionNavigateUp,
onActionNavigateDown: onActionNavigateDown,
onActionNavigateLeft: onActionNavigateLeft,
onActionNavigateRight: onActionNavigateRight,
onActionBack: onActionBack,
actionAutofocus: actionAutofocus,
actionUseBackgroundFocus: actionUseBackgroundFocus,
),
);
}
+8 -20
View File
@@ -1,4 +1,3 @@
import 'dart:io';
import 'dart:ui';
import '../../media/ids.dart';
@@ -19,7 +18,6 @@ import '../../utils/provider_extensions.dart';
import '../../widgets/app_menu.dart';
import '../../widgets/media_context_menu.dart';
import '../../widgets/optimized_media_image.dart';
import '../../widgets/overlay_sheet.dart';
import '../../widgets/watched_indicator.dart';
import '../../theme/mono_tokens.dart';
import '../../i18n/strings.g.dart';
@@ -42,7 +40,7 @@ class FolderTreeItem extends StatefulWidget {
final FocusNode? focusNode;
final VoidCallback? onNavigateUp;
final VoidCallback? onNavigateLeft;
final void Function(String itemId)? onRefresh;
final void Function(MediaItem source)? onRefresh;
final VoidCallback? onListRefresh;
final String? serverId;
@@ -150,23 +148,13 @@ class _FolderTreeItemState extends State<FolderTreeItem> with ContextMenuTapMixi
final previousFocus = FocusManager.instance.primaryFocus;
final position = lastTapPosition;
final fromKeyboard = position == null;
final useBottomSheet = Platform.isIOS || Platform.isAndroid;
String? selected;
if (useBottomSheet) {
selected = await OverlaySheetController.showAdaptive<String>(
context,
showDragHandle: true,
builder: (context) => AppMenuSheet<String>(title: _rowTitle(), entries: entries, focusFirstItem: fromKeyboard),
);
} else {
selected = await showAppMenu<String>(
context,
entries: entries,
position: position ?? _rowCenter(),
focusFirstItem: fromKeyboard,
);
}
final selected = await showAdaptiveAppMenu<String>(
context,
title: _rowTitle(),
entries: entries,
position: position ?? _rowCenter(),
focusFirstItem: fromKeyboard,
);
if (!mounted) return;
+9 -8
View File
@@ -22,7 +22,7 @@ class FolderTreeView extends StatefulWidget {
final String libraryKey;
final String? serverId; // Server this library belongs to
final MediaKind? libraryKind;
final void Function(String)? onRefresh;
final void Function(MediaItem source)? onRefresh;
final FocusNode? firstItemFocusNode;
final VoidCallback? onNavigateUp;
final VoidCallback? onNavigateLeft;
@@ -117,11 +117,12 @@ class FolderTreeViewState extends State<FolderTreeView> {
});
appLogger.d('Loaded ${folders.length} root folders');
} catch (e) {
} catch (e, stackTrace) {
if (!mounted || epoch != _loadEpoch) return;
final message = localizedLoadErrorMessage(e, stackTrace, context: t.libraries.folders);
setState(() {
_errorMessage = mapUnexpectedErrorToMessage(e, context: t.libraries.folders);
_errorMessage = message;
_isLoadingRoot = false;
});
}
@@ -181,10 +182,10 @@ class FolderTreeViewState extends State<FolderTreeView> {
});
appLogger.d('Loaded ${children.length} children for folder: ${folder.title}');
} catch (e) {
} catch (e, stackTrace) {
if (!mounted || epoch != _loadEpoch) return;
final message = mapUnexpectedErrorToMessage(e, context: t.libraries.folders);
final message = localizedLoadErrorMessage(e, stackTrace, context: t.libraries.folders);
setState(() {
_loadingFolders.remove(folderIdentity);
// Drop partial pages so a retry refetches instead of leaving a
@@ -228,14 +229,14 @@ class FolderTreeViewState extends State<FolderTreeView> {
_loadFolderChildren(parent);
}
Future<void> _handleItemTap(MediaItem item) async {
Future<void> _handleItemTap(MediaItem item, MediaItem? parent) async {
final result = await navigateToMediaItem(context, item, onRefresh: widget.onRefresh);
if (!mounted) return;
switch (result) {
case MediaNavigationResult.unsupported:
showAppSnackBar(context, t.messages.musicNotSupported);
case MediaNavigationResult.listRefreshNeeded:
widget.onRefresh?.call(item.id);
_refreshAfterDeletion(parent);
case MediaNavigationResult.navigated:
case MediaNavigationResult.librarySelected:
break;
@@ -398,7 +399,7 @@ class FolderTreeViewState extends State<FolderTreeView> {
isLoading: isLoading,
serverId: widget.serverId,
onExpand: isExpandable ? () => _toggleFolder(item) : null,
onTap: !isExpandable ? () => _handleItemTap(item) : null,
onTap: !isExpandable ? () => _handleItemTap(item, entry.parent) : null,
onPlayAll: canPlayFolder ? () => _handleFolderPlay(item) : null,
onShuffle: canPlayFolder ? () => _handleFolderShuffle(item) : null,
focusNode: isFirstRootItem ? widget.firstItemFocusNode : null,
+1 -1
View File
@@ -512,7 +512,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
}
@override
void updateItemInLists(String itemId, MediaItem updatedItem) {
void updateItemInLists(String sourceGlobalKey, MediaItem updatedItem) {
// Delegate to the active tab — parent doesn't maintain its own item list
}
+22 -1
View File
@@ -3,6 +3,7 @@ import 'package:plezy/widgets/app_icon.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../focus/focusable_button.dart';
import '../../i18n/strings.g.dart';
/// Base widget for displaying state messages (empty, error, etc.)
/// Provides a consistent UI pattern for showing icons, messages, and actions
@@ -43,6 +44,12 @@ class StateMessageWidget extends StatelessWidget {
final VoidCallback? onActionNavigateRight;
final VoidCallback? onActionBack;
/// Whether the action button should request focus when it appears.
final bool actionAutofocus;
/// Whether the action button uses a background focus indicator.
final bool actionUseBackgroundFocus;
const StateMessageWidget({
super.key,
required this.message,
@@ -61,6 +68,8 @@ class StateMessageWidget extends StatelessWidget {
this.onActionNavigateRight,
this.onActionBack,
this.actionIcon,
this.actionAutofocus = false,
this.actionUseBackgroundFocus = false,
});
@override
@@ -106,6 +115,8 @@ class StateMessageWidget extends StatelessWidget {
onNavigateRight: onActionNavigateRight,
onBack: onActionBack,
onPressed: onAction,
autofocus: actionAutofocus,
useBackgroundFocus: actionUseBackgroundFocus,
child: FilledButton.icon(
onPressed: onAction,
icon: AppIcon(actionIcon ?? Symbols.refresh_rounded, fill: 1),
@@ -197,6 +208,12 @@ class ErrorStateWidget extends StatelessWidget {
/// Optional callback for retry action
final VoidCallback? onRetry;
/// Whether the retry action should request focus when it appears.
final bool actionAutofocus;
/// Whether the retry action uses a background focus indicator.
final bool actionUseBackgroundFocus;
/// Optional label for the retry button
final String? retryLabel;
final FocusNode? actionFocusNode;
@@ -218,6 +235,8 @@ class ErrorStateWidget extends StatelessWidget {
this.onActionNavigateLeft,
this.onActionNavigateRight,
this.onActionBack,
this.actionAutofocus = false,
this.actionUseBackgroundFocus = false,
});
@override
@@ -228,7 +247,7 @@ class ErrorStateWidget extends StatelessWidget {
iconColor: Theme.of(context).colorScheme.error,
textColor: Theme.of(context).colorScheme.error,
onAction: onRetry,
actionLabel: retryLabel ?? 'Retry',
actionLabel: retryLabel ?? t.common.retry,
actionIcon: Symbols.refresh_rounded,
actionFocusNode: actionFocusNode,
onActionNavigateUp: onActionNavigateUp,
@@ -236,6 +255,8 @@ class ErrorStateWidget extends StatelessWidget {
onActionNavigateLeft: onActionNavigateLeft,
onActionNavigateRight: onActionNavigateRight,
onActionBack: onActionBack,
actionAutofocus: actionAutofocus,
actionUseBackgroundFocus: actionUseBackgroundFocus,
);
}
}
@@ -2,7 +2,7 @@ import 'dart:async';
import 'package:flutter/material.dart';
import '../../../focus/input_mode_tracker.dart';
import '../../../media/media_library.dart';
import '../../../utils/app_logger.dart';
import '../../../utils/error_message_utils.dart';
import '../../../mixins/library_tab_state.dart';
import '../../../mixins/refreshable.dart';
import '../content_state_builder.dart';
@@ -246,12 +246,12 @@ abstract class BaseLibraryTabState<T, W extends BaseLibraryTab<T>> extends State
widget.onDataLoaded!();
});
}
} catch (e) {
} catch (e, stackTrace) {
final message = localizedLoadErrorMessage(e, stackTrace, context: errorContext);
if (!mounted) return;
appLogger.e('Error loading $errorContext', error: e);
setState(() {
_errorMessage = 'Failed to load $errorContext: ${e.toString()}';
_errorMessage = message;
_isLoading = false;
});
}
@@ -4,7 +4,6 @@ import '../../../media/ids.dart';
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import 'package:cached_network_image_ce/cached_network_image.dart';
import '../../../media/library_first_character.dart';
import '../../../media/library_query.dart';
import '../../../media/media_backend.dart';
@@ -12,13 +11,11 @@ import '../../../media/media_item.dart';
import '../../../media/media_kind.dart';
import '../../../providers/multi_server_provider.dart';
import '../../../utils/media_server_http_client.dart';
import '../../../exceptions/media_server_exceptions.dart';
import '../../../focus/dpad_navigator.dart';
import '../../../focus/input_mode_tracker.dart';
import '../../../media/media_filter.dart';
import '../../../media/media_sort.dart';
import '../../../widgets/settings_builder.dart';
import '../../../services/image_cache_service.dart';
import '../../../services/library_query_translator.dart';
import '../../../services/plex_constants.dart';
import '../../../utils/error_message_utils.dart';
@@ -108,9 +105,6 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
DeletionAware,
PaginatedItemLoader<MediaItem, LibraryBrowseTab>,
SkeletonUpgradeScheduler {
@override
String? get itemServerId => widget.library.serverId;
String _toGlobalKey(String ratingKey, {required ServerId serverId}) => buildGlobalKey(serverId, ratingKey);
@override
@@ -161,7 +155,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
final affectedIds = {event.itemId, ...event.parentChain};
for (final item in loadedItems.values) {
if (affectedIds.contains(item.id)) {
unawaited(updateItem(item.id));
unawaited(updateItem(item));
}
}
}
@@ -219,9 +213,9 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
int get itemCount => totalSize;
@override
void updateItemInLists(String itemId, MediaItem updatedMetadata) {
void updateItemInLists(String sourceGlobalKey, MediaItem updatedMetadata) {
for (final entry in loadedItems.entries) {
if (entry.value.id == itemId) {
if (entry.value.globalKey == sourceGlobalKey) {
loadedItems[entry.key] = updatedMetadata;
break;
}
@@ -600,10 +594,11 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
// Load items and first characters in parallel
// _loadItems manages its own requestId internally
await Future.wait([_loadItems(), _loadFirstCharacters(requestId: firstCharactersGeneration)]);
} catch (e) {
} catch (e, stackTrace) {
if (!mounted) return;
final message = localizedLoadErrorMessage(e, stackTrace, context: t.libraries.content);
setState(() {
errorMessage = _getErrorMessage(e);
errorMessage = message;
isLoading = false;
});
}
@@ -727,10 +722,11 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
widget.onDataLoaded!();
});
}
} catch (e) {
} catch (e, stackTrace) {
if (generation != _contentRequestId || !mounted) return;
final message = localizedLoadErrorMessage(e, stackTrace, context: t.libraries.content);
setState(() {
errorMessage = _getErrorMessage(e);
errorMessage = message;
isLoading = false;
});
}
@@ -827,13 +823,6 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
}
}
String _getErrorMessage(dynamic error) {
if (error is MediaServerHttpException) {
return mapHttpErrorToMessage(error, context: t.libraries.content);
}
return mapUnexpectedErrorToMessage(error, context: t.libraries.content);
}
Widget _buildBrowseOptionsSheet(BuildContext sheetContext) {
final controller = OverlaySheetController.of(sheetContext);
return BottomSheetPageScaffold(
@@ -1634,7 +1623,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
final item = items[i];
final thumb = item.posterThumb(mode: episodePosterMode);
if (thumb == null || thumb.isEmpty) continue;
final imageType = item.usesWideAspectRatio(episodePosterMode) ? ImageType.thumb : ImageType.poster;
final imageType = MediaImageHelper.cardImageType(item, episodePosterMode);
final imageUrl = MediaImageHelper.getOptimizedImageUrl(
client: client,
@@ -1648,19 +1637,14 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
final scaledWidth = itemWidth * devicePixelRatio;
final scaledHeight = itemHeight * devicePixelRatio;
final (_, memHeight) = MediaImageHelper.getMemCacheDimensions(
final (memWidth, memHeight) = MediaImageHelper.getMemCacheDimensions(
displayWidth: scaledWidth.isFinite && scaledWidth > 0 ? scaledWidth.round() : 0,
displayHeight: scaledHeight.isFinite && scaledHeight > 0 ? scaledHeight.round() : 0,
imageType: imageType,
);
precacheImage(
CachedNetworkImageProvider(
imageUrl,
cacheManager: PlexImageCacheManager.instance,
headers: const {'User-Agent': 'Plezy'},
maxHeight: memHeight,
),
MediaImageHelper.serverArtworkProvider(imageUrl: imageUrl, memWidth: memWidth, memHeight: memHeight),
context,
).ignore();
}
@@ -6,7 +6,7 @@ import '../../../media/media_item.dart';
import '../../../mixins/library_tab_focus_mixin.dart';
import '../../../mixins/paginated_item_loader.dart';
import '../../../services/settings_service.dart';
import '../../../utils/app_logger.dart';
import '../../../utils/error_message_utils.dart';
import '../../../utils/layout_constants.dart';
import '../../../utils/library_refresh_notifier.dart';
import '../../../utils/media_server_http_client.dart';
@@ -79,6 +79,7 @@ class _LibraryCollectionsTabState extends BaseLibraryTabState<MediaItem, Library
@override
Future<void> loadItems() async {
String? loadErrorMessage;
await loadInitialPaginatedItems(
pageSize: _pageSize,
resetViewState: () {
@@ -90,8 +91,8 @@ class _LibraryCollectionsTabState extends BaseLibraryTabState<MediaItem, Library
items = loaded;
isLoading = false;
},
applyError: (error, _) {
errorMessage = 'Failed to load $errorContext: ${error.toString()}';
applyError: (error, stackTrace) {
errorMessage = loadErrorMessage ?? t.errors.unableToLoad(context: errorContext);
isLoading = false;
},
onLoaded: (_, _) {
@@ -104,7 +105,7 @@ class _LibraryCollectionsTabState extends BaseLibraryTabState<MediaItem, Library
}
},
onError: (error, stackTrace) {
appLogger.e('Error loading $errorContext', error: error, stackTrace: stackTrace);
loadErrorMessage = localizedLoadErrorMessage(error, stackTrace, context: errorContext);
},
);
}
@@ -7,7 +7,7 @@ import '../../../media/media_playlist.dart';
import '../../../mixins/library_tab_focus_mixin.dart';
import '../../../mixins/paginated_item_loader.dart';
import '../../../services/settings_service.dart';
import '../../../utils/app_logger.dart';
import '../../../utils/error_message_utils.dart';
import '../../../utils/layout_constants.dart';
import '../../../utils/library_refresh_notifier.dart';
import '../../../utils/media_server_http_client.dart';
@@ -84,6 +84,7 @@ class _LibraryPlaylistsTabState extends BaseLibraryTabState<MediaPlaylist, Libra
@override
Future<void> loadItems() async {
String? loadErrorMessage;
await loadInitialPaginatedItems(
pageSize: _pageSize,
resetViewState: () {
@@ -95,8 +96,8 @@ class _LibraryPlaylistsTabState extends BaseLibraryTabState<MediaPlaylist, Libra
items = loaded;
isLoading = false;
},
applyError: (error, _) {
errorMessage = 'Failed to load $errorContext: ${error.toString()}';
applyError: (error, stackTrace) {
errorMessage = loadErrorMessage ?? t.errors.unableToLoad(context: errorContext);
isLoading = false;
},
onLoaded: (_, _) {
@@ -109,7 +110,7 @@ class _LibraryPlaylistsTabState extends BaseLibraryTabState<MediaPlaylist, Libra
}
},
onError: (error, stackTrace) {
appLogger.e('Error loading $errorContext', error: error, stackTrace: stackTrace);
loadErrorMessage = localizedLoadErrorMessage(error, stackTrace, context: errorContext);
},
);
}
@@ -59,9 +59,6 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryR
super.dispose();
}
@override
String? get itemServerId => widget.library.serverId;
@override
String? get watchStateServerId => widget.library.serverId;
@@ -106,12 +103,12 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryR
}
@override
void updateItemInLists(String itemId, MediaItem updatedItem) {
void updateItemInLists(String sourceGlobalKey, MediaItem updatedItem) {
// Update the item in any hub that contains it. MediaHub items are
// immutable lists; rebuild the affected hub in-place.
for (var i = 0; i < items.length; i++) {
final hub = items[i];
final itemIndex = hub.items.indexWhere((item) => item.id == itemId);
final itemIndex = hub.items.indexWhere((item) => item.globalKey == sourceGlobalKey);
if (itemIndex != -1) {
final newItems = List<MediaItem>.from(hub.items);
newItems[itemIndex] = updatedItem;
@@ -131,16 +128,16 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryR
}
final affectedIds = {event.itemId, ...event.parentChain};
final refreshIds = <String>{};
final refreshItems = <String, MediaItem>{};
for (final hub in items) {
for (final item in hub.items) {
if (affectedIds.contains(item.id)) {
refreshIds.add(item.id);
refreshItems[item.globalKey] = item;
}
}
}
for (final itemId in refreshIds) {
unawaited(updateItem(itemId));
for (final item in refreshItems.values) {
unawaited(updateItem(item));
}
}