Files
plezy/test/screens/discover_screen_test.dart
edde746 5a666fd10f feat(discover): refresh home and library content from server library push events
New media added server-side was invisible while the app was open - the
client had no channel for library-change notifications, so the home
screen and library tabs sat stale until an app restart.

Each online server now runs a reconnecting websocket push channel
(Plex `/:/websockets/notifications`; Jellyfin/Emby `/socket` with the
dialect deltas, including Emby's capabilities registration), owned per
server by a LibraryEventService supervisor that follows server
online/offline/replaced-client transitions and app lifecycle. Channels
emit one coalesced LibraryChangeEvent per burst on a leading-edge
throttle modeled on Plex Web's repopulate pacing: the first settled
change surfaces immediately and a library scan's flood merges behind
it. Connection failure degrades silently to the stale-refresh paths -
bounded reconnect backoff, re-armed on every status sync.

Consumers pace their reaction through one shared RefreshPacer: burst
debounce, blocked retry while video playback or an active scroll owns
the surface, a cooldown bounding pass frequency during bulk imports,
and credit for committed pull passes so a push landing right after a
fresh load defers to the cooldown's trailing edge.

- Discover runs a debounced full pass and swaps the result in place;
  the hero carousel resets only when a pass lands first content, so a
  push never yanks a screen the user is reading. Pushed removals drop
  from every visible list immediately via the deletion bus, scoped to
  the emitting server.
- The visible library tab swaps its data in place: transaction tabs
  reload without clearing, and the browse and paginated card grids
  refetch their loaded span (Plex Web's repopulateRange) with the
  scroll offset anchored on the first visible item and the span
  clamped after alpha jumps. Hidden tabs - and tabs behind another
  main tab - mark per-library staleness epochs instead and reload when
  next shown; epochs are snapshotted at load start so a push racing an
  in-flight fetch stays stale.
- The stale-resume and tab-shown paths refetch home hubs (previously
  Continue Watching only), covering setups where the socket cannot
  connect.

close #1646
2026-08-31 18:52:03 +02:00

960 lines
38 KiB
Dart

import 'dart:async';
import 'package:drift/native.dart';
import 'package:intl/date_symbol_data_local.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_library.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/system_clock.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();
setUpAll(() => initializeDateFormatting('en'));
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<State<DiscoverScreen>>();
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<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: MaterialApp(
theme: monoTheme(dark: true),
home: MainScreenFocusScope(
focusSidebar: () {},
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<TvSpotlightBackground>(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<TvBrowseRail>(find.byType(TvBrowseRail));
expect(browseRail.backgroundBleedLeft, isNull);
final railBleedPositions = tester
.widgetList<Positioned>(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<Positioned>(
find.ancestor(of: find.byType(TvSpotlightBackground), matching: find.byType(Positioned)).first,
);
expect(backgroundPosition.left, -currentForegroundLeft);
expect(backgroundPosition.width, 1280);
tester.state<FocusableActionBarState>(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<FocusableActionBarState>(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('startup prime does not duplicate the load DiscoverScreen started in initState', (tester) async {
// DiscoverScreen.initState fires load(); main_screen._primeOnlineServices
// then primes the content tabs once libraries land. Asking for a full
// refresh there queued a trailing pass through CoalescedLoadCoordinator and
// ran the whole home fan-out twice on every cold start (#1784).
await SettingsService.getInstance();
tester.view.devicePixelRatio = 1.0;
tester.view.physicalSize = const Size(1280, 720);
addTearDown(() {
tester.view.resetDevicePixelRatio();
tester.view.resetPhysicalSize();
});
final hub = MediaHub(id: 'hub_1', title: 'Recommended', type: 'movie', items: const [], size: 0);
final client = _GatedHubsFakeClient(hubs: [hub]);
// Disposes both the provider and the manager it wraps; MultiServerProvider
// does not own the manager, and manager.dispose() is what closes its
// status/progress controllers and the registered clients.
final multiServerProvider = testMultiServer(clients: [client]).provider;
final hiddenLibrariesProvider = HiddenLibrariesProvider();
final librariesProvider = LibrariesProvider();
final watchTogetherProvider = WatchTogetherProvider();
final companionRemoteProvider = CompanionRemoteProvider();
final db = AppDatabase.forTesting(NativeDatabase.memory());
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: _FakeProfileRegistry(db),
plexHome: plexHome,
connections: connectionRegistry,
profileConnections: profileConnectionRegistry,
storage: storage,
);
final discoverProvider = DiscoverProvider(
multiServerProvider,
hiddenLibrariesProvider,
librariesProvider,
profileId: null,
isProfileBinding: () => false,
);
addTearDown(() async {
discoverProvider.dispose();
activeProfileProvider.dispose();
companionRemoteProvider.dispose();
watchTogetherProvider.dispose();
librariesProvider.dispose();
hiddenLibrariesProvider.dispose();
// multiServerProvider + its manager are torn down by testMultiServer.
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<DiscoverProvider>.value(value: discoverProvider),
ChangeNotifierProvider<WatchTogetherProvider>.value(value: watchTogetherProvider),
ChangeNotifierProvider<CompanionRemoteProvider>.value(value: companionRemoteProvider),
ChangeNotifierProvider<ActiveProfileProvider>.value(value: activeProfileProvider),
],
child: MaterialApp(
theme: monoTheme(dark: true),
home: MainScreenFocusScope(
focusSidebar: () {},
sideNavigationWidth: SideNavigationRailState.expandedWidth,
reservedSideNavigationWidth: SideNavigationRailState.tvCollapsedWidth,
foregroundLeft: 0,
foregroundWidth: 1280,
viewportWidth: 1280,
child: const SizedBox(width: 1280, height: 720, child: DiscoverScreen()),
),
),
),
),
);
await tester.pump();
// The initState pass is in flight, waiting on the server.
expect(discoverProvider.isLoadInFlight, isTrue);
expect(client.hubCalls, 1);
final screen = tester.state(find.byType(DiscoverScreen)) as FullRefreshable;
screen.primeRefresh();
client.release();
// Bounded pumps, not pumpAndSettle: the hero carousel runs a repeating
// auto-scroll timer, so the frame loop never goes quiet.
await tester.pump();
await tester.pump();
await tester.pump();
expect(client.hubCalls, 1, reason: 'the prime rode along with the load already running');
expect(discoverProvider.isLoadInFlight, isFalse);
// A prime with nothing in flight (reconnect-from-offline) must still refetch.
screen.primeRefresh();
await tester.pump();
client.release();
await tester.pump();
await tester.pump();
expect(client.hubCalls, 2);
});
testWidgets('Refreshable.refresh refetches hubs only once they are stale (#1646)', (tester) async {
// The stale-resume gate goes through Refreshable.refresh(). A fresh
// refresh must stay Continue Watching-only (zero hub calls), but once the
// hub list is older than DiscoverProvider.staleAfter it must run a full
// pass — otherwise media added server-side never appears until a restart.
await SettingsService.getInstance();
tester.view.devicePixelRatio = 1.0;
tester.view.physicalSize = const Size(1280, 720);
addTearDown(() {
tester.view.resetDevicePixelRatio();
tester.view.resetPhysicalSize();
});
var currentTime = DateTime(2026, 1, 1, 12);
final hub = MediaHub(id: 'hub_1', title: 'Recommended', type: 'movie', items: const [], size: 0);
final client = _GatedHubsFakeClient(hubs: [hub]);
final multiServerProvider = testMultiServer(clients: [client]).provider;
final hiddenLibrariesProvider = HiddenLibrariesProvider();
final librariesProvider = LibrariesProvider();
final watchTogetherProvider = WatchTogetherProvider();
final companionRemoteProvider = CompanionRemoteProvider();
final db = AppDatabase.forTesting(NativeDatabase.memory());
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: _FakeProfileRegistry(db),
plexHome: plexHome,
connections: connectionRegistry,
profileConnections: profileConnectionRegistry,
storage: storage,
);
final discoverProvider = DiscoverProvider(
multiServerProvider,
hiddenLibrariesProvider,
librariesProvider,
profileId: null,
isProfileBinding: () => false,
now: () => currentTime,
);
addTearDown(() async {
discoverProvider.dispose();
activeProfileProvider.dispose();
companionRemoteProvider.dispose();
watchTogetherProvider.dispose();
librariesProvider.dispose();
hiddenLibrariesProvider.dispose();
// multiServerProvider + its manager are torn down by testMultiServer.
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<DiscoverProvider>.value(value: discoverProvider),
ChangeNotifierProvider<WatchTogetherProvider>.value(value: watchTogetherProvider),
ChangeNotifierProvider<CompanionRemoteProvider>.value(value: companionRemoteProvider),
ChangeNotifierProvider<ActiveProfileProvider>.value(value: activeProfileProvider),
],
child: MaterialApp(
theme: monoTheme(dark: true),
home: MainScreenFocusScope(
focusSidebar: () {},
sideNavigationWidth: SideNavigationRailState.expandedWidth,
reservedSideNavigationWidth: SideNavigationRailState.tvCollapsedWidth,
foregroundLeft: 0,
foregroundWidth: 1280,
viewportWidth: 1280,
child: const SizedBox(width: 1280, height: 720, child: DiscoverScreen()),
),
),
),
),
);
await tester.pump();
// Let the initState load pass commit, stamping hub freshness at T0.
client.release();
await tester.pump();
await tester.pump();
expect(client.hubCalls, 1);
expect(discoverProvider.isLoadInFlight, isFalse);
final screen = tester.state(find.byType(DiscoverScreen)) as Refreshable;
// Fresh: the resume refresh stays Continue Watching-only.
screen.refresh();
await tester.pump();
await tester.pump();
expect(client.hubCalls, 1, reason: 'a fresh refresh must not refetch hubs');
// Stale: the same refresh entry point now runs the full pass.
currentTime = currentTime.add(DiscoverProvider.staleAfter + const Duration(seconds: 1));
screen.refresh();
await tester.pump();
client.release();
await tester.pump();
await tester.pump();
expect(client.hubCalls, 2, reason: 'a stale refresh must refetch the home hubs');
});
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<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: MaterialApp(
theme: monoTheme(dark: true),
home: MainScreenFocusScope(
focusSidebar: () {},
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<TvSpotlightBackground>(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<TvSpotlightBackground>(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<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: () {},
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());
});
testWidgets('the home clock is TV-only chrome', (tester) async {
await _pumpDiscoverShell(tester, isTv: false);
expect(find.text(t.discover.title), findsOneWidget);
expect(
find.byType(SystemClock),
findsNothing,
reason: 'a phone status bar and a desktop menu bar already show the time',
);
await tester.pumpWidget(const SizedBox());
await _pumpDiscoverShell(tester, isTv: true);
expect(find.byType(SystemClock), findsOneWidget, reason: 'a fullscreen leanback app hides the system clock');
await tester.pumpWidget(const SizedBox());
});
}
/// Mounts [DiscoverScreen] in the smallest graph both layout branches need, so
/// one test can compare the TV and non-TV chrome without rebuilding it twice.
Future<void> _pumpDiscoverShell(WidgetTester tester, {required bool isTv}) async {
TvDetectionService.debugSetAppleTVOverride(isTv);
await SettingsService.getInstance();
tester.view.devicePixelRatio = 1.0;
tester.view.physicalSize = const Size(1280, 720);
addTearDown(() {
tester.view.resetDevicePixelRatio();
tester.view.resetPhysicalSize();
});
final client = _FakeMediaServerClient(hubs: const []);
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 storage = await StorageService.getInstance();
final connectionRegistry = _FakeConnectionRegistry(db);
final profileConnectionRegistry = _FakeProfileConnectionRegistry(db);
final plexHome = PlexHomeService(
connections: connectionRegistry,
profileConnections: profileConnectionRegistry,
storage: storage,
plexHomeUserFetcher: (_) async => const [],
);
final activeProfileProvider = ActiveProfileProvider(
registry: _FakeProfileRegistry(db),
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<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: () {},
sideNavigationWidth: SideNavigationRailState.expandedWidth,
reservedSideNavigationWidth: SideNavigationRailState.tvCollapsedWidth,
foregroundLeft: 0,
foregroundWidth: 1280,
viewportWidth: 1280,
child: const DiscoverScreen(),
),
),
),
),
),
);
// Bounded pumps only: the hero runs periodic auto-scroll timers, so
// pumpAndSettle would never settle.
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
}
class _FakeMediaServerClient implements MediaServerClient {
final List<MediaHub> hubs;
List<MediaItem> 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<List<MediaItem>> fetchContinueWatching({int? count = 20}) async => continueWatching;
@override
Future<List<MediaHub>> fetchGlobalHubs({
int limit = defaultHubPreviewLimit,
bool includePlaybackHubs = true,
HubFetchDiagnostics? diagnostics,
}) async => hubs;
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
/// Hub client that holds each fetch open until [release], so a test can act
/// while a Discover load pass is genuinely in flight.
class _GatedHubsFakeClient implements MediaServerClient {
_GatedHubsFakeClient({required this.hubs});
final List<MediaHub> hubs;
int hubCalls = 0;
final _gates = <Completer<List<MediaHub>>>[];
void release() {
for (final gate in _gates.where((g) => !g.isCompleted)) {
gate.complete(hubs);
}
}
@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<List<MediaItem>> fetchContinueWatching({int? count = 20}) async => const [];
@override
Future<List<MediaLibrary>> fetchLibraries() async => const [];
@override
Future<List<MediaHub>> fetchGlobalHubs({
int limit = defaultHubPreviewLimit,
bool includePlaybackHubs = true,
HubFetchDiagnostics? diagnostics,
}) {
hubCalls++;
final gate = Completer<List<MediaHub>>();
_gates.add(gate);
return gate.future;
}
/// Reached by `MultiServerManager.dispose()` via `_closeClientGracefully`.
/// Releases any gate still open so teardown cannot leave a fetch hanging.
@override
void close() => release();
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _FakeProfileRegistry extends ProfileRegistry {
_FakeProfileRegistry(super.db);
@override
Stream<List<Profile>> watchProfiles() => Stream.value(const []);
@override
Future<List<Profile>> list() async => const [];
}
class _FakeConnectionRegistry extends ConnectionRegistry {
_FakeConnectionRegistry(super.db);
@override
Stream<List<Connection>> watchConnections() => Stream.value(const []);
@override
Future<List<Connection>> list() async => const [];
}
class _FakeProfileConnectionRegistry extends ProfileConnectionRegistry {
_FakeProfileConnectionRegistry(super.db);
@override
Stream<List<ProfileConnection>> watchAll() => Stream.value(const []);
}