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).
This commit is contained in:
@@ -1373,8 +1373,11 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
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<DiscoverScreen>
|
||||
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],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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<MultiServerProvider>.value(value: multiServerProvider),
|
||||
ChangeNotifierProvider<HiddenLibrariesProvider>.value(value: hiddenLibrariesProvider),
|
||||
ChangeNotifierProvider<LibrariesProvider>.value(value: librariesProvider),
|
||||
ChangeNotifierProvider<WatchTogetherProvider>.value(value: watchTogetherProvider),
|
||||
ChangeNotifierProvider<CompanionRemoteProvider>.value(value: companionRemoteProvider),
|
||||
ChangeNotifierProvider<ActiveProfileProvider>.value(value: activeProfileProvider),
|
||||
ChangeNotifierProvider<DiscoverProvider>.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<Container>(
|
||||
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<MediaHub> hubs;
|
||||
final List<MediaItem> 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<List<MediaItem>> fetchContinueWatching({int? count = 20}) async => const [];
|
||||
Future<List<MediaItem>> fetchContinueWatching({int? count = 20}) async => continueWatching;
|
||||
|
||||
@override
|
||||
Future<List<MediaHub>> fetchGlobalHubs({int limit = defaultHubPreviewLimit, bool includePlaybackHubs = true}) async =>
|
||||
|
||||
Reference in New Issue
Block a user