From c8ca8a7875dc333951f526b5ce4ee8908da0da44 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sat, 4 Jul 2026 23:17:58 +0200 Subject: [PATCH] fix(discover): restore hero indicators and bottom fade on non-TV The hero dots/pause row was gated on live input mode, so any navigation key event (Android back key, BT keyboards, gamepad-source noise) hid it until the next pointer event - on phones it ended up permanently hidden. Gate on the TV platform instead (issue #600's actual scope); the TV layout never renders the carousel, so nothing changes there. The bottom fade lost its guaranteed opaque band when the section-level gradient was removed (686a61ac): the per-item overlay only reached full background at the literal last pixel, letting artwork ghost through the final 15% and read as a hard cut against the content below - worst on phones, where square hero art is bright at the bottom. Finish the fade at solid background from the 0.94 stop (~32px band on a phone hero). --- lib/screens/discover_screen.dart | 14 ++- test/screens/discover_screen_test.dart | 148 ++++++++++++++++++++++++- 2 files changed, 156 insertions(+), 6 deletions(-) diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index 6e71f0b2..914bd284 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -1373,8 +1373,11 @@ class _DiscoverScreenState extends State return _buildHeroItem(_onDeck[index], heroHeight); }, ), - // Page indicators with animated progress and pause/play button - if (!InputModeTracker.isKeyboardMode(context)) + // Page indicators with animated progress and pause/play button. + // Hidden on TV only (issue #600: pointer-only control unreachable + // via d-pad) — never gated on transient input mode, which back-key + // events, BT keyboards, and gamepads can flip on phones/desktop. + if (!isTv) Positioned( bottom: 16, left: -26, @@ -1586,8 +1589,11 @@ class _DiscoverScreenState extends State gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, - colors: [Colors.transparent, bgColor.withValues(alpha: 0.9), bgColor], - stops: isTv ? const [0.25, 0.78, 1.0] : const [0.5, 0.85, 1.0], + // Reach full bg before the bottom edge so the hero + // blends seamlessly into the content below and the + // page dots sit on a solid band. + colors: [Colors.transparent, bgColor.withValues(alpha: 0.9), bgColor, bgColor], + stops: isTv ? const [0.25, 0.78, 0.94, 1.0] : const [0.5, 0.85, 0.94, 1.0], ), ), ); diff --git a/test/screens/discover_screen_test.dart b/test/screens/discover_screen_test.dart index c02b5c30..c8b0f4d8 100644 --- a/test/screens/discover_screen_test.dart +++ b/test/screens/discover_screen_test.dart @@ -1,9 +1,11 @@ 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'; @@ -234,12 +236,154 @@ void main() { expect(FocusManager.instance.primaryFocus?.debugLabel, 'tv_browse_rail'); }); + + 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++) + MediaItem( + 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 = 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, + storage: storage, + ); + final discoverProvider = DiscoverProvider( + multiServerProvider, + hiddenLibrariesProvider, + librariesProvider, + 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; + final List continueWatching; - _FakeMediaServerClient({required this.hubs}); + _FakeMediaServerClient({required this.hubs, this.continueWatching = const []}); @override ServerId get serverId => ServerId('server_1'); @@ -254,7 +398,7 @@ class _FakeMediaServerClient implements MediaServerClient { ServerCapabilities get capabilities => ServerCapabilities.plex; @override - Future> fetchContinueWatching({int? count = 20}) async => const []; + Future> fetchContinueWatching({int? count = 20}) async => continueWatching; @override Future> fetchGlobalHubs({int limit = defaultHubPreviewLimit, bool includePlaybackHubs = true}) async =>