Grid Spacing (Settings > Appearance) only changed the library grid. The home, explore, detail and library-recommended rows were byte-identical across Tight/Normal/Spacious because #2083 deliberately opted hub-row cell packing out of the setting and hard-coded a 2px pad per card, and the TV home rail never read the setting at all. Treat a hub row as one row of the grid. MediaGridDelegate.cellWidth packs row cells with the same gutter MediaGridGeometry uses, so a row and the "see all" grid behind it render the same card at every setting. HubSection renders max(4, gap) between cards - Tight keeps its historical 4px so nothing moves on update, Normal/Spacious render the grid's 6/12px gutter - and feeds the same gap into its D-pad item extent and card memo epoch. TvBrowseRail threads GridSpacing through metricsForHub, maxActiveRailHeight and estimateHeight; non-full-card rails use the setting scaled with the rest of the rail metrics, full-card rails keep their own scale-derived gutter like full-bleed grids. The spotlight and detail rail height reservations pass the setting too so reserved heights track the narrower cards. Both surfaces watch the pref, so rows re-lay out live. close #2226
494 lines
17 KiB
Dart
494 lines
17 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:material_symbols_icons/symbols.dart';
|
|
import 'package:plezy/focus/input_mode_tracker.dart';
|
|
import 'package:plezy/focus/locked_hub_controller.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/services/settings_service.dart';
|
|
import 'package:plezy/theme/mono_theme.dart';
|
|
import 'package:plezy/utils/platform_detector.dart';
|
|
import 'package:plezy/widgets/media_card.dart';
|
|
import 'package:plezy/widgets/hub_section.dart';
|
|
import 'package:plezy/utils/layout_constants.dart';
|
|
import 'package:plezy/widgets/media_grid_delegate.dart';
|
|
import 'package:plezy/widgets/tv_browse_rail.dart';
|
|
|
|
import '../test_helpers/media_items.dart';
|
|
import '../test_helpers/prefs.dart';
|
|
|
|
void main() {
|
|
TestWidgetsFlutterBinding.ensureInitialized();
|
|
|
|
setUp(() async {
|
|
LocaleSettings.setLocaleSync(AppLocale.en);
|
|
resetSharedPreferencesForTest();
|
|
SettingsService.resetForTesting();
|
|
await SettingsService.getInstance();
|
|
});
|
|
|
|
tearDown(() {
|
|
TvDetectionService.debugSetAppleTVOverride(null);
|
|
});
|
|
|
|
testWidgets('custom item callbacks own pointer actions', (tester) async {
|
|
final item = testMediaItem(
|
|
id: 'pointer_item',
|
|
backend: MediaBackend.plex,
|
|
kind: MediaKind.movie,
|
|
title: 'Pointer Movie',
|
|
);
|
|
MediaItem? tappedItem;
|
|
MediaItem? longPressedItem;
|
|
|
|
await tester.pumpWidget(
|
|
_TestApp(
|
|
child: HubSection(
|
|
hub: _hubWith(item),
|
|
focusMemory: HubFocusMemory(),
|
|
icon: Symbols.live_tv_rounded,
|
|
onItemTap: (value) => tappedItem = value,
|
|
onItemLongPress: (value) => longPressedItem = value,
|
|
),
|
|
),
|
|
);
|
|
|
|
await tester.tap(find.text('Pointer Movie'));
|
|
expect(tappedItem, same(item));
|
|
|
|
await tester.longPress(find.text('Pointer Movie'));
|
|
expect(longPressedItem, same(item));
|
|
});
|
|
|
|
testWidgets('custom item callbacks own D-pad actions', (tester) async {
|
|
final item = testMediaItem(
|
|
id: 'dpad_item',
|
|
backend: MediaBackend.plex,
|
|
kind: MediaKind.movie,
|
|
title: 'D-pad Movie',
|
|
);
|
|
final hubKey = GlobalKey<HubSectionState>();
|
|
MediaItem? tappedItem;
|
|
MediaItem? longPressedItem;
|
|
|
|
await tester.pumpWidget(
|
|
InputModeTracker(
|
|
child: _TestApp(
|
|
child: HubSection(
|
|
key: hubKey,
|
|
hub: _hubWith(item),
|
|
focusMemory: HubFocusMemory(),
|
|
icon: Symbols.live_tv_rounded,
|
|
onItemTap: (value) => tappedItem = value,
|
|
onItemLongPress: (value) => longPressedItem = value,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
hubKey.currentState!.requestFocusAt(0);
|
|
await tester.pump();
|
|
|
|
await tester.sendKeyEvent(LogicalKeyboardKey.select);
|
|
expect(tappedItem, same(item));
|
|
|
|
await tester.sendKeyEvent(LogicalKeyboardKey.contextMenu);
|
|
expect(longPressedItem, same(item));
|
|
});
|
|
|
|
testWidgets('programmatic hub focus scrolls the page only in keyboard mode', (tester) async {
|
|
// Regression: switching to the Explore tab hands focus to the first hub;
|
|
// in touch mode that must not scroll the page (it hid the app bar and
|
|
// search field). Keyboard/D-pad focus keeps scrolling the hub into view.
|
|
final item = testMediaItem(
|
|
id: 'scroll_item',
|
|
backend: MediaBackend.plex,
|
|
kind: MediaKind.movie,
|
|
title: 'Scroll Movie',
|
|
);
|
|
final hubKey = GlobalKey<HubSectionState>();
|
|
final scrollController = ScrollController();
|
|
addTearDown(scrollController.dispose);
|
|
|
|
await tester.pumpWidget(
|
|
InputModeTracker(
|
|
child: MaterialApp(
|
|
theme: monoTheme(dark: true),
|
|
home: Scaffold(
|
|
body: SingleChildScrollView(
|
|
controller: scrollController,
|
|
child: Column(
|
|
children: [
|
|
const SizedBox(height: 900),
|
|
HubSection(
|
|
key: hubKey,
|
|
hub: _hubWith(item),
|
|
focusMemory: HubFocusMemory(),
|
|
icon: Symbols.live_tv_rounded,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
hubKey.currentState!.requestFocusFromMemory();
|
|
await tester.pumpAndSettle();
|
|
expect(scrollController.offset, 0, reason: 'touch-mode programmatic focus must not scroll the page');
|
|
|
|
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
|
|
expect(InputModeTracker.currentMode, InputMode.keyboard, reason: 'arrow key must enter keyboard mode');
|
|
hubKey.currentState!.requestFocusFromMemory();
|
|
await tester.pumpAndSettle();
|
|
expect(scrollController.offset, greaterThan(0), reason: 'keyboard-mode focus scrolls the hub into view');
|
|
});
|
|
|
|
testWidgets('grid poster override uses dense 2:3 TV geometry', (tester) async {
|
|
TvDetectionService.debugSetAppleTVOverride(true);
|
|
final item = testMediaItem(
|
|
id: 'poster_episode',
|
|
backend: MediaBackend.plex,
|
|
kind: MediaKind.episode,
|
|
title: 'Poster Episode',
|
|
parentIndex: 1,
|
|
index: 2,
|
|
thumbPath: '/episode-thumb.jpg',
|
|
grandparentThumbPath: '/series-poster.jpg',
|
|
);
|
|
|
|
await tester.pumpWidget(
|
|
_TestApp(
|
|
child: HubSection(
|
|
hub: _hubWith(item),
|
|
focusMemory: HubFocusMemory(),
|
|
icon: Symbols.live_tv_rounded,
|
|
cardSizing: HubCardSizing.grid,
|
|
episodePosterModeOverride: EpisodePosterMode.seriesPoster,
|
|
),
|
|
),
|
|
);
|
|
|
|
final mediaCard = tester.widget<MediaCard>(find.byType(MediaCard));
|
|
expect(mediaCard.episodePosterModeOverride, EpisodePosterMode.seriesPoster);
|
|
|
|
final poster = find.descendant(of: find.byType(MediaCard), matching: find.byType(ClipRRect)).first;
|
|
final posterSize = tester.getSize(poster);
|
|
expect(posterSize.height / posterSize.width, closeTo(1.5, 0.001));
|
|
|
|
final outerPadding = tester.widget<Padding>(
|
|
find.descendant(of: find.byType(HubSection), matching: find.byType(Padding)).first,
|
|
);
|
|
expect(outerPadding.padding.resolve(TextDirection.ltr).bottom, 0);
|
|
});
|
|
|
|
testWidgets('clip-only hub keeps 16:9 cards in a poster episode mode', (tester) async {
|
|
// Clips (home videos) are wide in every mode; only episode/mixed hubs
|
|
// should fold the poster preference back to 2:3 (#2036).
|
|
final item = testMediaItem(
|
|
id: 'home_video',
|
|
backend: MediaBackend.plex,
|
|
kind: MediaKind.clip,
|
|
title: 'Home Video',
|
|
thumbPath: '/video-frame.jpg',
|
|
);
|
|
|
|
await tester.pumpWidget(
|
|
_TestApp(
|
|
child: HubSection(
|
|
hub: _hubWith(item),
|
|
focusMemory: HubFocusMemory(),
|
|
icon: Symbols.movie_rounded,
|
|
episodePosterModeOverride: EpisodePosterMode.seriesPoster,
|
|
),
|
|
),
|
|
);
|
|
|
|
final poster = find.descendant(of: find.byType(MediaCard), matching: find.byType(ClipRRect)).first;
|
|
final posterSize = tester.getSize(poster);
|
|
expect(posterSize.width / posterSize.height, closeTo(16 / 9, 0.001));
|
|
});
|
|
|
|
testWidgets('wide hub row cards match the episode grid cell at equal width', (tester) async {
|
|
// One widening scheme (#2039 plan item 3): the row adopts the grid's
|
|
// packed wide cell (1.8x extent before the ceil), never poster cell x 1.5.
|
|
// At 480dp / density 3 the schemes diverge: 240dp vs the old 180dp.
|
|
tester.view.physicalSize = const Size(1440, 2400);
|
|
tester.view.devicePixelRatio = 3.0;
|
|
addTearDown(tester.view.reset);
|
|
|
|
final item = testMediaItem(
|
|
id: 'clip_wide',
|
|
backend: MediaBackend.plex,
|
|
kind: MediaKind.clip,
|
|
title: 'Home Video',
|
|
thumbPath: '/video-frame.jpg',
|
|
);
|
|
|
|
await tester.pumpWidget(
|
|
_TestApp(
|
|
child: HubSection(hub: _hubWith(item), focusMemory: HubFocusMemory(), icon: Symbols.movie_rounded),
|
|
),
|
|
);
|
|
|
|
final geometry = MediaGridGeometry.resolve(
|
|
context: tester.element(find.byType(HubSection)),
|
|
crossAxisExtent: 480,
|
|
density: LibraryDensity.defaultValue,
|
|
useWideAspectRatio: true,
|
|
);
|
|
final mediaCard = tester.widget<MediaCard>(find.byType(MediaCard));
|
|
expect(mediaCard.width, geometry.itemWidth);
|
|
expect(mediaCard.width, 240);
|
|
});
|
|
|
|
testWidgets('hub row cards follow the grid-spacing setting and re-lay out live', (tester) async {
|
|
// #2226: the home rows honour the same setting as the library grid. A
|
|
// row is one row of the grid: its card is the grid's packed cell, and
|
|
// neighbours sit the grid gutter apart — except Tight, where the row
|
|
// keeps its historical 4px (2px per side) so nothing moves on update.
|
|
tester.view.physicalSize = const Size(1440, 2400);
|
|
tester.view.devicePixelRatio = 3.0;
|
|
addTearDown(tester.view.reset);
|
|
|
|
final items = [
|
|
for (var i = 0; i < 3; i++)
|
|
testMediaItem(id: 'movie_$i', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie $i'),
|
|
];
|
|
final hub = MediaHub(id: 'movies', title: 'Movies', type: 'movie', items: items, size: items.length);
|
|
|
|
await tester.pumpWidget(
|
|
_TestApp(
|
|
child: HubSection(hub: hub, focusMemory: HubFocusMemory(), icon: Symbols.movie_rounded),
|
|
),
|
|
);
|
|
|
|
double cardPitch() {
|
|
final cards = find.byType(MediaCard);
|
|
return tester.getTopLeft(cards.at(1)).dx - tester.getTopLeft(cards.at(0)).dx;
|
|
}
|
|
|
|
for (final spacing in GridSpacing.values) {
|
|
await SettingsService.instance.write(SettingsService.gridSpacing, spacing);
|
|
await tester.pump();
|
|
|
|
final geometry = MediaGridGeometry.resolve(
|
|
context: tester.element(find.byType(HubSection)),
|
|
crossAxisExtent: 480,
|
|
density: LibraryDensity.defaultValue,
|
|
);
|
|
final card = tester.widget<MediaCard>(find.byType(MediaCard).first);
|
|
expect(card.width, geometry.itemWidth, reason: '$spacing: row card must be the grid cell');
|
|
|
|
final expectedGap = spacing == GridSpacing.tight ? 4.0 : spacing.gridGap;
|
|
expect(cardPitch(), closeTo(card.width! + expectedGap, 0.01), reason: '$spacing: card pitch');
|
|
}
|
|
});
|
|
|
|
testWidgets('TV shelf wide cards use the rail wide formula, not a tall-card multiplier', (tester) async {
|
|
TvDetectionService.debugSetAppleTVOverride(true);
|
|
tester.view.physicalSize = const Size(1920, 1080);
|
|
tester.view.devicePixelRatio = 2.0;
|
|
addTearDown(tester.view.reset);
|
|
|
|
final item = testMediaItem(
|
|
id: 'clip_wide_tv',
|
|
backend: MediaBackend.plex,
|
|
kind: MediaKind.clip,
|
|
title: 'Home Video',
|
|
thumbPath: '/video-frame.jpg',
|
|
);
|
|
|
|
await tester.pumpWidget(
|
|
_TestApp(
|
|
child: HubSection(hub: _hubWith(item), focusMemory: HubFocusMemory(), icon: Symbols.movie_rounded),
|
|
),
|
|
);
|
|
|
|
double expectedFor(GridSpacing spacing) => TvBrowseRailLayout.cardWidthFor(
|
|
availableWidth: 960,
|
|
density: LibraryDensity.defaultValue,
|
|
useWideLayout: true,
|
|
scale: TvLayoutConstants.scaleForSize(const Size(960, 540)),
|
|
horizontalPadding: TvLayoutConstants.shelfHorizontalInset * 2,
|
|
itemGap: spacing.gridGap,
|
|
);
|
|
expect(tester.widget<MediaCard>(find.byType(MediaCard)).width, expectedFor(GridSpacing.tight));
|
|
|
|
// The shelf fits its cards around the grid-spacing gutter too (#2226).
|
|
await SettingsService.instance.write(SettingsService.gridSpacing, GridSpacing.spacious);
|
|
await tester.pump();
|
|
expect(expectedFor(GridSpacing.spacious), lessThan(expectedFor(GridSpacing.tight)));
|
|
expect(tester.widget<MediaCard>(find.byType(MediaCard)).width, expectedFor(GridSpacing.spacious));
|
|
});
|
|
|
|
testWidgets('shows a provider result count in the existing hub header only when supplied', (tester) async {
|
|
final item = testMediaItem(
|
|
id: 'counted_item',
|
|
backend: MediaBackend.plex,
|
|
kind: MediaKind.movie,
|
|
title: 'Counted Movie',
|
|
);
|
|
final hub = MediaHub(id: 'counted_hub', title: 'Popular', type: 'mixed', items: [item], size: 237, more: true);
|
|
|
|
await tester.pumpWidget(
|
|
_TestApp(
|
|
child: HubSection(hub: hub, focusMemory: HubFocusMemory(), icon: Symbols.movie_rounded),
|
|
),
|
|
);
|
|
expect(find.text(t.explore.totalResults(n: 237)), findsNothing);
|
|
|
|
await tester.pumpWidget(
|
|
_TestApp(
|
|
child: HubSection(hub: hub, focusMemory: HubFocusMemory(), icon: Symbols.movie_rounded, totalResults: 237),
|
|
),
|
|
);
|
|
expect(find.text(t.explore.totalResults(n: 237)), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('restores within one owner but resets for a fresh owner', (tester) async {
|
|
final items = [
|
|
for (var index = 0; index < 3; index++)
|
|
testMediaItem(id: 'item_$index', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Item $index'),
|
|
];
|
|
MediaHub hub(String id) => MediaHub(id: id, title: id, type: 'movie', items: items, size: items.length);
|
|
|
|
final ownerA = HubFocusMemory();
|
|
final ownerB = HubFocusMemory();
|
|
String? focusedItemId;
|
|
|
|
Future<void> mount({
|
|
required HubFocusMemory owner,
|
|
required String hubId,
|
|
required GlobalKey<HubSectionState> key,
|
|
}) async {
|
|
await tester.pumpWidget(
|
|
_TestApp(
|
|
child: HubSection(
|
|
key: key,
|
|
hub: hub(hubId),
|
|
focusMemory: owner,
|
|
icon: Symbols.movie_rounded,
|
|
onFocusedItemChanged: (item) => focusedItemId = item.id,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
final firstMountKey = GlobalKey<HubSectionState>();
|
|
await mount(owner: ownerA, hubId: 'detail_episodes', key: firstMountKey);
|
|
firstMountKey.currentState!.requestFocusAt(2);
|
|
await tester.pumpAndSettle();
|
|
expect(focusedItemId, 'item_2');
|
|
|
|
await tester.pumpWidget(const SizedBox.shrink());
|
|
await tester.pump();
|
|
|
|
final remountKey = GlobalKey<HubSectionState>();
|
|
await mount(owner: ownerA, hubId: 'detail_episodes', key: remountKey);
|
|
remountKey.currentState!.requestFocusFromMemory();
|
|
await tester.pumpAndSettle();
|
|
expect(focusedItemId, 'item_2');
|
|
|
|
await tester.pumpWidget(const SizedBox.shrink());
|
|
await tester.pump();
|
|
|
|
final secondHubKey = GlobalKey<HubSectionState>();
|
|
await mount(owner: ownerA, hubId: 'detail_extras', key: secondHubKey);
|
|
secondHubKey.currentState!.requestFocusFromMemory();
|
|
await tester.pumpAndSettle();
|
|
expect(focusedItemId, 'item_2');
|
|
|
|
await tester.pumpWidget(const SizedBox.shrink());
|
|
await tester.pump();
|
|
|
|
final freshOwnerKey = GlobalKey<HubSectionState>();
|
|
await mount(owner: ownerB, hubId: 'detail_episodes', key: freshOwnerKey);
|
|
freshOwnerKey.currentState!.requestFocusFromMemory();
|
|
await tester.pumpAndSettle();
|
|
expect(focusedItemId, 'item_0');
|
|
});
|
|
|
|
testWidgets('visual focus follows the played item when the hub reorders', (tester) async {
|
|
final items = [
|
|
for (var index = 0; index < 3; index++)
|
|
testMediaItem(id: 'item_$index', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Item $index'),
|
|
];
|
|
MediaHub hub(List<MediaItem> hubItems) => MediaHub(
|
|
id: 'continue_watching',
|
|
title: 'Continue Watching',
|
|
type: 'mixed',
|
|
items: hubItems,
|
|
size: hubItems.length,
|
|
);
|
|
|
|
final memory = HubFocusMemory();
|
|
final hubKey = GlobalKey<HubSectionState>();
|
|
String? focusedItemId;
|
|
|
|
Future<void> mount(List<MediaItem> hubItems) async {
|
|
await tester.pumpWidget(
|
|
InputModeTracker(
|
|
child: _TestApp(
|
|
child: HubSection(
|
|
key: hubKey,
|
|
hub: hub(hubItems),
|
|
focusMemory: memory,
|
|
icon: Symbols.play_circle_rounded,
|
|
isInContinueWatching: true,
|
|
onFocusedItemChanged: (item) => focusedItemId = item.id,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
await mount(items);
|
|
hubKey.currentState!.requestFocusAt(1);
|
|
await tester.pumpAndSettle();
|
|
expect(focusedItemId, 'item_1');
|
|
|
|
// Playback progress moved the played item to the front (#1987).
|
|
await mount([items[1], items[0], items[2]]);
|
|
await tester.pumpAndSettle();
|
|
|
|
// Live focus followed to index 0: moving right lands on the neighbor in
|
|
// the new order, not two past the stale position.
|
|
await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowRight);
|
|
await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowRight);
|
|
await tester.pumpAndSettle();
|
|
expect(focusedItemId, 'item_0');
|
|
|
|
// The remembered position follows a reorder too: memory now points at
|
|
// item_0 (index 1), which the next reorder moves back to index 0.
|
|
await mount([items[0], items[1], items[2]]);
|
|
await tester.pumpAndSettle();
|
|
hubKey.currentState!.requestFocusFromMemory();
|
|
await tester.pumpAndSettle();
|
|
expect(focusedItemId, 'item_0');
|
|
});
|
|
}
|
|
|
|
MediaHub _hubWith(MediaItem item) {
|
|
return MediaHub(id: 'live_tv_hub', title: 'Live TV', type: 'mixed', items: [item], size: 1, serverId: item.serverId);
|
|
}
|
|
|
|
class _TestApp extends StatelessWidget {
|
|
final Widget child;
|
|
|
|
const _TestApp({required this.child});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return MaterialApp(
|
|
theme: monoTheme(dark: true),
|
|
home: Scaffold(body: ListView(children: [child])),
|
|
);
|
|
}
|
|
}
|