Files
plezy/test/widgets/tv_browse_rail_test.dart
edde746 a56b9a3dfb Merge the deduplication and dead-code removal pass
Consolidates duplicated logic behind shared implementations — paginated
grid tabs, focus chrome, cached remote stores, sheet selection columns,
the server artifact store and a test fixture layer — and removes code
that had become unreachable. Net reduction of about 5,500 lines with no
behaviour change.

Where a fix had landed separately in code that moved into a shared
helper, the fix was re-applied inside the helper rather than left behind
in the copy that went away.
2026-07-26 19:41:23 +02:00

2241 lines
82 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter/semantics.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/focus/dpad_navigator.dart';
import 'package:plezy/focus/input_mode_tracker.dart';
import 'package:plezy/focus/locked_hub_controller.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/providers/multi_server_provider.dart';
import 'package:plezy/services/device_performance.dart';
import 'package:plezy/services/multi_server_manager.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/animated_dim_scrim.dart';
import 'package:plezy/widgets/media_card.dart';
import 'package:plezy/widgets/rasterized_gradient.dart';
import 'package:plezy/widgets/side_navigation_rail.dart';
import 'package:plezy/widgets/tv_browse_rail.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();
group('TvBrowseRailLayout', () {
test('density changes card width', () {
final item = testMediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie');
final hub = MediaHub(id: 'hub_1', title: 'Movies', type: 'movie', items: [item], size: 1);
final compact = TvBrowseRailLayout.metricsForHub(
hub: hub,
availableWidth: 1040,
density: LibraryDensity.min,
episodePosterMode: EpisodePosterMode.seriesPoster,
scale: 0.85,
);
final comfortable = TvBrowseRailLayout.metricsForHub(
hub: hub,
availableWidth: 1040,
density: LibraryDensity.max,
episodePosterMode: EpisodePosterMode.seriesPoster,
scale: 0.85,
);
expect(comfortable.cardWidth, greaterThan(compact.cardWidth));
expect(comfortable.posterWidth, greaterThan(compact.posterWidth));
});
test('detail episode hubs can force episode thumbnails', () {
final episode = testMediaItem(
id: 'episode_1',
backend: MediaBackend.plex,
kind: MediaKind.episode,
title: 'Episode 1',
thumbPath: '/episode-thumb',
grandparentThumbPath: '/show-poster',
);
final hub = MediaHub(id: 'detail_season_0', title: 'Season 1', type: 'episode', items: [episode], size: 1);
final defaultLayout = TvBrowseRailLayout.metricsForHub(
hub: hub,
availableWidth: 1040,
density: LibraryDensity.defaultValue,
episodePosterMode: EpisodePosterMode.seriesPoster,
scale: 0.85,
);
final forcedLayout = TvBrowseRailLayout.metricsForHub(
hub: hub,
availableWidth: 1040,
density: LibraryDensity.defaultValue,
episodePosterMode: EpisodePosterMode.episodeThumbnail,
scale: 0.85,
);
final compactForcedLayout = TvBrowseRailLayout.metricsForHub(
hub: hub,
availableWidth: 1040,
density: LibraryDensity.defaultValue,
episodePosterMode: EpisodePosterMode.episodeThumbnail,
scale: 0.85,
widePosterScale: TvBrowseRailLayout.compactEpisodeThumbnailScale,
);
expect(defaultLayout.useWideLayout, isFalse);
expect(forcedLayout.useWideLayout, isTrue);
expect(forcedLayout.posterHeight, lessThan(defaultLayout.posterHeight));
expect(compactForcedLayout.useWideLayout, isTrue);
expect(compactForcedLayout.cardWidth, lessThan(forcedLayout.cardWidth));
expect(compactForcedLayout.posterHeight, lessThan(forcedLayout.posterHeight));
});
test('estimated rail height is stable across mixed hub heights', () {
final movie = testMediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie');
final episode = testMediaItem(
id: 'episode_1',
backend: MediaBackend.plex,
kind: MediaKind.episode,
title: 'Episode 1',
thumbPath: '/episode-thumb',
);
final posterHub = MediaHub(id: 'movies', title: 'Movies', type: 'movie', items: [movie], size: 1);
final wideHub = MediaHub(id: 'episodes', title: 'Episodes', type: 'episode', items: [episode], size: 1);
const size = Size(1280, 720);
final scale = TvBrowseRailLayout.scaleForSize(size);
final availableWidth = size.width - TvBrowseRailLayout.horizontalInsetForScale(scale);
final posterMetrics = TvBrowseRailLayout.metricsForHub(
hub: posterHub,
availableWidth: availableWidth,
density: LibraryDensity.max,
episodePosterMode: EpisodePosterMode.episodeThumbnail,
scale: scale,
);
final wideMetrics = TvBrowseRailLayout.metricsForHub(
hub: wideHub,
availableWidth: availableWidth,
density: LibraryDensity.max,
episodePosterMode: EpisodePosterMode.episodeThumbnail,
scale: scale,
);
final maxHeight = TvBrowseRailLayout.maxActiveRailHeight(
hubs: [wideHub, posterHub],
availableWidth: availableWidth,
density: LibraryDensity.max,
episodePosterMode: EpisodePosterMode.episodeThumbnail,
scale: scale,
);
final estimate = TvBrowseRailLayout.estimateHeight(
size: size,
hubs: [wideHub, posterHub],
density: LibraryDensity.max,
episodePosterMode: EpisodePosterMode.episodeThumbnail,
);
final posterSectionHeight = TvBrowseRailLayout.hubSectionHeightFor(
scale: scale,
activeRailHeight: posterMetrics.height,
);
final expectedPosterEstimate =
TvBrowseRailLayout.railTopPaddingForScale(scale) +
TvBrowseRailLayout.viewportHeightFor(hubCount: 2, scale: scale, sectionHeight: posterSectionHeight) +
TvBrowseRailLayout.railBottomPaddingForScale(scale);
expect(posterMetrics.height, greaterThan(wideMetrics.height));
expect(maxHeight, posterMetrics.height);
expect(estimate, closeTo(expectedPosterEstimate, 0.001));
expect(
estimate,
TvBrowseRailLayout.estimateHeight(
size: size,
hubs: [posterHub, wideHub],
density: LibraryDensity.max,
episodePosterMode: EpisodePosterMode.episodeThumbnail,
),
);
});
test('compact tall poster scale reduces browse rail height', () {
final movie = testMediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie');
final hub = MediaHub(id: 'movies', title: 'Movies', type: 'movie', items: [movie], size: 1);
const size = Size(1280, 720);
final defaultHeight = TvBrowseRailLayout.estimateHeight(
size: size,
hubs: [hub],
density: LibraryDensity.max,
episodePosterMode: EpisodePosterMode.seriesPoster,
);
final compactHeight = TvBrowseRailLayout.estimateHeight(
size: size,
hubs: [hub],
density: LibraryDensity.max,
episodePosterMode: EpisodePosterMode.seriesPoster,
tallPosterScale: TvBrowseRailLayout.compactTallPosterScale,
);
expect(compactHeight, lessThan(defaultHeight));
});
test('empty episode thumbnail hubs reserve thumbnail row height', () {
final episode = testMediaItem(
id: 'episode_1',
backend: MediaBackend.plex,
kind: MediaKind.episode,
title: 'Episode 1',
thumbPath: '/episode-thumb',
);
const emptyHub = MediaHub(id: 'detail_season_0', title: 'Season 1', type: 'episode', items: <MediaItem>[]);
final loadedHub = MediaHub(id: emptyHub.id, title: emptyHub.title, type: emptyHub.type, items: [episode]);
const size = Size(1280, 720);
final scale = TvBrowseRailLayout.scaleForSize(size);
final availableWidth = size.width - TvBrowseRailLayout.horizontalInsetForScale(scale);
final emptyMetrics = TvBrowseRailLayout.metricsForHub(
hub: emptyHub,
availableWidth: availableWidth,
density: LibraryDensity.defaultValue,
episodePosterMode: EpisodePosterMode.episodeThumbnail,
scale: scale,
tallPosterScale: TvBrowseRailLayout.compactTallPosterScale,
widePosterScale: TvBrowseRailLayout.compactEpisodeThumbnailScale,
);
final loadedMetrics = TvBrowseRailLayout.metricsForHub(
hub: loadedHub,
availableWidth: availableWidth,
density: LibraryDensity.defaultValue,
episodePosterMode: EpisodePosterMode.episodeThumbnail,
scale: scale,
tallPosterScale: TvBrowseRailLayout.compactTallPosterScale,
widePosterScale: TvBrowseRailLayout.compactEpisodeThumbnailScale,
);
expect(emptyMetrics.useWideLayout, isTrue);
expect(emptyMetrics.posterHeight, closeTo(loadedMetrics.posterHeight, 0.001));
expect(emptyMetrics.height, closeTo(loadedMetrics.height, 0.001));
});
test('full card layout removes label reserve and preserves episode poster mode', () {
final episode = testMediaItem(
id: 'episode_1',
backend: MediaBackend.plex,
kind: MediaKind.episode,
title: 'Episode 1',
thumbPath: '/episode-thumb',
grandparentThumbPath: '/show-poster',
);
final hub = MediaHub(id: 'episodes', title: 'Episodes', type: 'episode', items: [episode], size: 1);
final detailed = TvBrowseRailLayout.metricsForHub(
hub: hub,
availableWidth: 1040,
density: LibraryDensity.defaultValue,
episodePosterMode: EpisodePosterMode.episodeThumbnail,
scale: 0.85,
);
final full = TvBrowseRailLayout.metricsForHub(
hub: hub,
availableWidth: 1040,
density: LibraryDensity.defaultValue,
episodePosterMode: EpisodePosterMode.episodeThumbnail,
scale: 0.85,
fullCardLayout: true,
);
expect(full.height, lessThan(detailed.height));
expect(full.useWideLayout, isTrue);
expect(detailed.itemGap, 0);
expect(full.itemGap, closeTo(12 * 0.85, 0.001));
expect(full.posterHeight, closeTo(full.posterWidth * 9 / 16, 0.001));
});
test('compact wide poster scale makes clips match compact episode thumbnails', () {
final episode = testMediaItem(
id: 'episode_1',
backend: MediaBackend.plex,
kind: MediaKind.episode,
title: 'Episode 1',
thumbPath: '/episode-thumb',
);
final clip = testMediaItem(
id: 'clip_1',
backend: MediaBackend.plex,
kind: MediaKind.clip,
title: 'Trailer',
thumbPath: '/trailer-thumb',
);
final episodeHub = MediaHub(id: 'detail_season_0', title: 'Season 1', type: 'episode', items: [episode], size: 1);
final clipHub = MediaHub(id: 'detail_extras', title: 'Extras', type: 'clip', items: [clip], size: 1);
final episodeMetrics = TvBrowseRailLayout.metricsForHub(
hub: episodeHub,
availableWidth: 1040,
density: LibraryDensity.defaultValue,
episodePosterMode: EpisodePosterMode.episodeThumbnail,
scale: 0.85,
widePosterScale: TvBrowseRailLayout.compactEpisodeThumbnailScale,
);
final clipMetrics = TvBrowseRailLayout.metricsForHub(
hub: clipHub,
availableWidth: 1040,
density: LibraryDensity.defaultValue,
episodePosterMode: EpisodePosterMode.seriesPoster,
scale: 0.85,
widePosterScale: TvBrowseRailLayout.compactEpisodeThumbnailScale,
);
expect(clipMetrics.useWideLayout, isTrue);
expect(clipMetrics.cardWidth, closeTo(episodeMetrics.cardWidth, 0.001));
expect(clipMetrics.posterHeight, closeTo(episodeMetrics.posterHeight, 0.001));
});
test('multi-hub estimate reserves next hub peek height', () {
final movie = testMediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie');
final movieHub = MediaHub(id: 'movies', title: 'Movies', type: 'movie', items: [movie], size: 1);
final showHub = MediaHub(id: 'shows', title: 'Shows', type: 'show', items: [movie], size: 1);
const size = Size(1280, 720);
final scale = TvBrowseRailLayout.scaleForSize(size);
final singleHubHeight = TvBrowseRailLayout.estimateHeight(
size: size,
hubs: [movieHub],
density: LibraryDensity.max,
episodePosterMode: EpisodePosterMode.seriesPoster,
);
final multiHubHeight = TvBrowseRailLayout.estimateHeight(
size: size,
hubs: [movieHub, showHub],
density: LibraryDensity.max,
episodePosterMode: EpisodePosterMode.seriesPoster,
);
expect(multiHubHeight - singleHubHeight, closeTo(TvBrowseRailLayout.nextHubPeekHeightForScale(scale), 0.001));
});
});
late HubFocusMemory focusMemory;
setUp(() async {
resetSharedPreferencesForTest();
SettingsService.resetForTesting();
focusMemory = HubFocusMemory();
await SettingsService.getInstance();
});
testWidgets('semantic selection proxy stays fixed while animated rail selection changes', (tester) async {
final semantics = tester.ensureSemantics();
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = const Size(1280, 720);
addTearDown(tester.view.resetDevicePixelRatio);
addTearDown(tester.view.resetPhysicalSize);
final serverManager = MultiServerManager();
final items = [
testMediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'First Movie'),
testMediaItem(id: 'movie_2', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Second Movie'),
];
final hub = MediaHub(id: 'movies', title: 'Movies', type: 'movie', items: items, size: items.length);
String? activatedItemId;
await tester.pumpWidget(
ChangeNotifierProvider<MultiServerProvider>(
create: (_) => testMultiServerProvider(serverManager),
child: InputModeTracker(
child: MaterialApp(
theme: monoTheme(dark: true),
home: Scaffold(
body: SizedBox(
width: 1280,
height: 720,
child: TvBrowseRail(
focusMemory: focusMemory,
hubs: [hub],
autofocus: true,
iconForHub: (_, _) => Icons.movie_rounded,
onActivateItem: (_, item) {
activatedItemId = item.id;
return true;
},
),
),
),
),
),
),
);
await tester.pump();
final proxy = find.bySemanticsIdentifier('tv_browse_rail_selection');
expect(proxy, findsOneWidget);
final initialRect = tester.getRect(proxy);
final initialNode = tester.getSemantics(proxy);
expect(initialNode.label, contains('Movies'));
expect(initialNode.label, contains('First Movie'));
expect(initialNode.getSemanticsData().hasAction(SemanticsAction.tap), isTrue);
expect(tester.widget<Semantics>(proxy).properties.onTap, isNotNull);
await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowRight);
await tester.pump();
final movedNode = tester.getSemantics(proxy);
expect(movedNode.label, contains('Second Movie'));
expect(tester.getRect(proxy), initialRect);
expect(tester.hasRunningAnimations, isTrue);
tester.widget<Semantics>(find.byKey(const ValueKey('tv_browse_rail_semantic_proxy'))).properties.onTap!();
await tester.pump();
expect(activatedItemId, 'movie_2');
await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowRight);
semantics.dispose();
});
testWidgets('low-end snapshot optimization preserves vertical scroll animation', (tester) async {
DevicePerformance.debugReset(autoReduced: true);
addTearDown(DevicePerformance.debugReset);
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = const Size(1280, 720);
addTearDown(tester.view.resetDevicePixelRatio);
addTearDown(tester.view.resetPhysicalSize);
final serverManager = MultiServerManager();
final hubs = List.generate(6, (hubIndex) {
final item = testMediaItem(
id: 'movie_$hubIndex',
backend: MediaBackend.plex,
kind: MediaKind.movie,
title: 'Movie $hubIndex',
);
return MediaHub(id: 'hub_$hubIndex', title: 'Hub $hubIndex', type: 'movie', items: [item], size: 1);
});
await tester.pumpWidget(
ChangeNotifierProvider<MultiServerProvider>(
create: (_) => testMultiServerProvider(serverManager),
child: InputModeTracker(
child: MaterialApp(
theme: monoTheme(dark: true),
home: Scaffold(
body: SizedBox(
width: 1280,
height: 720,
child: TvBrowseRail(
focusMemory: focusMemory,
hubs: hubs,
autofocus: true,
iconForHub: (_, _) => Icons.movie_rounded,
),
),
),
),
),
),
);
await tester.pump();
tester.state<TvBrowseRailState>(find.byType(TvBrowseRail)).requestFocus();
await tester.pump();
final position = _verticalRailPosition(tester);
final initialOffset = position.pixels;
tester.widget<Semantics>(find.byKey(const ValueKey('tv_browse_rail_semantic_proxy'))).properties.onScrollDown!();
await tester.pump();
await tester.pump(const Duration(milliseconds: 80));
await tester.pump(const Duration(milliseconds: 80));
final animatedOffset = position.pixels;
expect(animatedOffset, greaterThan(initialOffset));
expect(tester.hasRunningAnimations, isTrue);
final snapshots = tester.widgetList<SnapshotWidget>(find.byType(SnapshotWidget));
expect(snapshots, isNotEmpty);
expect(snapshots.every((widget) => widget.controller.allowSnapshotting), isTrue);
await tester.pumpAndSettle();
expect(position.pixels, greaterThan(animatedOffset));
expect(snapshots.every((widget) => widget.controller.allowSnapshotting), isFalse);
});
testWidgets('active hub header uses theme foreground in light mode', (tester) async {
final serverManager = MultiServerManager();
final theme = monoTheme(dark: false);
final episode = testMediaItem(
id: 'episode_1',
backend: MediaBackend.plex,
kind: MediaKind.episode,
title: 'Episode 1',
);
final hub = MediaHub(id: 'season_1', title: 'Season 1', type: 'episode', items: [episode], size: 1);
await tester.pumpWidget(
ChangeNotifierProvider<MultiServerProvider>(
create: (_) => testMultiServerProvider(serverManager),
child: MaterialApp(
theme: theme,
home: Scaffold(
body: SizedBox(
width: 1280,
height: 720,
child: TvBrowseRail(focusMemory: focusMemory, hubs: [hub], iconForHub: (_, _) => Icons.tv_rounded),
),
),
),
),
);
await tester.pump();
final headerText = tester.widget<Text>(find.text('Season 1'));
expect(headerText.style?.color, theme.colorScheme.onSurface);
});
testWidgets('two hubs sharing a backend id across servers do not collide on card GlobalKeys', (tester) async {
final serverManager = MultiServerManager();
// Multi-server aggregation: two servers return a hub with the SAME backend
// id ('recently_added'). Per-card GlobalKeys must be qualified by serverId,
// or both rows hand the same GlobalKey<MediaCardState> to different cards
// and finalizeTree throws "Duplicate GlobalKey".
MediaHub hubFor(String serverId) => MediaHub(
id: 'recently_added',
title: 'Recently Added ($serverId)',
type: 'movie',
serverId: serverId,
size: 2,
items: [
testMediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'A', serverId: serverId),
testMediaItem(id: 'movie_2', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'B', serverId: serverId),
],
);
await tester.pumpWidget(
ChangeNotifierProvider<MultiServerProvider>(
create: (_) => testMultiServerProvider(serverManager),
child: MaterialApp(
theme: monoTheme(dark: true),
home: Scaffold(
body: SizedBox(
width: 1280,
height: 720,
child: TvBrowseRail(
focusMemory: focusMemory,
hubs: [hubFor('serverA'), hubFor('serverB')],
iconForHub: (_, _) => Icons.movie_rounded,
),
),
),
),
),
);
await tester.pump();
expect(tester.takeException(), isNull);
});
testWidgets('full card layout hides media text and overlays actor text when enabled', (tester) async {
await SettingsService.instanceOrNull!.write(SettingsService.tvFullCardLayout, true);
final serverManager = MultiServerManager();
final movie = testMediaItem(
id: 'movie_1',
backend: MediaBackend.plex,
kind: MediaKind.movie,
title: 'Hidden Movie',
);
final actor = testMediaItem(
id: 'actor_1',
backend: MediaBackend.plex,
kind: MediaKind.unknown,
title: 'Actor Name',
parentTitle: 'Character Name',
);
final movieHub = MediaHub(id: 'movies', title: 'Movies', type: 'movie', items: [movie], size: 1);
final actorHub = MediaHub(id: 'actors', title: 'Cast', type: 'person', items: [actor], size: 1);
Widget rail(MediaHub hub) {
return ChangeNotifierProvider<MultiServerProvider>(
create: (_) => testMultiServerProvider(serverManager),
child: MaterialApp(
theme: monoTheme(dark: true),
home: Scaffold(
body: SizedBox(
width: 1280,
height: 720,
child: TvBrowseRail(focusMemory: focusMemory, hubs: [hub], iconForHub: (_, _) => Icons.movie_rounded),
),
),
),
);
}
await tester.pumpWidget(rail(movieHub));
await tester.pump();
expect(find.text('Hidden Movie'), findsNothing);
await tester.pumpWidget(rail(actorHub));
await tester.pump();
expect(find.text('Actor Name'), findsOneWidget);
expect(find.text('Character Name'), findsOneWidget);
});
testWidgets('full card focus adds outside ring, local glow, and card image scale', (tester) async {
await SettingsService.instanceOrNull!.write(SettingsService.tvFullCardLayout, true);
TvDetectionService.debugSetAppleTVOverride(true);
tester.view.devicePixelRatio = 1.0;
tester.view.physicalSize = const Size(1280, 720);
addTearDown(() {
TvDetectionService.debugSetAppleTVOverride(null);
tester.view.resetDevicePixelRatio();
tester.view.resetPhysicalSize();
});
final serverManager = MultiServerManager();
final movie = testMediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie');
final hub = MediaHub(id: 'movies', title: 'Movies', type: 'movie', items: [movie], size: 1);
await tester.pumpWidget(
ChangeNotifierProvider<MultiServerProvider>(
create: (_) => testMultiServerProvider(serverManager),
child: InputModeTracker(
child: MaterialApp(
theme: monoTheme(dark: true),
home: Scaffold(
body: SizedBox(
width: 1280,
height: 720,
child: TvBrowseRail(
focusMemory: focusMemory,
hubs: [hub],
autofocus: true,
iconForHub: (_, _) => Icons.movie_rounded,
),
),
),
),
),
),
);
await tester.pump();
final scale = TvBrowseRailLayout.scaleForSize(tester.view.physicalSize / tester.view.devicePixelRatio);
final metrics = TvBrowseRailLayout.metricsForHub(
hub: hub,
availableWidth: 1280 - TvBrowseRailLayout.horizontalInsetForScale(scale),
density: LibraryDensity.defaultValue,
episodePosterMode: EpisodePosterMode.seriesPoster,
scale: scale,
fullCardLayout: true,
);
// The focused card mounts a leader (CompositedTransformTarget); scope to it
// so we measure the focused card's in-card border container.
final cardFinder = find
.descendant(of: find.byType(CompositedTransformTarget), matching: find.byType(AnimatedContainer))
.first;
final borderContainer = tester.widget<AnimatedContainer>(cardFinder);
final border = (borderContainer.foregroundDecoration as BoxDecoration).border as Border;
final cardSize = tester.getSize(cardFinder);
final focusScale = tester.widget<AnimatedScale>(
find.ancestor(of: cardFinder, matching: find.byType(AnimatedScale)).first,
);
// The border stays in-card; the glow now renders in an overlay that follows
// the focused card so it paints above siblings on all sides.
expect(borderContainer.decoration, isNull);
expect(border.top.strokeAlign, BorderSide.strokeAlignOutside);
expect(find.byType(ShaderMask), findsNothing);
expect(find.byType(CompositedTransformTarget), findsOneWidget);
expect(find.byType(CompositedTransformFollower), findsOneWidget);
expect(
find.descendant(of: find.byType(CompositedTransformFollower), matching: find.byType(CustomPaint)),
findsOneWidget,
);
expect(focusScale.scale, closeTo(1.03, 0.0001));
expect(cardSize.width, closeTo(metrics.cardWidth, 0.001));
expect(cardSize.height, closeTo(metrics.posterHeight, 0.001));
});
testWidgets('vertical hub viewport keeps top clipping while switching hubs', (tester) async {
await SettingsService.instanceOrNull!.write(SettingsService.tvFullCardLayout, true);
TvDetectionService.debugSetAppleTVOverride(true);
tester.view.devicePixelRatio = 1.0;
tester.view.physicalSize = const Size(1280, 720);
addTearDown(() {
TvDetectionService.debugSetAppleTVOverride(null);
tester.view.resetDevicePixelRatio();
tester.view.resetPhysicalSize();
});
final serverManager = MultiServerManager();
final firstMovie = testMediaItem(
id: 'movie_1',
backend: MediaBackend.plex,
kind: MediaKind.movie,
title: 'Movie 1',
);
final secondMovie = testMediaItem(
id: 'movie_2',
backend: MediaBackend.plex,
kind: MediaKind.movie,
title: 'Movie 2',
);
final firstHub = MediaHub(id: 'movies_1', title: 'Movies 1', type: 'movie', items: [firstMovie], size: 1);
final secondHub = MediaHub(id: 'movies_2', title: 'Movies 2', type: 'movie', items: [secondMovie], size: 1);
await tester.pumpWidget(
ChangeNotifierProvider<MultiServerProvider>(
create: (_) => testMultiServerProvider(serverManager),
child: InputModeTracker(
child: MaterialApp(
theme: monoTheme(dark: true),
home: Scaffold(
body: SizedBox(
width: 1280,
height: 720,
child: TvBrowseRail(
focusMemory: focusMemory,
hubs: [firstHub, secondHub],
autofocus: true,
iconForHub: (_, _) => Icons.movie_rounded,
),
),
),
),
),
),
);
await tester.pump();
await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowDown);
await tester.pump();
await tester.pump(const Duration(milliseconds: 16));
final scale = TvBrowseRailLayout.scaleForSize(tester.view.physicalSize / tester.view.devicePixelRatio);
final expectedLeftOverflow = TvBrowseRailLayout.horizontalInsetForScale(scale);
final verticalViewportClip = tester
.widgetList<ClipRect>(
find.ancestor(of: find.byKey(const ValueKey('tv_browse_rail_vertical')), matching: find.byType(ClipRect)),
)
.singleWhere((widget) => widget.clipper != null);
final clipRectSize = tester.getSize(find.byWidget(verticalViewportClip));
final clip = verticalViewportClip.clipper!.getClip(clipRectSize);
// The vertical viewport keeps a tight top/bottom clip (the glow is no longer
// clipped here — it renders in the overlay); only the left background bleed
// extends beyond the viewport.
expect(clip.left, closeTo(-expectedLeftOverflow, 0.001));
expect(clip.top, 0);
expect(clip.bottom, closeTo(clipRectSize.height, 0.001));
await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowDown);
await tester.pumpAndSettle();
// The newly focused hub's card still carries the overlay glow.
expect(find.byType(CompositedTransformFollower), findsOneWidget);
});
testWidgets('detailed card focus border hugs the poster, captions outside', (tester) async {
await SettingsService.instanceOrNull!.write(SettingsService.tvFullCardLayout, false);
TvDetectionService.debugSetAppleTVOverride(true);
tester.view.devicePixelRatio = 1.0;
tester.view.physicalSize = const Size(1280, 720);
addTearDown(() {
TvDetectionService.debugSetAppleTVOverride(null);
tester.view.resetDevicePixelRatio();
tester.view.resetPhysicalSize();
});
final serverManager = MultiServerManager();
final movie = testMediaItem(
id: 'movie_1',
backend: MediaBackend.plex,
kind: MediaKind.movie,
title: 'Visible Movie',
year: 2024,
);
final hub = MediaHub(id: 'movies', title: 'Movies', type: 'movie', items: [movie], size: 1);
await tester.pumpWidget(
ChangeNotifierProvider<MultiServerProvider>(
create: (_) => testMultiServerProvider(serverManager),
child: InputModeTracker(
child: MaterialApp(
theme: monoTheme(dark: true),
home: Scaffold(
body: SizedBox(
width: 1280,
height: 720,
child: TvBrowseRail(
focusMemory: focusMemory,
hubs: [hub],
autofocus: true,
iconForHub: (_, _) => Icons.movie_rounded,
),
),
),
),
),
),
);
await tester.pump();
// The border is drawn inside MediaCard around the poster only (#1278);
// the title/year captions render below it, outside the focus rect.
final focusDecoration = find.descendant(
of: find.ancestor(of: find.text('Visible Movie'), matching: find.byType(MediaCard)),
matching: find.byWidgetPredicate((widget) {
if (widget is! AnimatedContainer || widget.foregroundDecoration is! BoxDecoration) return false;
return (widget.foregroundDecoration as BoxDecoration).border is Border;
}),
);
expect(focusDecoration, findsOneWidget);
expect(find.text('2024'), findsOneWidget);
final focusRect = tester.getRect(focusDecoration);
final titleRect = tester.getRect(find.text('Visible Movie'));
final subtitleRect = tester.getRect(find.text('2024'));
expect(focusRect.bottom, lessThanOrEqualTo(titleRect.top));
expect(focusRect.bottom, lessThanOrEqualTo(subtitleRect.top));
// Still a tight ring: the poster fills the card width above the captions.
expect(focusRect.top, lessThan(titleRect.top));
});
testWidgets('view all item uses compact pill focus style', (tester) async {
TvDetectionService.debugSetAppleTVOverride(true);
tester.view.devicePixelRatio = 1.0;
tester.view.physicalSize = const Size(1280, 720);
addTearDown(() {
TvDetectionService.debugSetAppleTVOverride(null);
tester.view.resetDevicePixelRatio();
tester.view.resetPhysicalSize();
});
final serverManager = MultiServerManager();
final movie = testMediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie');
final hub = MediaHub(id: 'movies', title: 'Movies', type: 'movie', items: [movie], size: 2, more: true);
await tester.pumpWidget(
ChangeNotifierProvider<MultiServerProvider>(
create: (_) => testMultiServerProvider(serverManager),
child: InputModeTracker(
child: MaterialApp(
theme: monoTheme(dark: true),
home: Scaffold(
body: SizedBox(
width: 1280,
height: 720,
child: TvBrowseRail(
focusMemory: focusMemory,
hubs: [hub],
autofocus: true,
iconForHub: (_, _) => Icons.movie_rounded,
),
),
),
),
),
),
);
await tester.pump();
tester.state<TvBrowseRailState>(find.byType(TvBrowseRail)).requestFocus();
await tester.pump();
await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowRight);
await tester.pump();
await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowRight);
await tester.pumpAndSettle();
final viewAllText = find.text('View All');
final pill = find.ancestor(of: viewAllText, matching: find.byType(AnimatedContainer));
final scale = TvBrowseRailLayout.scaleForSize(tester.view.physicalSize / tester.view.devicePixelRatio);
expect(viewAllText, findsOneWidget);
expect(pill, findsOneWidget);
final pillWidget = tester.widget<AnimatedContainer>(pill);
final decoration = pillWidget.decoration as BoxDecoration;
final pillSize = tester.getSize(pill);
expect(decoration.border, isNull);
expect(decoration.boxShadow, isNotNull);
expect(pillSize.width, closeTo(TvBrowseRailLayout.viewAllItemWidthForScale(scale), 0.001));
expect(pillSize.width, lessThan(132 * scale));
expect(pillSize.height, closeTo(TvBrowseRailLayout.viewAllPillHeightForScale(scale), 0.001));
});
testWidgets('loading trailing item keeps visible focus style', (tester) async {
TvDetectionService.debugSetAppleTVOverride(true);
tester.view.devicePixelRatio = 1.0;
tester.view.physicalSize = const Size(1280, 720);
addTearDown(() {
TvDetectionService.debugSetAppleTVOverride(null);
tester.view.resetDevicePixelRatio();
tester.view.resetPhysicalSize();
});
final serverManager = MultiServerManager();
final movie = testMediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie');
final hub = MediaHub(id: 'movies', title: 'Movies', type: 'movie', items: [movie], size: 2);
await tester.pumpWidget(
ChangeNotifierProvider<MultiServerProvider>(
create: (_) => testMultiServerProvider(serverManager),
child: MaterialApp(
theme: monoTheme(dark: true),
home: Scaffold(
body: SizedBox(
width: 1280,
height: 720,
child: TvBrowseRail(
focusMemory: focusMemory,
hubs: [hub],
autofocus: true,
iconForHub: (_, _) => Icons.movie_rounded,
trailingForHub: (_) => TvRailTrailing.loading,
),
),
),
),
),
);
await tester.pump();
tester.state<TvBrowseRailState>(find.byType(TvBrowseRail)).requestFocus();
await tester.pump();
await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowRight);
await tester.pump(const Duration(milliseconds: 150));
await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowRight);
await tester.pump();
final spinner = find.byType(CircularProgressIndicator);
final pill = find.ancestor(of: spinner, matching: find.byType(AnimatedContainer));
final decoration = tester.widget<AnimatedContainer>(pill).decoration as BoxDecoration;
expect(spinner, findsOneWidget);
expect(decoration.boxShadow, isNotNull);
});
testWidgets('clamps focused trailing item when trailing state disappears', (tester) async {
TvDetectionService.debugSetAppleTVOverride(true);
tester.view.devicePixelRatio = 1.0;
tester.view.physicalSize = const Size(1280, 720);
addTearDown(() {
TvDetectionService.debugSetAppleTVOverride(null);
tester.view.resetDevicePixelRatio();
tester.view.resetPhysicalSize();
});
final serverManager = MultiServerManager();
final movie = testMediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie');
final hub = MediaHub(id: 'movies', title: 'Movies', type: 'movie', items: [movie], size: 1);
var trailing = TvRailTrailing.loading;
var activations = 0;
late StateSetter setParentState;
await tester.pumpWidget(
ChangeNotifierProvider<MultiServerProvider>(
create: (_) => testMultiServerProvider(serverManager),
child: MaterialApp(
theme: monoTheme(dark: true),
home: Scaffold(
body: StatefulBuilder(
builder: (context, setState) {
setParentState = setState;
return SizedBox(
width: 1280,
height: 720,
child: TvBrowseRail(
focusMemory: focusMemory,
hubs: [hub],
autofocus: true,
iconForHub: (_, _) => Icons.movie_rounded,
trailingForHub: (_) => trailing,
onActivateItem: (_, _) {
activations++;
return Future.value(true);
},
),
);
},
),
),
),
),
);
await tester.pump();
tester.state<TvBrowseRailState>(find.byType(TvBrowseRail)).requestFocus();
await tester.pump();
await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowRight);
await tester.pump(const Duration(milliseconds: 150));
await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowRight);
await tester.pump();
setParentState(() => trailing = TvRailTrailing.none);
await tester.pump();
await tester.sendKeyDownEvent(LogicalKeyboardKey.enter);
await tester.pump();
await tester.sendKeyUpEvent(LogicalKeyboardKey.enter);
await tester.pump();
expect(activations, 1);
});
testWidgets('focused rail dims only inactive hub artwork', (tester) async {
final serverManager = MultiServerManager();
final firstItem = testMediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie 1');
final secondItem = testMediaItem(
id: 'movie_2',
backend: MediaBackend.plex,
kind: MediaKind.movie,
title: 'Movie 2',
);
final firstHub = MediaHub(id: 'hub_1', title: 'First Hub', type: 'movie', items: [firstItem], size: 1);
final secondHub = MediaHub(id: 'hub_2', title: 'Second Hub', type: 'movie', items: [secondItem], size: 1);
await tester.pumpWidget(
ChangeNotifierProvider<MultiServerProvider>(
create: (_) => testMultiServerProvider(serverManager),
child: MaterialApp(
theme: monoTheme(dark: true),
home: Scaffold(
body: SizedBox(
width: 1280,
height: 720,
child: TvBrowseRail(
focusMemory: focusMemory,
hubs: [firstHub, secondHub],
iconForHub: (_, _) => Icons.movie_rounded,
),
),
),
),
),
);
await tester.pump();
tester.state<TvBrowseRailState>(find.byType(TvBrowseRail)).requestFocus();
await tester.pumpAndSettle();
expect(FocusManager.instance.primaryFocus?.debugLabel, 'tv_browse_rail');
final scrims = tester.widgetList<AnimatedDimScrim>(find.byType(AnimatedDimScrim)).toList();
expect(scrims, hasLength(1));
expect(scrims.single.alpha, 0.4);
expect(scrims.single.dimmed, isFalse);
List<MediaCard> cards() => tester.widgetList<MediaCard>(find.byType(MediaCard)).toList();
MediaCard cardFor(MediaItem item) => cards().singleWhere((card) => card.item == item);
final firstArtworkDim = cardFor(firstItem).artworkDim!;
final secondArtworkDim = cardFor(secondItem).artworkDim!;
expect(firstArtworkDim.value, 0);
expect(secondArtworkDim.value, closeTo(0.3, 0.001));
await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowDown);
await tester.pumpAndSettle();
await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowDown);
expect(firstArtworkDim.value, closeTo(0.3, 0.001));
expect(secondArtworkDim.value, 0);
});
testWidgets('selects preferred hub when hubs are inserted asynchronously', (tester) async {
final activeHubIds = <String>[];
Widget buildRail(List<MediaHub> hubs, {String? initialHubId, String? initialItemId, bool autofocus = false}) {
final serverManager = MultiServerManager();
return ChangeNotifierProvider<MultiServerProvider>(
create: (_) => testMultiServerProvider(serverManager),
child: MaterialApp(
theme: monoTheme(dark: true),
home: Scaffold(
body: SizedBox(
width: 1280,
height: 720,
child: TvBrowseRail(
focusMemory: focusMemory,
key: const ValueKey('rail'),
hubs: hubs,
initialHubId: initialHubId,
initialItemId: initialItemId,
autofocus: autofocus,
iconForHub: (_, _) => Icons.tv_rounded,
onActiveHubChanged: (hub, _) => activeHubIds.add(hub.id),
),
),
),
),
);
}
const castHub = MediaHub(id: 'detail_actors', title: 'Cast', type: 'person', items: <MediaItem>[]);
const preferredSeason = MediaHub(id: 'detail_season_1', title: 'Season 2', type: 'episode', items: <MediaItem>[]);
await tester.pumpWidget(buildRail(const [castHub]));
await tester.pump();
await tester.pumpWidget(buildRail(const [preferredSeason, castHub], initialHubId: preferredSeason.id));
await tester.pump();
expect(activeHubIds, containsAllInOrder(['detail_actors', 'detail_season_1']));
expect(activeHubIds.last, 'detail_season_1');
});
testWidgets('selects preferred hub after an earlier update could not find it', (tester) async {
final activeHubIds = <String>[];
Widget buildRail(List<MediaHub> hubs, {String? initialHubId}) {
final serverManager = MultiServerManager();
return ChangeNotifierProvider<MultiServerProvider>(
create: (_) => testMultiServerProvider(serverManager),
child: MaterialApp(
theme: monoTheme(dark: true),
home: Scaffold(
body: SizedBox(
width: 1280,
height: 720,
child: TvBrowseRail(
focusMemory: focusMemory,
key: const ValueKey('rail'),
hubs: hubs,
initialHubId: initialHubId,
iconForHub: (_, _) => Icons.tv_rounded,
onActiveHubChanged: (hub, _) => activeHubIds.add(hub.id),
),
),
),
),
);
}
const castHub = MediaHub(id: 'detail_actors', title: 'Cast', type: 'person', items: <MediaItem>[]);
const episodesHub = MediaHub(id: 'detail_episodes', title: 'Episodes', type: 'episode', items: <MediaItem>[]);
await tester.pumpWidget(buildRail(const [castHub]));
await tester.pump();
await tester.pumpWidget(buildRail(const [castHub], initialHubId: episodesHub.id));
await tester.pump();
await tester.pumpWidget(buildRail(const [episodesHub, castHub], initialHubId: episodesHub.id));
await tester.pump();
expect(activeHubIds, containsAllInOrder(['detail_actors', 'detail_episodes']));
expect(activeHubIds.last, 'detail_episodes');
});
testWidgets('keeps the user selection when a preferred hub arrives after item navigation', (tester) async {
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = const Size(1280, 720);
addTearDown(tester.view.resetDevicePixelRatio);
addTearDown(tester.view.resetPhysicalSize);
final serverManager = MultiServerManager();
final multiServerProvider = testMultiServerProvider(serverManager);
addTearDown(multiServerProvider.dispose);
final recentItems = [
testMediaItem(id: 'recent_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Recently Added 1'),
testMediaItem(id: 'recent_2', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Recently Added 2'),
];
final continueItem = testMediaItem(
id: 'continue_1',
backend: MediaBackend.plex,
kind: MediaKind.movie,
title: 'Continue Watching',
);
final recentHub = MediaHub(
id: 'recently_added',
title: 'Recently Added',
type: 'movie',
items: recentItems,
size: recentItems.length,
);
final continueHub = MediaHub(
id: 'continue_watching',
title: 'Continue Watching',
type: 'mixed',
items: [continueItem],
size: 1,
);
final focusedSelections = <(String, String)>[];
Widget buildRail(List<MediaHub> hubs) {
return ChangeNotifierProvider<MultiServerProvider>.value(
value: multiServerProvider,
child: InputModeTracker(
child: MaterialApp(
theme: monoTheme(dark: true),
home: Scaffold(
body: SizedBox(
width: 1280,
height: 720,
child: TvBrowseRail(
focusMemory: focusMemory,
key: const ValueKey('rail'),
hubs: hubs,
initialHubId: continueHub.id,
autofocus: true,
iconForHub: (_, _) => Icons.tv_rounded,
onFocusedHubItemChanged: (hub, item) => focusedSelections.add((hub.id, item.id)),
),
),
),
),
),
);
}
await tester.pumpWidget(buildRail([recentHub]));
await tester.pump();
expect(focusedSelections.last, (recentHub.id, recentItems.first.id));
await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowRight);
await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowRight);
await tester.pumpAndSettle();
expect(focusedSelections.last, (recentHub.id, recentItems.last.id));
await tester.pumpWidget(buildRail([continueHub, recentHub]));
await tester.pumpAndSettle();
expect(focusedSelections.last, (recentHub.id, recentItems.last.id));
});
testWidgets('selects preferred item when active hub items are populated asynchronously', (tester) async {
final focusedItemIds = <String>[];
Widget buildRail(List<MediaHub> hubs, {String? initialItemId}) {
final serverManager = MultiServerManager();
return ChangeNotifierProvider<MultiServerProvider>(
create: (_) => testMultiServerProvider(serverManager),
child: MaterialApp(
theme: monoTheme(dark: true),
home: Scaffold(
body: SizedBox(
width: 1280,
height: 720,
child: TvBrowseRail(
focusMemory: focusMemory,
key: const ValueKey('rail'),
hubs: hubs,
initialItemId: initialItemId,
iconForHub: (_, _) => Icons.tv_rounded,
onFocusedItemChanged: (item) => focusedItemIds.add(item.id),
),
),
),
),
);
}
final episode1 = testMediaItem(
id: 'episode_1',
backend: MediaBackend.plex,
kind: MediaKind.episode,
title: 'Episode 1',
);
final episode2 = testMediaItem(
id: 'episode_2',
backend: MediaBackend.plex,
kind: MediaKind.episode,
title: 'Episode 2',
);
const emptySeason = MediaHub(id: 'detail_season_0', title: 'Season 1', type: 'episode', items: <MediaItem>[]);
final loadedSeason = MediaHub(
id: emptySeason.id,
title: emptySeason.title,
type: emptySeason.type,
items: [episode1, episode2],
size: 2,
);
await tester.pumpWidget(buildRail(const [emptySeason], initialItemId: episode2.id));
await tester.pump();
await tester.pumpWidget(buildRail([loadedSeason], initialItemId: episode2.id));
await tester.pump();
expect(focusedItemIds.last, episode2.id);
});
testWidgets('scrolls remembered item after switching hubs', (tester) async {
List<MediaItem> movieItems() => List.generate(
12,
(index) =>
testMediaItem(id: 'movie_$index', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie $index'),
);
List<MediaItem> episodeItems() => List.generate(
12,
(index) => testMediaItem(
id: 'episode_$index',
backend: MediaBackend.plex,
kind: MediaKind.episode,
title: 'Episode $index',
thumbPath: '/episode_$index',
),
);
final movieHub = MediaHub(id: 'movies', title: 'Movies', type: 'movie', items: movieItems(), size: 12);
final episodeHub = MediaHub(id: 'episodes', title: 'Episodes', type: 'episode', items: episodeItems(), size: 12);
final serverManager = MultiServerManager();
final activeHubIds = <String>[];
var parentRebuilds = 0;
// Seed remembered focus under the rail's server-qualified hub key (mirrors
// _TvBrowseRailState._hubKey: '<serverId>:<id>'), so the multi-server keying
// resolves it the same way the rail does.
focusMemory.setForHub('${episodeHub.serverId ?? ''}:${episodeHub.id}', 5);
await tester.pumpWidget(
ChangeNotifierProvider<MultiServerProvider>(
create: (_) => testMultiServerProvider(serverManager),
child: MaterialApp(
theme: monoTheme(dark: true),
home: Scaffold(
body: StatefulBuilder(
builder: (context, setParentState) {
return SizedBox(
width: 700,
height: 720,
child: TvBrowseRail(
focusMemory: focusMemory,
hubs: [movieHub, episodeHub],
autofocus: true,
iconForHub: (_, _) => Icons.tv_rounded,
onActiveHubChanged: (hub, _) {
activeHubIds.add(hub.id);
setParentState(() => parentRebuilds++);
},
episodePosterModeForHub: (_) => EpisodePosterMode.episodeThumbnail,
),
);
},
),
),
),
),
);
await tester.pump();
tester.state<TvBrowseRailState>(find.byType(TvBrowseRail)).requestFocus();
await tester.pump();
final scale = TvBrowseRailLayout.scaleForSize(tester.view.physicalSize / tester.view.devicePixelRatio);
final fullCardLayout = SettingsService.instanceOrNull!.read(SettingsService.tvFullCardLayout);
final availableWidth = 700 - TvBrowseRailLayout.horizontalInsetForScale(scale);
final movieMetrics = TvBrowseRailLayout.metricsForHub(
hub: movieHub,
availableWidth: availableWidth,
density: LibraryDensity.defaultValue,
episodePosterMode: EpisodePosterMode.episodeThumbnail,
scale: scale,
fullCardLayout: fullCardLayout,
);
final expectedVerticalOffset = TvBrowseRailLayout.hubSectionHeightFor(
scale: scale,
activeRailHeight: movieMetrics.height,
);
await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowDown);
await tester.pump();
await tester.pump(const Duration(milliseconds: 16));
await tester.pump(const Duration(milliseconds: 100));
final midAnimationPosition = _verticalRailPosition(tester).pixels;
expect(midAnimationPosition, greaterThan(0));
expect(midAnimationPosition, lessThan(expectedVerticalOffset));
await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowDown);
await tester.pumpAndSettle();
final position = _activeRailPosition(tester);
final verticalPosition = _verticalRailPosition(tester);
final metrics = TvBrowseRailLayout.metricsForHub(
hub: episodeHub,
availableWidth: position.viewportDimension,
density: LibraryDensity.defaultValue,
episodePosterMode: EpisodePosterMode.episodeThumbnail,
scale: scale,
fullCardLayout: fullCardLayout,
);
final expectedOffset = TvBrowseRailLayout.scrollOffsetForIndex(
hub: episodeHub,
index: 5,
metrics: metrics,
viewportWidth: position.viewportDimension,
maxScrollExtent: position.maxScrollExtent,
scale: scale,
);
expect(activeHubIds.last, episodeHub.id);
expect(parentRebuilds, greaterThan(0));
expect(position.pixels, closeTo(expectedOffset, 0.1));
expect(verticalPosition.pixels, closeTo(expectedVerticalOffset, 0.1));
});
testWidgets('realigns active hub after preceding hub height changes', (tester) async {
tester.view.devicePixelRatio = 1.0;
tester.view.physicalSize = const Size(1280, 720);
addTearDown(() {
tester.view.resetDevicePixelRatio();
tester.view.resetPhysicalSize();
});
final serverManager = MultiServerManager();
final tallItem = testMediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie 1');
final wideItem = testMediaItem(
id: 'episode_1',
backend: MediaBackend.plex,
kind: MediaKind.episode,
title: 'Episode 1',
thumbPath: '/episode_1',
);
final activeItem = testMediaItem(
id: 'episode_2',
backend: MediaBackend.plex,
kind: MediaKind.episode,
title: 'Episode 2',
thumbPath: '/episode_2',
);
final firstHubTall = MediaHub(id: 'dynamic', title: 'Dynamic', type: 'movie', items: [tallItem], size: 1);
final firstHubWide = MediaHub(id: 'dynamic', title: 'Dynamic', type: 'episode', items: [wideItem], size: 1);
final activeHub = MediaHub(id: 'active', title: 'Active', type: 'episode', items: [activeItem], size: 1);
Widget buildRail(List<MediaHub> hubs) {
return ChangeNotifierProvider<MultiServerProvider>(
create: (_) => testMultiServerProvider(serverManager),
child: MaterialApp(
theme: monoTheme(dark: true),
home: Scaffold(
body: SizedBox(
width: 1280,
height: 720,
child: TvBrowseRail(
focusMemory: focusMemory,
key: const ValueKey('rail'),
hubs: hubs,
autofocus: true,
iconForHub: (_, _) => Icons.tv_rounded,
episodePosterModeForHub: (_) => EpisodePosterMode.episodeThumbnail,
),
),
),
),
);
}
await tester.pumpWidget(buildRail([firstHubTall, activeHub]));
await tester.pump();
tester.state<TvBrowseRailState>(find.byType(TvBrowseRail)).requestFocus();
await tester.pump();
await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowDown);
await tester.pumpAndSettle();
await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowDown);
await tester.pump();
await tester.pumpWidget(buildRail([firstHubWide, activeHub]));
await tester.pumpAndSettle();
final scale = TvBrowseRailLayout.scaleForSize(tester.view.physicalSize / tester.view.devicePixelRatio);
final availableWidth = 1280 - TvBrowseRailLayout.horizontalInsetForScale(scale);
final firstWideMetrics = TvBrowseRailLayout.metricsForHub(
hub: firstHubWide,
availableWidth: availableWidth,
density: LibraryDensity.defaultValue,
episodePosterMode: EpisodePosterMode.episodeThumbnail,
scale: scale,
);
final expectedVerticalOffset = TvBrowseRailLayout.hubSectionHeightFor(
scale: scale,
activeRailHeight: firstWideMetrics.height,
);
expect(_verticalRailPosition(tester).pixels, closeTo(expectedVerticalOffset, 0.1));
});
testWidgets('does not realign active hub when a background hub updates', (tester) async {
tester.view.devicePixelRatio = 1.0;
tester.view.physicalSize = const Size(1280, 720);
addTearDown(() {
tester.view.resetDevicePixelRatio();
tester.view.resetPhysicalSize();
});
MediaItem episode(String id) {
return testMediaItem(id: id, backend: MediaBackend.plex, kind: MediaKind.episode, title: id, thumbPath: '/$id');
}
final serverManager = MultiServerManager();
final firstHub = MediaHub(id: 'first', title: 'First', type: 'episode', items: [episode('episode_1')], size: 1);
final activeHub = MediaHub(id: 'active', title: 'Active', type: 'episode', items: [episode('episode_2')], size: 1);
final backgroundInitialHub = MediaHub(
id: 'background',
title: 'Background',
type: 'episode',
items: [episode('episode_3')],
size: 1,
);
final backgroundUpdatedHub = MediaHub(
id: backgroundInitialHub.id,
title: backgroundInitialHub.title,
type: backgroundInitialHub.type,
items: [episode('episode_3'), episode('episode_4')],
size: 2,
);
Widget buildRail({required bool backgroundLoaded}) {
return ChangeNotifierProvider<MultiServerProvider>(
create: (_) => testMultiServerProvider(serverManager),
child: MaterialApp(
theme: monoTheme(dark: true),
home: Scaffold(
body: SizedBox(
width: 1280,
height: 720,
child: TvBrowseRail(
focusMemory: focusMemory,
key: const ValueKey('rail'),
hubs: [firstHub, activeHub, backgroundLoaded ? backgroundUpdatedHub : backgroundInitialHub],
autofocus: true,
iconForHub: (_, _) => Icons.tv_rounded,
episodePosterModeForHub: (_) => EpisodePosterMode.episodeThumbnail,
),
),
),
),
);
}
await tester.pumpWidget(buildRail(backgroundLoaded: false));
await tester.pump();
tester.state<TvBrowseRailState>(find.byType(TvBrowseRail)).requestFocus();
await tester.pump();
await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowDown);
await tester.pumpAndSettle();
await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowDown);
await tester.pump();
expect(_verticalRailPosition(tester).pixels, greaterThan(0));
_verticalRailPosition(tester).jumpTo(0);
await tester.pump();
await tester.pumpWidget(buildRail(backgroundLoaded: true));
await tester.pump();
await tester.pump(const Duration(milliseconds: 80));
expect(_verticalRailPosition(tester).pixels, 0);
});
testWidgets('keeps vertical navigation smooth when active hub updates during scroll', (tester) async {
tester.view.devicePixelRatio = 1.0;
tester.view.physicalSize = const Size(1280, 720);
addTearDown(() {
tester.view.resetDevicePixelRatio();
tester.view.resetPhysicalSize();
});
MediaItem episode(String id) {
return testMediaItem(id: id, backend: MediaBackend.plex, kind: MediaKind.episode, title: id, thumbPath: '/$id');
}
final serverManager = MultiServerManager();
final firstHub = MediaHub(id: 'first', title: 'First', type: 'episode', items: [episode('episode_1')], size: 1);
final middleInitialHub = MediaHub(
id: 'middle',
title: 'Middle',
type: 'episode',
items: [episode('episode_2')],
size: 1,
);
final middleUpdatedHub = MediaHub(
id: middleInitialHub.id,
title: middleInitialHub.title,
type: middleInitialHub.type,
items: [episode('episode_2'), episode('episode_3')],
size: 2,
);
final lastHub = MediaHub(id: 'last', title: 'Last', type: 'episode', items: [episode('episode_4')], size: 1);
var updateMiddleOnFocus = false;
var middleLoaded = false;
await tester.pumpWidget(
ChangeNotifierProvider<MultiServerProvider>(
create: (_) => testMultiServerProvider(serverManager),
child: MaterialApp(
theme: monoTheme(dark: true),
home: Scaffold(
body: StatefulBuilder(
builder: (context, setParentState) {
return SizedBox(
width: 1280,
height: 720,
child: TvBrowseRail(
focusMemory: focusMemory,
hubs: [firstHub, middleLoaded ? middleUpdatedHub : middleInitialHub, lastHub],
autofocus: true,
iconForHub: (_, _) => Icons.tv_rounded,
episodePosterModeForHub: (_) => EpisodePosterMode.episodeThumbnail,
onActiveHubChanged: (hub, _) {
if (updateMiddleOnFocus && hub.id == middleInitialHub.id && !middleLoaded) {
setParentState(() => middleLoaded = true);
}
},
),
);
},
),
),
),
),
);
await tester.pump();
tester.state<TvBrowseRailState>(find.byType(TvBrowseRail)).requestFocus();
await tester.pump();
for (var i = 0; i < 2; i++) {
await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowDown);
await tester.pumpAndSettle();
await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowDown);
await tester.pump();
}
final scale = TvBrowseRailLayout.scaleForSize(tester.view.physicalSize / tester.view.devicePixelRatio);
final availableWidth = 1280 - TvBrowseRailLayout.horizontalInsetForScale(scale);
final firstMetrics = TvBrowseRailLayout.metricsForHub(
hub: firstHub,
availableWidth: availableWidth,
density: LibraryDensity.defaultValue,
episodePosterMode: EpisodePosterMode.episodeThumbnail,
scale: scale,
);
final middleTargetOffset = TvBrowseRailLayout.hubSectionHeightFor(
scale: scale,
activeRailHeight: firstMetrics.height,
);
final startOffset = _verticalRailPosition(tester).pixels;
expect(startOffset, greaterThan(middleTargetOffset));
updateMiddleOnFocus = true;
await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowUp);
await tester.pump();
await tester.pump();
await tester.pump(const Duration(milliseconds: 80));
await tester.pump(const Duration(milliseconds: 80));
expect(middleLoaded, isTrue);
final midAnimationOffset = _verticalRailPosition(tester).pixels;
expect(midAnimationOffset, greaterThan(middleTargetOffset + 0.5));
expect(midAnimationOffset, lessThan(startOffset - 0.5));
await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowUp);
await tester.pumpAndSettle();
expect(_verticalRailPosition(tester).pixels, closeTo(middleTargetOffset, 0.1));
});
testWidgets('uses per-hub item focus instead of the owner last-column hint', (tester) async {
List<MediaItem> movieItems() => List.generate(
8,
(index) =>
testMediaItem(id: 'movie_$index', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie $index'),
);
List<MediaItem> episodeItems() => List.generate(
8,
(index) => testMediaItem(
id: 'episode_$index',
backend: MediaBackend.plex,
kind: MediaKind.episode,
title: 'Episode $index',
thumbPath: '/episode_$index',
),
);
final movieHub = MediaHub(id: 'movies', title: 'Movies', type: 'movie', items: movieItems(), size: 8);
final episodeHub = MediaHub(id: 'episodes', title: 'Episodes', type: 'episode', items: episodeItems(), size: 8);
final focused = <String>[];
final serverManager = MultiServerManager();
Future<void> press(LogicalKeyboardKey key) async {
await tester.sendKeyDownEvent(key);
await tester.pump();
await tester.sendKeyUpEvent(key);
await tester.pump();
}
await tester.pumpWidget(
ChangeNotifierProvider<MultiServerProvider>(
create: (_) => testMultiServerProvider(serverManager),
child: MaterialApp(
theme: monoTheme(dark: true),
home: Scaffold(
body: StatefulBuilder(
builder: (context, setParentState) {
return SizedBox(
width: 700,
height: 720,
child: TvBrowseRail(
focusMemory: focusMemory,
hubs: [movieHub, episodeHub],
autofocus: true,
iconForHub: (_, _) => Icons.tv_rounded,
onActiveHubChanged: (_, _) => setParentState(() {}),
onFocusedHubItemChanged: (hub, item) => focused.add('${hub.id}:${item.id}'),
episodePosterModeForHub: (_) => EpisodePosterMode.episodeThumbnail,
),
);
},
),
),
),
),
);
await tester.pump();
tester.state<TvBrowseRailState>(find.byType(TvBrowseRail)).requestFocus();
await tester.pump();
for (var i = 0; i < 5; i++) {
await press(LogicalKeyboardKey.arrowRight);
}
expect(focused.last, 'movies:movie_5');
await press(LogicalKeyboardKey.arrowDown);
expect(focused.last, 'episodes:episode_0');
await press(LogicalKeyboardKey.arrowUp);
expect(focused.last, 'movies:movie_5');
});
testWidgets('repeated detail hub ids restore only within their browse owner', (tester) async {
final episodeItems = [
for (var index = 0; index < 12; index++)
testMediaItem(
id: 'episode_$index',
backend: MediaBackend.plex,
kind: MediaKind.episode,
title: 'Episode $index',
thumbPath: '/episode_$index',
),
];
final extraItems = [
for (var index = 0; index < 3; index++)
testMediaItem(id: 'extra_$index', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Extra $index'),
];
final episodeHub = MediaHub(
id: 'detail_episodes',
title: 'Episodes',
type: 'episode',
items: episodeItems,
size: episodeItems.length,
);
final extrasHub = MediaHub(
id: 'detail_extras',
title: 'Extras',
type: 'movie',
items: extraItems,
size: extraItems.length,
);
final focused = <String>[];
Future<void> mount(HubFocusMemory owner, List<MediaHub> hubs) async {
final serverManager = MultiServerManager();
await tester.pumpWidget(
ChangeNotifierProvider<MultiServerProvider>(
create: (_) => testMultiServerProvider(serverManager),
child: MaterialApp(
theme: monoTheme(dark: true),
home: Scaffold(
body: SizedBox(
width: 700,
height: 720,
child: TvBrowseRail(
focusMemory: owner,
hubs: hubs,
autofocus: true,
iconForHub: (_, _) => Icons.tv_rounded,
onFocusedHubItemChanged: (hub, item) => focused.add('${hub.id}:${item.id}'),
episodePosterModeForHub: (_) => EpisodePosterMode.episodeThumbnail,
),
),
),
),
),
);
await tester.pump();
tester.state<TvBrowseRailState>(find.byType(TvBrowseRail)).requestFocus();
await tester.pump();
}
Future<void> press(LogicalKeyboardKey key) async {
await tester.sendKeyDownEvent(key);
await tester.pump();
await tester.sendKeyUpEvent(key);
await tester.pumpAndSettle();
}
await mount(focusMemory, [episodeHub, extrasHub]);
for (var index = 0; index < 5; index++) {
await press(LogicalKeyboardKey.arrowRight);
}
expect(focused.last, 'detail_episodes:episode_5');
expect(_activeRailPosition(tester).pixels, greaterThan(0));
await press(LogicalKeyboardKey.arrowDown);
expect(focused.last, 'detail_extras:extra_0');
await press(LogicalKeyboardKey.arrowUp);
expect(focused.last, 'detail_episodes:episode_5');
expect(_activeRailPosition(tester).pixels, greaterThan(0));
await tester.pumpWidget(const SizedBox.shrink());
await tester.pump();
final freshOwner = HubFocusMemory();
await mount(freshOwner, [extrasHub, episodeHub]);
await press(LogicalKeyboardKey.arrowDown);
expect(focused.last, 'detail_episodes:episode_0');
expect(_activeRailPosition(tester).pixels, 0);
});
testWidgets('keeps late episode thumbnails visible in long TV rows', (tester) async {
await SettingsService.instanceOrNull!.write(SettingsService.tvFullCardLayout, false);
tester.view.devicePixelRatio = 1.0;
tester.view.physicalSize = const Size(1280, 720);
addTearDown(() {
tester.view.resetDevicePixelRatio();
tester.view.resetPhysicalSize();
});
Future<void> pressRight() async {
await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowRight);
await tester.pump();
await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowRight);
await tester.pump(const Duration(milliseconds: 16));
}
final episodes = List.generate(
153,
(index) => testMediaItem(
id: 'episode_${index + 1}',
backend: MediaBackend.plex,
kind: MediaKind.episode,
title: 'Episode ${index + 1}',
parentIndex: 11,
index: index + 1,
thumbPath: '/episode_${index + 1}',
),
);
final hub = MediaHub(id: 'detail_season_11', title: 'Season 11', type: 'episode', items: episodes, size: 153);
final serverManager = MultiServerManager();
await tester.pumpWidget(
ChangeNotifierProvider<MultiServerProvider>(
create: (_) => testMultiServerProvider(serverManager),
child: MaterialApp(
theme: monoTheme(dark: true),
home: Scaffold(
body: SizedBox(
width: 1280,
height: 720,
child: TvBrowseRail(
focusMemory: focusMemory,
hubs: [hub],
autofocus: true,
iconForHub: (_, _) => Icons.tv_rounded,
episodePosterModeForHub: (_) => EpisodePosterMode.episodeThumbnail,
),
),
),
),
),
);
await tester.pump();
tester.state<TvBrowseRailState>(find.byType(TvBrowseRail)).requestFocus();
await tester.pump();
for (var i = 0; i < 117; i++) {
await pressRight();
}
await tester.pumpAndSettle();
final targetTitle = find.text('Episode 118');
expect(targetTitle, findsOneWidget);
final railRect = tester.getRect(find.byType(TvBrowseRail));
final targetRect = tester.getRect(targetTitle);
expect(targetRect.left, greaterThanOrEqualTo(railRect.left - 0.5));
expect(targetRect.right, lessThanOrEqualTo(railRect.right + 0.5));
final position = _activeRailPosition(tester);
final size = tester.view.physicalSize / tester.view.devicePixelRatio;
final scale = TvBrowseRailLayout.scaleForSize(size);
final metrics = TvBrowseRailLayout.metricsForHub(
hub: hub,
availableWidth: size.width - TvBrowseRailLayout.horizontalInsetForScale(scale),
density: LibraryDensity.defaultValue,
episodePosterMode: EpisodePosterMode.episodeThumbnail,
scale: scale,
);
final expectedOffset = TvBrowseRailLayout.scrollOffsetForIndex(
hub: hub,
index: 117,
metrics: metrics,
viewportWidth: position.viewportDimension,
maxScrollExtent: position.maxScrollExtent,
scale: scale,
);
expect(position.pixels, closeTo(expectedOffset, 0.1));
});
testWidgets('keeps late episode thumbnails visible during rapid key repeat', (tester) async {
await SettingsService.instanceOrNull!.write(SettingsService.tvFullCardLayout, false);
tester.view.devicePixelRatio = 1.0;
tester.view.physicalSize = const Size(1280, 720);
addTearDown(() {
tester.view.resetDevicePixelRatio();
tester.view.resetPhysicalSize();
});
final focusedItemIds = <String>[];
const episodeCount = 500;
const targetIndex = 419;
final episodes = List.generate(
episodeCount,
(index) => testMediaItem(
id: 'episode_${index + 1}',
backend: MediaBackend.plex,
kind: MediaKind.episode,
title: 'Episode ${index + 1}',
parentIndex: 11,
index: index + 1,
thumbPath: '/episode_${index + 1}',
),
);
final hub = MediaHub(
id: 'detail_season_11',
title: 'Season 11',
type: 'episode',
items: episodes,
size: episodeCount,
);
final serverManager = MultiServerManager();
await tester.pumpWidget(
ChangeNotifierProvider<MultiServerProvider>(
create: (_) => testMultiServerProvider(serverManager),
child: MaterialApp(
theme: monoTheme(dark: true),
home: Scaffold(
body: SizedBox(
width: 1280,
height: 720,
child: TvBrowseRail(
focusMemory: focusMemory,
hubs: [hub],
autofocus: true,
iconForHub: (_, _) => Icons.tv_rounded,
onFocusedItemChanged: (item) => focusedItemIds.add(item.id),
episodePosterModeForHub: (_) => EpisodePosterMode.episodeThumbnail,
),
),
),
),
),
);
await tester.pump();
tester.state<TvBrowseRailState>(find.byType(TvBrowseRail)).requestFocus();
await tester.pump();
await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowRight);
for (var i = 0; i < targetIndex - 1; i++) {
await tester.sendKeyRepeatEvent(LogicalKeyboardKey.arrowRight);
}
await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowRight);
await tester.pumpAndSettle();
expect(focusedItemIds.last, 'episode_${targetIndex + 1}');
final targetTitle = find.text('Episode ${targetIndex + 1}');
expect(targetTitle, findsOneWidget);
final railRect = tester.getRect(find.byType(TvBrowseRail));
final targetRect = tester.getRect(targetTitle);
expect(targetRect.left, greaterThanOrEqualTo(railRect.left - 0.5));
expect(targetRect.right, lessThanOrEqualTo(railRect.right + 0.5));
final position = _activeRailPosition(tester);
final size = tester.view.physicalSize / tester.view.devicePixelRatio;
final scale = TvBrowseRailLayout.scaleForSize(size);
final metrics = TvBrowseRailLayout.metricsForHub(
hub: hub,
availableWidth: size.width - TvBrowseRailLayout.horizontalInsetForScale(scale),
density: LibraryDensity.defaultValue,
episodePosterMode: EpisodePosterMode.episodeThumbnail,
scale: scale,
);
final expectedOffset = TvBrowseRailLayout.scrollOffsetForIndex(
hub: hub,
index: targetIndex,
metrics: metrics,
viewportWidth: position.viewportDimension,
maxScrollExtent: position.maxScrollExtent,
scale: scale,
);
expect(position.pixels, closeTo(expectedOffset, 0.1));
});
testWidgets('resets long-press state when context menu focus receives select key up', (tester) async {
final menuFocusNode = FocusNode(debugLabel: 'context_menu_probe');
addTearDown(menuFocusNode.dispose);
addTearDown(SelectKeyUpSuppressor.clearSuppression);
var activations = 0;
final person = testMediaItem(id: 'person_1', backend: MediaBackend.plex, kind: MediaKind.unknown, title: 'Person');
final hub = MediaHub(id: 'people', title: 'People', type: 'person', items: [person], size: 1);
final serverManager = MultiServerManager();
await tester.pumpWidget(
ChangeNotifierProvider<MultiServerProvider>(
create: (_) => testMultiServerProvider(serverManager),
child: MaterialApp(
theme: monoTheme(dark: true),
home: Scaffold(
body: Stack(
children: [
SizedBox(
width: 1280,
height: 720,
child: TvBrowseRail(
focusMemory: focusMemory,
hubs: [hub],
iconForHub: (_, _) => Icons.person_rounded,
onActivateItem: (_, _) {
activations++;
return Future.value(true);
},
),
),
Focus(
focusNode: menuFocusNode,
onKeyEvent: (_, event) {
if (SelectKeyUpSuppressor.consumeIfSuppressed(event)) return KeyEventResult.handled;
return KeyEventResult.ignored;
},
child: const SizedBox.shrink(),
),
],
),
),
),
),
);
await tester.pump();
final railState = tester.state<TvBrowseRailState>(find.byType(TvBrowseRail));
railState.requestFocus();
await tester.pump();
await tester.sendKeyDownEvent(LogicalKeyboardKey.enter);
await tester.pump(const Duration(milliseconds: 501));
menuFocusNode.requestFocus();
await tester.pump();
await tester.sendKeyUpEvent(LogicalKeyboardKey.enter);
await tester.pump();
railState.requestFocus();
await tester.pump();
await tester.sendKeyDownEvent(LogicalKeyboardKey.enter);
await tester.pump(const Duration(milliseconds: 100));
await tester.sendKeyUpEvent(LogicalKeyboardKey.enter);
await tester.pump();
expect(activations, 1);
});
testWidgets('does not autofocus unless requested', (tester) async {
FocusManager.instance.primaryFocus?.unfocus();
Widget buildRail({required bool autofocus}) {
final serverManager = MultiServerManager();
final item = testMediaItem(id: 'item_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie');
final hub = MediaHub(id: 'hub_1', title: 'Hub', type: 'movie', items: [item], size: 1);
return ChangeNotifierProvider<MultiServerProvider>(
create: (_) => testMultiServerProvider(serverManager),
child: MaterialApp(
theme: monoTheme(dark: true),
home: Scaffold(
body: SizedBox(
width: 1280,
height: 720,
child: TvBrowseRail(
focusMemory: focusMemory,
hubs: [hub],
autofocus: autofocus,
iconForHub: (_, _) => Icons.tv_rounded,
),
),
),
),
);
}
await tester.pumpWidget(buildRail(autofocus: false));
await tester.pump();
expect(FocusManager.instance.primaryFocus?.debugLabel, isNot('tv_browse_rail'));
await tester.pumpWidget(buildRail(autofocus: true));
await tester.pump();
expect(FocusManager.instance.primaryFocus?.debugLabel, 'tv_browse_rail');
});
testWidgets('lays out when bottom-positioned in a stack', (tester) async {
final serverManager = MultiServerManager();
final item = testMediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie');
final hub = MediaHub(id: 'hub_1', title: 'Hub', type: 'movie', items: [item], size: 1);
await tester.pumpWidget(
ChangeNotifierProvider<MultiServerProvider>(
create: (_) => testMultiServerProvider(serverManager),
child: MaterialApp(
theme: monoTheme(dark: true),
home: Scaffold(
body: SizedBox(
width: 896,
height: 540,
child: Stack(
children: [
Positioned(
left: 0,
right: 0,
bottom: 0,
child: TvBrowseRail(
focusMemory: focusMemory,
hubs: [hub],
iconForHub: (_, _) => Icons.movie_rounded,
),
),
],
),
),
),
),
),
);
await tester.pump();
expect(tester.takeException(), isNull);
});
testWidgets('background gradient stays full bleed beside pushed foreground', (tester) async {
tester.view.devicePixelRatio = 1.0;
tester.view.physicalSize = const Size(1280, 720);
addTearDown(() {
tester.view.resetDevicePixelRatio();
tester.view.resetPhysicalSize();
});
final serverManager = MultiServerManager();
final item = testMediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie');
final hub = MediaHub(id: 'hub_1', title: 'Hub', type: 'movie', items: [item], size: 1);
await tester.pumpWidget(
ChangeNotifierProvider<MultiServerProvider>(
create: (_) => testMultiServerProvider(serverManager),
child: MaterialApp(
theme: monoTheme(dark: true),
home: Scaffold(
body: SizedBox(
width: 1060,
height: 720,
child: TvBrowseRail(
focusMemory: focusMemory,
hubs: [hub],
iconForHub: (_, _) => Icons.movie_rounded,
backgroundBleedLeft: SideNavigationRailState.expandedWidth,
),
),
),
),
),
);
await tester.pump();
final gradient = find.byType(RasterizedGradient);
final backgroundPosition = tester.widget<Positioned>(
find.ancestor(of: gradient.first, matching: find.byType(Positioned)).first,
);
expect(backgroundPosition.left, -SideNavigationRailState.expandedWidth);
expect(backgroundPosition.width, 1280);
});
testWidgets('background bleed updates do not renotify rail focus', (tester) async {
final serverManager = MultiServerManager();
final multiServerProvider = testMultiServerProvider(serverManager);
addTearDown(multiServerProvider.dispose);
final focusedItemIds = <String>[];
final activeHubIds = <String>[];
final item = testMediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie');
final hub = MediaHub(id: 'hub_1', title: 'Hub', type: 'movie', items: [item], size: 1);
Widget buildRail(double backgroundBleedLeft) {
return ChangeNotifierProvider<MultiServerProvider>.value(
value: multiServerProvider,
child: MaterialApp(
theme: monoTheme(dark: true),
home: Scaffold(
body: SizedBox(
width: 1280,
height: 720,
child: TvBrowseRail(
focusMemory: focusMemory,
key: const ValueKey('rail'),
hubs: [hub],
iconForHub: (_, _) => Icons.movie_rounded,
backgroundBleedLeft: backgroundBleedLeft,
onFocusedItemChanged: (focused) => focusedItemIds.add(focused.id),
onActiveHubChanged: (active, _) => activeHubIds.add(active.id),
),
),
),
),
);
}
await tester.pumpWidget(buildRail(0));
await tester.pump();
focusedItemIds.clear();
activeHubIds.clear();
await tester.pumpWidget(buildRail(SideNavigationRailState.expandedWidth));
await tester.pump();
expect(focusedItemIds, isEmpty);
expect(activeHubIds, isEmpty);
});
}
ScrollPosition _activeRailPosition(WidgetTester tester) {
return tester
.stateList<ScrollableState>(find.byType(Scrollable))
.map((state) => state.position)
.where((position) => axisDirectionToAxis(position.axisDirection) == Axis.horizontal)
.where((position) => position.maxScrollExtent > 0)
.reduce((a, b) => a.maxScrollExtent > b.maxScrollExtent ? a : b);
}
ScrollPosition _verticalRailPosition(WidgetTester tester) {
final scrollable = find.descendant(
of: find.byKey(const ValueKey('tv_browse_rail_vertical')),
matching: find.byType(Scrollable),
);
return tester
.stateList<ScrollableState>(scrollable)
.map((state) => state.position)
.singleWhere((position) => axisDirectionToAxis(position.axisDirection) == Axis.vertical);
}