The clock renders through the existing formatClockTime helper driven by MediaQuery.alwaysUse24HourFormatOf, so it follows the OS 12/24-hour setting instead of introducing an app preference. It re-arms a one-shot timer onto each wall-clock minute boundary rather than polling, and resyncs on resume because a suspended process runs no timers. The player header is shared by the mobile and desktop/TV controls, so one insertion point covers every form factor: the player is fullscreen everywhere, so it never has an OS clock to defer to. Home is the exception and only gets one on TV, where a leanback app hides the system clock; a phone status bar and a desktop menu bar already show the time.
858 lines
34 KiB
Dart
858 lines
34 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: () {},
|
|
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<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: () {},
|
|
focusContent: () {},
|
|
isSidebarFocused: false,
|
|
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('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: () {},
|
|
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<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: () {},
|
|
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());
|
|
});
|
|
|
|
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: () {},
|
|
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 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 []);
|
|
}
|