diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index a442eb43..fb207241 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -2590,15 +2590,7 @@ class _MediaDetailScreenState extends State context, metadata: episode, isOffline: widget.isOffline, - onRefresh: () async { - final refreshed = await client?.fetchItem(episode.id); - if (refreshed != null) { - setStateIfMounted(() { - _episodes[index] = refreshed; - _syncEpisodeToCache(index, refreshed); - }); - } - }, + onRefresh: () => unawaited(_refreshItemInPlace(episode.id)), ); }, onRefresh: widget.isOffline ? null : _refreshItemInPlace, @@ -2674,18 +2666,6 @@ class _MediaDetailScreenState extends State ); } - /// Sync an updated episode back into the episode cache - void _syncEpisodeToCache(int episodeIndex, MediaItem updated) { - if (_isFlattenEpisodeList) { - _allEpisodes = _allEpisodes.replaceItems(_episodes); - return; - } - if (_seasons.isEmpty) return; - if (_selectedSeasonIndex >= _seasons.length) return; - final season = _seasons[_selectedSeasonIndex]; - _seasonEpisodePager.updateEpisode(season.id, episodeIndex, updated); - } - void _syncFlattenEpisodeState() { if (_isFlattenEpisodeList) _allEpisodes = _allEpisodes.replaceItems(_episodes); } diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index 974c7db9..75a3cc48 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -553,7 +553,10 @@ class MediaContextMenuState extends State { // Queue for later sync — the offline provider emits the WatchStateEvent. await WatchActions.setWatched(context, item, watched: watched, offline: true); if (context.mounted) { - showAppSnackBar(context, watched ? t.messages.markedAsWatchedOffline : t.messages.markedAsUnwatchedOffline); + showAppSnackBar( + context, + watched ? t.messages.markedAsWatchedOffline : t.messages.markedAsUnwatchedOffline, + ); _notifyRefresh(item.id); } } else { @@ -708,6 +711,11 @@ class MediaContextMenuState extends State { await _handleDeleteMediaItem(context, mediaKind); break; } + } catch (e, st) { + appLogger.e('Media context menu action failed', error: e, stackTrace: st); + if (context.mounted) { + showErrorSnackBar(context, t.messages.errorLoading(error: e.toString())); + } } finally { _isContextMenuOpen = false; @@ -810,11 +818,13 @@ class MediaContextMenuState extends State { } Future _showFileInfo(BuildContext context) async { - final client = _getMediaClientForItem(); + var loadingShown = false; try { + final client = _getMediaClientForItem(); if (context.mounted) { showLoadingDialog(context); + loadingShown = true; } // Fetch file info @@ -822,8 +832,9 @@ class MediaContextMenuState extends State { final fileInfo = await client.getFileInfo(item); // Close loading indicator - if (context.mounted) { + if (loadingShown && context.mounted) { Navigator.pop(context); + loadingShown = false; } if (fileInfo != null && context.mounted) { @@ -838,7 +849,7 @@ class MediaContextMenuState extends State { } } catch (e) { // Close loading indicator if it's still open - if (context.mounted && Navigator.canPop(context)) { + if (loadingShown && context.mounted && Navigator.canPop(context)) { Navigator.pop(context); } @@ -1346,10 +1357,10 @@ class MediaContextMenuState extends State { Future _handleDownload(BuildContext context) async { final downloadProvider = Provider.of(context, listen: false); final item = _mediaItem!; - // Backend-agnostic resolve so Jellyfin items can be downloaded too. - final client = context.getMediaClientWithFallback(serverIdOrNull(_itemServerId)); try { + // Backend-agnostic resolve so Jellyfin items can be downloaded too. + final client = context.getMediaClientWithFallback(serverIdOrNull(_itemServerId)); final result = await showDownloadOptionsAndQueue( context, metadata: item, diff --git a/test/widgets/media_context_menu_test.dart b/test/widgets/media_context_menu_test.dart index 9fcdd12d..782cbafd 100644 --- a/test/widgets/media_context_menu_test.dart +++ b/test/widgets/media_context_menu_test.dart @@ -1,16 +1,34 @@ +import 'package:drift/native.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:plezy/connection/connection.dart'; +import 'package:plezy/connection/connection_registry.dart'; +import 'package:plezy/database/app_database.dart'; +import 'package:plezy/i18n/strings.g.dart'; import 'package:plezy/media/media_backend.dart'; +import 'package:plezy/media/media_item.dart'; import 'package:plezy/media/media_kind.dart'; import 'package:plezy/metadata_edit/metadata_edit_adapters.dart'; import 'package:plezy/models/plex/plex_home_user.dart'; import 'package:plezy/profiles/profile.dart'; +import 'package:plezy/profiles/active_profile_provider.dart'; +import 'package:plezy/profiles/plex_home_service.dart'; +import 'package:plezy/profiles/profile_connection_registry.dart'; +import 'package:plezy/profiles/profile_registry.dart'; +import 'package:plezy/providers/multi_server_provider.dart'; +import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/jellyfin_client.dart'; +import 'package:plezy/services/multi_server_manager.dart'; +import 'package:plezy/theme/mono_theme.dart'; +import 'package:plezy/utils/platform_detector.dart'; import 'package:plezy/widgets/media_context_menu.dart'; +import 'package:provider/provider.dart'; void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + group('isAdminActionAllowedForMediaItem', () { test('blocks non-admin Plex Home users on Plex items', () { final profile = Profile.virtualPlexHome(connectionId: 'plex-1', homeUser: _homeUser(admin: false)); @@ -57,6 +75,79 @@ void main() { expect(supportsMetadataEdit(client, MediaKind.track), isFalse); }); }); + + group('MediaContextMenu actions', () { + testWidgets('file info client resolution failure shows an error without popping another route', (tester) async { + LocaleSettings.setLocaleSync(AppLocale.en); + TvDetectionService.debugSetAppleTVOverride(true); + addTearDown(() => TvDetectionService.debugSetAppleTVOverride(null)); + + final db = AppDatabase.forTesting(NativeDatabase.memory()); + final manager = MultiServerManager(); + final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); + final connections = ConnectionRegistry(db); + final profileConnections = ProfileConnectionRegistry(db); + final plexHome = PlexHomeService( + connections: connections, + profileConnections: profileConnections, + plexHomeUserFetcher: (_) async => const [], + ); + final activeProfileProvider = ActiveProfileProvider( + registry: ProfileRegistry(db), + plexHome: plexHome, + connections: connections, + ); + addTearDown(() async { + activeProfileProvider.dispose(); + await plexHome.dispose(); + multiServerProvider.dispose(); + manager.dispose(); + await db.close(); + }); + + final menuKey = GlobalKey(); + final item = MediaItem( + id: 'movie-1', + backend: MediaBackend.jellyfin, + kind: MediaKind.movie, + title: 'Movie', + serverId: 'missing-server', + ); + + await tester.pumpWidget( + TranslationProvider( + child: MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: multiServerProvider), + ChangeNotifierProvider.value(value: activeProfileProvider), + ], + child: MaterialApp( + theme: monoTheme(dark: true), + home: Scaffold( + body: Center( + child: MediaContextMenu( + key: menuKey, + item: item, + child: const SizedBox(width: 120, height: 80, child: Text('target')), + ), + ), + ), + ), + ), + ), + ); + + menuKey.currentState!.showContextMenu(tester.element(find.text('target'))); + await tester.pumpAndSettle(); + + await tester.tap(find.text(t.mediaMenu.fileInfo)); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(find.byType(SnackBar), findsOneWidget); + expect(find.text('target'), findsOneWidget); + }); + }); } PlexHomeUser _homeUser({required bool admin}) {