import 'package:drift/native.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:plezy/media/ids.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/connection/connection.dart'; import 'package:plezy/focus/input_mode_tracker.dart'; import 'package:plezy/connection/connection_registry.dart'; import 'package:plezy/database/app_database.dart'; import 'package:plezy/focus/focusable_action_bar.dart'; import 'package:plezy/i18n/strings.g.dart'; import 'package:plezy/media/media_backend.dart'; import 'package:plezy/media/media_hub.dart'; import 'package:plezy/media/media_item.dart'; import 'package:plezy/media/media_kind.dart'; import 'package:plezy/media/media_server_client.dart'; import 'package:plezy/media/server_capabilities.dart'; import 'package:plezy/mixins/refreshable.dart'; import 'package:plezy/mixins/tab_visibility_aware.dart'; import 'package:plezy/profiles/active_profile_provider.dart'; import 'package:plezy/profiles/plex_home_service.dart'; import 'package:plezy/profiles/profile.dart'; import 'package:plezy/profiles/profile_connection.dart'; import 'package:plezy/profiles/profile_connection_registry.dart'; import 'package:plezy/profiles/profile_registry.dart'; import 'package:plezy/providers/companion_remote_provider.dart'; import 'package:plezy/providers/discover_provider.dart'; import 'package:plezy/providers/hidden_libraries_provider.dart'; import 'package:plezy/providers/libraries_provider.dart'; import 'package:plezy/providers/multi_server_provider.dart'; import 'package:plezy/screens/discover_screen.dart'; import 'package:plezy/screens/main_screen.dart'; import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/settings_service.dart'; import 'package:plezy/services/storage_service.dart'; import 'package:plezy/theme/mono_theme.dart'; import 'package:plezy/utils/layout_constants.dart'; import 'package:plezy/utils/platform_detector.dart'; import 'package:plezy/watch_together/watch_together.dart'; import 'package:plezy/widgets/side_navigation_rail.dart'; import 'package:plezy/widgets/tv_browse_rail.dart'; import 'package:plezy/widgets/tv_spotlight_background.dart'; import 'package:provider/provider.dart'; import '../test_helpers/prefs.dart'; import '../test_helpers/media_items.dart'; import '../test_helpers/multi_server_fixtures.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); setUp(() { resetSharedPreferencesForTest(); SettingsService.resetForTesting(); TvDetectionService.debugSetAppleTVOverride(true); LocaleSettings.setLocaleSync(AppLocale.en); }); tearDown(() { TvDetectionService.debugSetAppleTVOverride(null); }); testWidgets('TV tab focus returns to discover browse rail instead of reload action', (tester) async { final settings = await SettingsService.getInstance(); await settings.write(SettingsService.libraryDensity, LibraryDensity.max); tester.view.devicePixelRatio = 1.0; tester.view.physicalSize = const Size(1280, 720); addTearDown(() { tester.view.resetDevicePixelRatio(); tester.view.resetPhysicalSize(); }); final item = testMediaItem( id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie 1', serverId: 'server_1', serverName: 'Server', ); final hub = MediaHub(id: 'hub_1', title: 'Recommended', type: 'movie', items: [item], size: 1); final client = _FakeMediaServerClient(hubs: [hub]); final manager = MultiServerManager()..debugRegisterClientForTesting(client); final multiServerProvider = testMultiServerProvider(manager); final hiddenLibrariesProvider = HiddenLibrariesProvider(); final librariesProvider = LibrariesProvider(); final watchTogetherProvider = WatchTogetherProvider(); final companionRemoteProvider = CompanionRemoteProvider(); final db = AppDatabase.forTesting(NativeDatabase.memory()); final profileRegistry = _FakeProfileRegistry(db); final connectionRegistry = _FakeConnectionRegistry(db); final profileConnectionRegistry = _FakeProfileConnectionRegistry(db); final storage = await StorageService.getInstance(); final plexHome = PlexHomeService( connections: connectionRegistry, profileConnections: profileConnectionRegistry, storage: storage, plexHomeUserFetcher: (_) async => const [], ); final activeProfileProvider = ActiveProfileProvider( registry: profileRegistry, plexHome: plexHome, connections: connectionRegistry, profileConnections: profileConnectionRegistry, storage: storage, ); final discoverProvider = DiscoverProvider( multiServerProvider, hiddenLibrariesProvider, librariesProvider, profileId: null, isProfileBinding: () => activeProfileProvider.isBinding, ); final discoverKey = GlobalKey>(); const targetSidebarOffset = SideNavigationRailState.expandedWidth; const currentForegroundLeft = 120.0; const foregroundWidth = 1280 - SideNavigationRailState.tvCollapsedWidth; addTearDown(() async { discoverProvider.dispose(); activeProfileProvider.dispose(); companionRemoteProvider.dispose(); watchTogetherProvider.dispose(); librariesProvider.dispose(); hiddenLibrariesProvider.dispose(); multiServerProvider.dispose(); await plexHome.dispose(); await db.close(); }); await tester.pumpWidget( TranslationProvider( child: MultiProvider( providers: [ ChangeNotifierProvider.value(value: multiServerProvider), ChangeNotifierProvider.value(value: hiddenLibrariesProvider), ChangeNotifierProvider.value(value: librariesProvider), ChangeNotifierProvider.value(value: watchTogetherProvider), ChangeNotifierProvider.value(value: companionRemoteProvider), ChangeNotifierProvider.value(value: activeProfileProvider), ChangeNotifierProvider.value(value: discoverProvider), ], child: MaterialApp( theme: monoTheme(dark: true), home: MainScreenFocusScope( focusSidebar: () {}, focusContent: () {}, isSidebarFocused: false, sideNavigationWidth: targetSidebarOffset, reservedSideNavigationWidth: SideNavigationRailState.tvCollapsedWidth, foregroundLeft: currentForegroundLeft, foregroundWidth: foregroundWidth, viewportWidth: 1280, child: Align( alignment: Alignment.centerLeft, child: SizedBox( width: foregroundWidth, height: 720, child: DiscoverScreen(key: discoverKey), ), ), ), ), ), ), ); await tester.pumpAndSettle(); expect(find.byType(TvBrowseRail), findsOneWidget); final scale = TvLayoutConstants.scaleForSize(const Size(1280, 720)); final spotlightLeft = (24 * scale).clamp(18.0, 40.0).toDouble(); final spotlightBackground = tester.widget(find.byType(TvSpotlightBackground)); expect(spotlightBackground.contentLeft, closeTo(spotlightLeft + currentForegroundLeft, 0.001)); final railHeight = TvBrowseRailLayout.estimateHeight( size: const Size(foregroundWidth, 720), hubs: [hub], density: LibraryDensity.max, episodePosterMode: settings.read(SettingsService.episodePosterMode), fullCardLayout: settings.read(SettingsService.tvFullCardLayout), tallPosterScale: TvBrowseRailLayout.compactTallPosterScale, ); final minimumSpotlightBottom = railHeight + (8 * scale); final baseSpotlightBottom = (720 * 0.48).clamp(160.0, 820.0).toDouble(); final desiredSpotlightBottom = minimumSpotlightBottom > baseSpotlightBottom ? minimumSpotlightBottom : baseSpotlightBottom; final maxSpotlightBottom = (720 - ((720 * 0.075).clamp(64.0 * scale, 120.0 * scale)) - (96 * scale)) .clamp(0.0, double.infinity) .toDouble(); final expectedSpotlightBottom = desiredSpotlightBottom > maxSpotlightBottom ? maxSpotlightBottom : desiredSpotlightBottom; expect(spotlightBackground.contentBottom, closeTo(expectedSpotlightBottom, 0.001)); // The rail no longer receives the bleed via constructor (a per-flip param // would rebuild the whole rail); its bleed layer reads the scope's // sideNavigationWidth itself. Assert the rendered bleed position instead. final browseRail = tester.widget(find.byType(TvBrowseRail)); expect(browseRail.backgroundBleedLeft, isNull); final railBleedPositions = tester .widgetList(find.descendant(of: find.byType(TvBrowseRail), matching: find.byType(Positioned))) .where((p) => p.left == -targetSidebarOffset); expect(railBleedPositions, isNotEmpty, reason: 'rail bleed layer positions at -sideNavigationWidth'); final backgroundPosition = tester.widget( find.ancestor(of: find.byType(TvSpotlightBackground), matching: find.byType(Positioned)).first, ); expect(backgroundPosition.left, -currentForegroundLeft); expect(backgroundPosition.width, 1280); tester.state(find.byType(FocusableActionBar)).requestFocusOnFirst(); await tester.pump(); expect(FocusManager.instance.primaryFocus?.debugLabel, 'ActionBar[0]'); await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowDown); expect(FocusManager.instance.primaryFocus?.debugLabel, 'tv_browse_rail'); await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowDown); await tester.pump(); tester.state(find.byType(FocusableActionBar)).requestFocusOnFirst(); await tester.pump(); expect(FocusManager.instance.primaryFocus?.debugLabel, 'ActionBar[0]'); (discoverKey.currentState! as FocusableTab).focusActiveTabIfReady(); (discoverKey.currentState! as TabVisibilityAware).onTabHidden(); await tester.pump(); await tester.pump(); expect(FocusManager.instance.primaryFocus?.debugLabel, 'ActionBar[0]'); (discoverKey.currentState! as TabVisibilityAware).onTabShown(); (discoverKey.currentState! as FocusableTab).focusActiveTabIfReady(); await tester.pump(); await tester.pump(); expect(FocusManager.instance.primaryFocus?.debugLabel, 'tv_browse_rail'); }); testWidgets('TV selects Continue Watching when it arrives after recommendation hubs', (tester) async { await SettingsService.getInstance(); tester.view.devicePixelRatio = 1.0; tester.view.physicalSize = const Size(1280, 720); addTearDown(() { tester.view.resetDevicePixelRatio(); tester.view.resetPhysicalSize(); }); final recommendedItem = testMediaItem( id: 'recommended', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Recommended', serverId: 'server_1', serverName: 'Server', ); final continueItem = testMediaItem( id: 'continue', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Continue Watching', serverId: 'server_1', serverName: 'Server', ); final recommendedHub = MediaHub( id: 'recommended_hub', title: 'Recommended', type: 'movie', items: [recommendedItem], size: 1, ); final client = _FakeMediaServerClient(hubs: [recommendedHub]); final manager = MultiServerManager()..debugRegisterClientForTesting(client); final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); final hiddenLibrariesProvider = HiddenLibrariesProvider(); final librariesProvider = LibrariesProvider(); final watchTogetherProvider = WatchTogetherProvider(); final companionRemoteProvider = CompanionRemoteProvider(); final db = AppDatabase.forTesting(NativeDatabase.memory()); final profileRegistry = _FakeProfileRegistry(db); final connectionRegistry = _FakeConnectionRegistry(db); final profileConnectionRegistry = _FakeProfileConnectionRegistry(db); final storage = await StorageService.getInstance(); final plexHome = PlexHomeService( connections: connectionRegistry, profileConnections: profileConnectionRegistry, storage: storage, plexHomeUserFetcher: (_) async => const [], ); final activeProfileProvider = ActiveProfileProvider( registry: profileRegistry, plexHome: plexHome, connections: connectionRegistry, profileConnections: profileConnectionRegistry, storage: storage, ); final discoverProvider = DiscoverProvider( multiServerProvider, hiddenLibrariesProvider, librariesProvider, profileId: null, isProfileBinding: () => activeProfileProvider.isBinding, ); const foregroundWidth = 1280 - SideNavigationRailState.tvCollapsedWidth; addTearDown(() async { discoverProvider.dispose(); activeProfileProvider.dispose(); companionRemoteProvider.dispose(); watchTogetherProvider.dispose(); librariesProvider.dispose(); hiddenLibrariesProvider.dispose(); multiServerProvider.dispose(); await plexHome.dispose(); await db.close(); }); await tester.pumpWidget( TranslationProvider( child: MultiProvider( providers: [ ChangeNotifierProvider.value(value: multiServerProvider), ChangeNotifierProvider.value(value: hiddenLibrariesProvider), ChangeNotifierProvider.value(value: librariesProvider), ChangeNotifierProvider.value(value: watchTogetherProvider), ChangeNotifierProvider.value(value: companionRemoteProvider), ChangeNotifierProvider.value(value: activeProfileProvider), ChangeNotifierProvider.value(value: discoverProvider), ], child: MaterialApp( theme: monoTheme(dark: true), home: MainScreenFocusScope( focusSidebar: () {}, focusContent: () {}, isSidebarFocused: false, sideNavigationWidth: SideNavigationRailState.expandedWidth, reservedSideNavigationWidth: SideNavigationRailState.tvCollapsedWidth, foregroundLeft: 0, foregroundWidth: foregroundWidth, viewportWidth: 1280, child: const SizedBox(width: foregroundWidth, height: 720, child: DiscoverScreen()), ), ), ), ), ); await tester.pumpAndSettle(); expect(FocusManager.instance.primaryFocus?.debugLabel, 'tv_browse_rail'); expect(tester.widget(find.byType(TvSpotlightBackground)).item?.id, recommendedItem.id); client.continueWatching = [continueItem]; await discoverProvider.load(); await tester.pump(); await tester.pump(const Duration(milliseconds: 200)); await tester.pump(); expect(FocusManager.instance.primaryFocus?.debugLabel, 'tv_browse_rail'); expect(tester.widget(find.byType(TvSpotlightBackground)).item?.id, continueItem.id); }); testWidgets('non-TV hero keeps indicators visible in keyboard mode and fades to solid bg', (tester) async { TvDetectionService.debugSetAppleTVOverride(false); await SettingsService.getInstance(); tester.view.devicePixelRatio = 1.0; tester.view.physicalSize = const Size(1280, 720); addTearDown(() { tester.view.resetDevicePixelRatio(); tester.view.resetPhysicalSize(); }); final onDeck = [ for (var i = 0; i < 3; i++) testMediaItem( id: 'movie_$i', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie $i', serverId: 'server_1', serverName: 'Server', ), ]; final client = _FakeMediaServerClient(hubs: const [], continueWatching: onDeck); final manager = MultiServerManager()..debugRegisterClientForTesting(client); final multiServerProvider = testMultiServerProvider(manager); final hiddenLibrariesProvider = HiddenLibrariesProvider(); final librariesProvider = LibrariesProvider(); final watchTogetherProvider = WatchTogetherProvider(); final companionRemoteProvider = CompanionRemoteProvider(); final db = AppDatabase.forTesting(NativeDatabase.memory()); final profileRegistry = _FakeProfileRegistry(db); final connectionRegistry = _FakeConnectionRegistry(db); final profileConnectionRegistry = _FakeProfileConnectionRegistry(db); final storage = await StorageService.getInstance(); final plexHome = PlexHomeService( connections: connectionRegistry, profileConnections: profileConnectionRegistry, storage: storage, plexHomeUserFetcher: (_) async => const [], ); final activeProfileProvider = ActiveProfileProvider( registry: profileRegistry, plexHome: plexHome, connections: connectionRegistry, profileConnections: profileConnectionRegistry, storage: storage, ); final discoverProvider = DiscoverProvider( multiServerProvider, hiddenLibrariesProvider, librariesProvider, profileId: null, isProfileBinding: () => activeProfileProvider.isBinding, ); addTearDown(() async { discoverProvider.dispose(); activeProfileProvider.dispose(); companionRemoteProvider.dispose(); watchTogetherProvider.dispose(); librariesProvider.dispose(); hiddenLibrariesProvider.dispose(); multiServerProvider.dispose(); await plexHome.dispose(); await db.close(); }); await tester.pumpWidget( TranslationProvider( child: MultiProvider( providers: [ ChangeNotifierProvider.value(value: multiServerProvider), ChangeNotifierProvider.value(value: hiddenLibrariesProvider), ChangeNotifierProvider.value(value: librariesProvider), ChangeNotifierProvider.value(value: watchTogetherProvider), ChangeNotifierProvider.value(value: companionRemoteProvider), ChangeNotifierProvider.value(value: activeProfileProvider), ChangeNotifierProvider.value(value: discoverProvider), ], child: InputModeTracker( child: MaterialApp( theme: monoTheme(dark: true), home: MainScreenFocusScope( focusSidebar: () {}, focusContent: () {}, isSidebarFocused: false, sideNavigationWidth: SideNavigationRailState.expandedWidth, reservedSideNavigationWidth: SideNavigationRailState.tvCollapsedWidth, foregroundLeft: 0, foregroundWidth: 1280, viewportWidth: 1280, child: const DiscoverScreen(), ), ), ), ), ), ); // Bounded pumps only: the hero runs periodic auto-scroll/indicator timers, // so pumpAndSettle would never settle. await tester.pump(); await tester.pump(const Duration(milliseconds: 100)); await tester.pump(const Duration(milliseconds: 100)); expect(find.byType(PageView), findsOneWidget); expect(find.byIcon(Symbols.pause_rounded), findsOneWidget, reason: 'hero indicators render in pointer mode'); // Entering keyboard mode must not hide the indicators on non-TV devices // (regression: back-key/BT-keyboard events left them permanently hidden). await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowDown); await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowDown); await tester.pump(); expect( find.byIcon(Symbols.pause_rounded), findsOneWidget, reason: 'hero indicators stay visible in keyboard mode on non-TV', ); // The bottom fade must end in solid scaffold background before the hero // edge so the artwork cannot ghost through at the hero/content boundary. final scaffoldBg = Theme.of(tester.element(find.byType(DiscoverScreen))).scaffoldBackgroundColor; final heroFades = tester .widgetList( find.byWidgetPredicate( (w) => w is Container && w.decoration is BoxDecoration && (w.decoration! as BoxDecoration).gradient is LinearGradient, ), ) .map((c) => (c.decoration! as BoxDecoration).gradient! as LinearGradient) .where((g) => g.begin == Alignment.topCenter && g.end == Alignment.bottomCenter && g.colors.length == 4) .toList(); expect(heroFades, isNotEmpty, reason: 'hero bottom fade overlay renders'); for (final fade in heroFades) { expect(fade.stops, const [0.5, 0.85, 0.94, 1.0]); expect(fade.colors[2], scaffoldBg, reason: 'fade reaches full bg at 94%'); expect(fade.colors[3], scaffoldBg); } // Dispose the screen so the hero's periodic timers are cancelled before // the binding's pending-timer check. await tester.pumpWidget(const SizedBox()); }); } class _FakeMediaServerClient implements MediaServerClient { final List hubs; List continueWatching; _FakeMediaServerClient({required this.hubs, this.continueWatching = const []}); @override ServerId get serverId => ServerId('server_1'); @override String? get serverName => 'Server'; @override MediaBackend get backend => MediaBackend.plex; @override ServerCapabilities get capabilities => ServerCapabilities.plex; @override Future> fetchContinueWatching({int? count = 20}) async => continueWatching; @override Future> fetchGlobalHubs({int limit = defaultHubPreviewLimit, bool includePlaybackHubs = true}) async => hubs; @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } class _FakeProfileRegistry extends ProfileRegistry { _FakeProfileRegistry(super.db); @override Stream> watchProfiles() => Stream.value(const []); @override Future> list() async => const []; } class _FakeConnectionRegistry extends ConnectionRegistry { _FakeConnectionRegistry(super.db); @override Stream> watchConnections() => Stream.value(const []); @override Future> list() async => const []; } class _FakeProfileConnectionRegistry extends ProfileConnectionRegistry { _FakeProfileConnectionRegistry(super.db); @override Stream> watchAll() => Stream.value(const []); }