feat(jellyfin): cycle media backdrops

close #1568
This commit is contained in:
edde746
2026-07-14 07:24:11 +02:00
parent 30c978719b
commit 8c88385977
15 changed files with 991 additions and 417 deletions
+59
View File
@@ -21,6 +21,7 @@ MediaItem _movie({
int? durationMs,
int? viewOffsetMs,
String? artPath,
List<String>? backdropPaths,
String? backgroundSquarePath,
MediaBackend backend = MediaBackend.plex,
}) => testMediaItem(
@@ -34,6 +35,7 @@ MediaItem _movie({
durationMs: durationMs,
viewOffsetMs: viewOffsetMs,
artPath: artPath,
backdropPaths: backdropPaths,
backgroundSquarePath: backgroundSquarePath,
serverId: 's1',
);
@@ -125,6 +127,46 @@ void main() {
expect(episode.heroArt(containerAspectRatio: 16 / 9), '/show-art');
expect(episode.heroArtCandidates(containerAspectRatio: 1.0), ['/square', '/show-art', '/episode-art']);
});
test('Jellyfin movies expose every backdrop in display order', () {
final movie = _movie(
backend: MediaBackend.jellyfin,
artPath: '/art-0',
backdropPaths: ['/art-0', '/art-1', '/art-2'],
backgroundSquarePath: '/square',
);
expect(movie.heroBackdropPaths, ['/art-0', '/art-1', '/art-2']);
expect(movie.heroArtCandidates(containerAspectRatio: 16 / 9), ['/art-0', '/art-1', '/art-2', '/square']);
});
test('episodes prefer inherited backdrops over their own art', () {
final episode = testMediaItem(
id: 'e-multi',
backend: MediaBackend.jellyfin,
kind: MediaKind.episode,
artPath: '/episode-0',
backdropPaths: ['/episode-0', '/episode-1'],
grandparentArtPath: '/show-0',
grandparentBackdropPaths: ['/show-0', '/show-1', '/show-2'],
);
expect(episode.heroBackdropPaths, ['/show-0', '/show-1', '/show-2']);
expect(episode.heroArtCandidates(containerAspectRatio: 16 / 9), [
'/show-0',
'/show-1',
'/show-2',
'/episode-0',
'/episode-1',
]);
});
test('legacy scalar art remains a single static backdrop', () {
final movie = _movie(artPath: '/legacy-art');
expect(movie.resolvedBackdropPaths, ['/legacy-art']);
expect(movie.heroBackdropPaths, ['/legacy-art']);
});
});
group('MediaItem.isPartiallyWatched', () {
@@ -338,6 +380,23 @@ void main() {
expect((decoded as JellyfinMediaItem).playlistItemId, 'entry-1');
});
test('round-trips Jellyfin backdrop lists', () {
const original = JellyfinMediaItem(
id: 'j-backdrops',
kind: MediaKind.episode,
artPath: '/episode-0',
backdropPaths: ['/episode-0', '/episode-1'],
grandparentArtPath: '/show-0',
grandparentBackdropPaths: ['/show-0', '/show-1'],
);
final decoded = MediaItem.fromJson(original.toJson());
expect(decoded.backdropPaths, ['/episode-0', '/episode-1']);
expect(decoded.grandparentBackdropPaths, ['/show-0', '/show-1']);
expect(decoded.heroBackdropPaths, ['/show-0', '/show-1']);
});
test('missing backend keeps legacy Plex fallback', () {
final decoded = MediaItem.fromJson({'id': 'legacy', 'kind': 'movie'});
+26 -16
View File
@@ -219,7 +219,7 @@ void main() {
});
expect(requests.every((uri) => uri.queryParameters['userId'] == 'user-1'), isTrue);
expect(requests.every((uri) => uri.queryParameters['EnableImageTypes'] == 'Primary,Backdrop,Thumb,Logo'), isTrue);
expect(requests.every((uri) => uri.queryParameters['ImageTypeLimit'] == '1'), isTrue);
expect(requests.every((uri) => uri.queryParameters['ImageTypeLimit'] == '3'), isTrue);
expect(extras.map((item) => item.id).toList(), ['trailer-1', 'featurette-1']);
expect(extras.every((item) => item.kind.isVideo), isTrue);
expect(extras.every((item) => item.serverId == 'srv-1'), isTrue);
@@ -1724,7 +1724,12 @@ void main() {
return http.Response(
jsonEncode({
'Items': [
{'Id': 'movie-1', 'Type': 'Movie', 'Name': 'Movie'},
{
'Id': 'movie-1',
'Type': 'Movie',
'Name': 'Movie',
'BackdropImageTags': ['backdrop-0', 'backdrop-1', 'backdrop-2'],
},
],
'TotalRecordCount': 123,
}),
@@ -1741,6 +1746,11 @@ void main() {
);
expect(page.items.single.id, 'movie-1');
expect(page.items.single.backdropPaths!.map((url) => Uri.parse(url).path).toList(), [
'/Items/movie-1/Images/Backdrop/0',
'/Items/movie-1/Images/Backdrop/1',
'/Items/movie-1/Images/Backdrop/2',
]);
expect(page.totalCount, 123);
expect(captured, isNotNull);
expect(captured!.path, '/Items');
@@ -1751,7 +1761,7 @@ void main() {
expect(captured!.queryParameters['IncludeItemTypes'], 'Movie');
expect(captured!.queryParameters['Fields'], isNot(contains('MediaSources')));
expect(captured!.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Thumb,Logo');
expect(captured!.queryParameters['ImageTypeLimit'], '1');
expect(captured!.queryParameters['ImageTypeLimit'], '3');
});
test('music browse and detail requests use leaf-appropriate fields', () async {
@@ -2113,7 +2123,7 @@ void main() {
expect(captured!.queryParameters['SortOrder'], 'Descending,Descending,Ascending');
expect(captured!.queryParameters['CollapseBoxSetItems'], 'false');
expect(captured!.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Thumb,Logo');
expect(captured!.queryParameters['ImageTypeLimit'], '1');
expect(captured!.queryParameters['ImageTypeLimit'], '3');
});
test('fetchItemWithOnDeck keeps resumable NextUp semantics for show detail lookup', () async {
@@ -2143,7 +2153,7 @@ void main() {
expect(capturedNextUp!.queryParameters['seriesId'], 'show-1');
expect(capturedNextUp!.queryParameters['Limit'], '1');
expect(capturedNextUp!.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Thumb,Logo');
expect(capturedNextUp!.queryParameters['ImageTypeLimit'], '1');
expect(capturedNextUp!.queryParameters['ImageTypeLimit'], '3');
expect(capturedNextUp!.queryParameters.containsKey('EnableResumable'), isFalse);
expect(capturedNextUp!.queryParameters.containsKey('NextUpDateCutoff'), isFalse);
});
@@ -2267,14 +2277,14 @@ void main() {
expect(resume.queryParameters['Recursive'], 'true');
expect(resume.queryParameters['EnableTotalRecordCount'], 'false');
expect(resume.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Thumb,Logo');
expect(resume.queryParameters['ImageTypeLimit'], '1');
expect(resume.queryParameters['ImageTypeLimit'], '3');
final nextUp = requests.singleWhere((uri) => uri.path == '/Shows/NextUp');
expect(nextUp.queryParameters['userId'], 'user-1');
expect(nextUp.queryParameters['Limit'], '3');
expect(nextUp.queryParameters['EnableResumable'], 'false');
expect(nextUp.queryParameters['EnableTotalRecordCount'], 'false');
expect(nextUp.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Thumb,Logo');
expect(nextUp.queryParameters['ImageTypeLimit'], '1');
expect(nextUp.queryParameters['ImageTypeLimit'], '3');
expect(nextUp.queryParameters.containsKey('NextUpDateCutoff'), isFalse);
});
@@ -2541,7 +2551,7 @@ void main() {
expect(nextUp.queryParameters['EnableResumable'], 'false');
expect(nextUp.queryParameters['EnableTotalRecordCount'], 'false');
expect(nextUp.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Thumb,Logo');
expect(nextUp.queryParameters['ImageTypeLimit'], '1');
expect(nextUp.queryParameters['ImageTypeLimit'], '3');
expect(nextUp.queryParameters.containsKey('NextUpDateCutoff'), isFalse);
});
@@ -2582,7 +2592,7 @@ void main() {
expect(nextUp.queryParameters['EnableResumable'], 'false');
expect(nextUp.queryParameters['EnableTotalRecordCount'], 'false');
expect(nextUp.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Thumb,Logo');
expect(nextUp.queryParameters['ImageTypeLimit'], '1');
expect(nextUp.queryParameters['ImageTypeLimit'], '3');
expect(nextUp.queryParameters.containsKey('NextUpDateCutoff'), isFalse);
});
@@ -2632,7 +2642,7 @@ void main() {
expect(captured!.queryParameters['Limit'], '80');
expect(captured!.queryParameters['IncludeItemTypes'], 'Movie,Series,Episode');
expect(captured!.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Thumb,Logo');
expect(captured!.queryParameters['ImageTypeLimit'], '1');
expect(captured!.queryParameters['ImageTypeLimit'], '3');
expect(captured!.queryParameters.containsKey('ParentId'), isFalse);
client.close();
});
@@ -2650,7 +2660,7 @@ void main() {
expect(captured!.queryParameters['Recursive'], 'true');
expect(captured!.queryParameters['EnableTotalRecordCount'], 'true');
expect(captured!.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Thumb,Logo');
expect(captured!.queryParameters['ImageTypeLimit'], '1');
expect(captured!.queryParameters['ImageTypeLimit'], '3');
expect(captured!.queryParameters.containsKey('ParentId'), isFalse);
client.close();
});
@@ -2668,7 +2678,7 @@ void main() {
expect(captured!.queryParameters['EnableResumable'], 'false');
expect(captured!.queryParameters['EnableTotalRecordCount'], 'true');
expect(captured!.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Thumb,Logo');
expect(captured!.queryParameters['ImageTypeLimit'], '1');
expect(captured!.queryParameters['ImageTypeLimit'], '3');
expect(captured!.queryParameters.containsKey('NextUpDateCutoff'), isFalse);
client.close();
});
@@ -2682,7 +2692,7 @@ void main() {
expect(captured!.queryParameters['ParentId'], 'lib-99');
expect(captured!.queryParameters['Limit'], '30');
expect(captured!.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Thumb,Logo');
expect(captured!.queryParameters['ImageTypeLimit'], '1');
expect(captured!.queryParameters['ImageTypeLimit'], '3');
// ParentId-scoped Latest should NOT also pin IncludeItemTypes (the
// library already constrains the kinds returned).
expect(captured!.queryParameters.containsKey('IncludeItemTypes'), isFalse);
@@ -2701,7 +2711,7 @@ void main() {
expect(captured!.queryParameters['Recursive'], 'true');
expect(captured!.queryParameters['EnableTotalRecordCount'], 'true');
expect(captured!.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Thumb,Logo');
expect(captured!.queryParameters['ImageTypeLimit'], '1');
expect(captured!.queryParameters['ImageTypeLimit'], '3');
client.close();
});
@@ -2717,7 +2727,7 @@ void main() {
expect(captured!.queryParameters['EnableResumable'], 'false');
expect(captured!.queryParameters['EnableTotalRecordCount'], 'true');
expect(captured!.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Thumb,Logo');
expect(captured!.queryParameters['ImageTypeLimit'], '1');
expect(captured!.queryParameters['ImageTypeLimit'], '3');
expect(captured!.queryParameters.containsKey('NextUpDateCutoff'), isFalse);
client.close();
});
@@ -2902,7 +2912,7 @@ void main() {
);
expect(itemsRequest.queryParameters.containsKey('EnableTotalRecordCount'), isFalse);
expect(itemsRequest.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Thumb,Logo');
expect(itemsRequest.queryParameters['ImageTypeLimit'], '1');
expect(itemsRequest.queryParameters['ImageTypeLimit'], '3');
});
test('fetchCollectionsPage uses requested collection page bounds', () async {
+47 -1
View File
@@ -41,7 +41,7 @@ void main() {
'DateCreated': '2025-01-15T10:00:00.0000000Z',
'DateLastSaved': '2026-03-01T10:00:00.0000000Z',
'ImageTags': {'Primary': 'thumbtag', 'Logo': 'logotag'},
'BackdropImageTags': ['backtag'],
'BackdropImageTags': ['backtag', 'backtag-2', 'backtag-3'],
};
final item = JellyfinMappers.mediaItem(
@@ -79,6 +79,11 @@ void main() {
// Image paths.
expect(item.thumbPath, '/Items/abc123/Images/Primary?tag=thumbtag');
expect(item.artPath, '/Items/abc123/Images/Backdrop/0?tag=backtag');
expect(item.backdropPaths, [
'/Items/abc123/Images/Backdrop/0?tag=backtag',
'/Items/abc123/Images/Backdrop/1?tag=backtag-2',
'/Items/abc123/Images/Backdrop/2?tag=backtag-3',
]);
expect(item.clearLogoPath, '/Items/abc123/Images/Logo?tag=logotag');
// Multi-server fields.
@@ -86,6 +91,25 @@ void main() {
expect(item.serverName, 'Home');
});
test('preserves backdrop indices, deduplicates tags, and absolutizes every valid path', () {
const absolutizer = JellyfinImageAbsolutizer(baseUrl: 'https://jellyfin.example', accessToken: 'secret');
final item = JellyfinMappers.mediaItem(
{
'Id': 'movie-1',
'Type': 'Movie',
'BackdropImageTags': ['first', 42, '', 'first', 'fifth'],
},
serverId: ServerId(_serverId),
absolutizer: absolutizer,
)!;
expect(item.artPath, 'https://jellyfin.example/Items/movie-1/Images/Backdrop/0?tag=first&api_key=secret');
expect(item.backdropPaths, [
'https://jellyfin.example/Items/movie-1/Images/Backdrop/0?tag=first&api_key=secret',
'https://jellyfin.example/Items/movie-1/Images/Backdrop/4?tag=fifth&api_key=secret',
]);
});
test('does not treat Jellyfin PlayCount as watched when Played is false', () {
final json = {
'Id': 'started-only',
@@ -174,6 +198,28 @@ void main() {
expect(item.grandparentArtPath, '/Items/series-1/Images/Backdrop/0');
});
test('episode maps every inherited series backdrop', () {
final item = JellyfinMappers.mediaItem(
{
'Id': 'ep-parent-art',
'Type': 'Episode',
'SeriesId': 'series-fallback',
'ParentBackdropItemId': 'series-parent',
'ParentBackdropImageTags': ['parent-0', 'parent-1', 'parent-2'],
},
serverId: ServerId(_serverId),
absolutizer: null,
)!;
expect(item.grandparentArtPath, '/Items/series-parent/Images/Backdrop/0?tag=parent-0');
expect(item.grandparentBackdropPaths, [
'/Items/series-parent/Images/Backdrop/0?tag=parent-0',
'/Items/series-parent/Images/Backdrop/1?tag=parent-1',
'/Items/series-parent/Images/Backdrop/2?tag=parent-2',
]);
expect(item.heroBackdropPaths, item.grandparentBackdropPaths);
});
test('episode season poster falls back to series poster when season image tag is absent', () {
final json = {
'Id': 'ep1',
@@ -85,7 +85,7 @@ void main() {
expect(params['IncludeItemTypes'], isNotEmpty);
expect(params['EnableTotalRecordCount'], 'true');
expect(params['EnableImageTypes'], 'Primary,Backdrop,Thumb,Logo');
expect(params['ImageTypeLimit'], '1');
expect(params['ImageTypeLimit'], '3');
});
test('movie kind maps to IncludeItemTypes=Movie', () {
+4
View File
@@ -32,8 +32,10 @@ MediaItem testMediaItem({
String? grandparentTitle,
String? grandparentThumbPath,
String? grandparentArtPath,
List<String>? grandparentBackdropPaths,
String? thumbPath,
String? artPath,
List<String>? backdropPaths,
String? clearLogoPath,
String? backgroundSquarePath,
int? durationMs,
@@ -92,8 +94,10 @@ MediaItem testMediaItem({
grandparentTitle: grandparentTitle,
grandparentThumbPath: grandparentThumbPath,
grandparentArtPath: grandparentArtPath,
grandparentBackdropPaths: grandparentBackdropPaths,
thumbPath: thumbPath,
artPath: artPath,
backdropPaths: backdropPaths,
clearLogoPath: clearLogoPath,
backgroundSquarePath: backgroundSquarePath,
durationMs: durationMs,
@@ -0,0 +1,227 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/media/media_item.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/widgets/cycling_media_backdrop.dart';
import 'package:plezy/widgets/tv_spotlight_background.dart';
const _png = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=';
const _rotationInterval = Duration(seconds: 1);
const _fadeDuration = Duration(milliseconds: 20);
void main() {
late Directory directory;
late File first;
late File second;
late File third;
late Map<String, MemoryImage> imageProviders;
setUp(() {
directory = Directory.systemTemp.createTempSync('plezy-backdrop-cycle');
final bytes = base64Decode(_png);
first = File('${directory.path}/first.png')..writeAsBytesSync(bytes);
second = File('${directory.path}/second.png')..writeAsBytesSync(bytes);
third = File('${directory.path}/third.png')..writeAsBytesSync(bytes);
imageProviders = {
first.path: MemoryImage(base64Decode(_png)),
second.path: MemoryImage(base64Decode(_png)),
third.path: MemoryImage(base64Decode(_png)),
};
});
tearDown(() {
directory.deleteSync(recursive: true);
});
Widget buildBackdrop(
List<String> paths, {
bool active = true,
bool disableAnimations = false,
bool tickerEnabled = true,
Object? mediaKey = 'movie-1',
}) {
return MaterialApp(
home: MediaQuery(
data: MediaQueryData(size: const Size(320, 180), devicePixelRatio: 1, disableAnimations: disableAnimations),
child: TickerMode(
enabled: tickerEnabled,
child: SizedBox(
width: 320,
height: 180,
child: CyclingMediaBackdrop(
mediaKey: mediaKey,
imagePaths: paths,
client: null,
localArtworkPathResolver: (path) => path,
imageProviderResolver: (path) => imageProviders[path],
allowNetwork: false,
active: active,
width: 320,
height: 180,
fallbackColor: Colors.black,
rotationInterval: _rotationInterval,
fadeDuration: _fadeDuration,
),
),
),
),
);
}
String pathForProvider(ImageProvider provider) {
while (provider is ResizeImage) {
provider = provider.imageProvider;
}
if (provider case FileImage(:final file)) return file.path;
return imageProviders.entries.singleWhere((entry) => identical(entry.value, provider)).key;
}
List<String> renderedFilePaths(WidgetTester tester) {
return tester.widgetList<Image>(find.byType(Image)).map((image) {
return pathForProvider(image.image);
}).toList();
}
void expectVisibleBackdrop(WidgetTester tester, String path) {
final image = tester.widgetList<Image>(find.byType(Image)).last;
expect(pathForProvider(image.image), path);
expect(image.opacity?.value ?? 1, 1);
}
Future<void> finishImageTransition(WidgetTester tester, {Duration fadeDuration = _fadeDuration}) async {
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 200)));
await tester.pump();
await tester.pump(fadeDuration);
await tester.pump(fadeDuration);
await tester.pump();
}
testWidgets('rotates loaded backdrops in order and wraps', (tester) async {
await tester.pumpWidget(buildBackdrop([first.path, second.path, third.path]));
expect(renderedFilePaths(tester), [first.path]);
await tester.pump(_rotationInterval);
expect(renderedFilePaths(tester).last, second.path);
await finishImageTransition(tester);
expectVisibleBackdrop(tester, second.path);
await tester.pump(_rotationInterval);
await finishImageTransition(tester);
expectVisibleBackdrop(tester, third.path);
await tester.pump(_rotationInterval);
await finishImageTransition(tester);
expectVisibleBackdrop(tester, first.path);
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('skips a missing incoming image without dropping the settled backdrop', (tester) async {
final missing = '${directory.path}/missing.png';
await tester.pumpWidget(buildBackdrop([first.path, missing, third.path]));
await tester.pump(_rotationInterval);
expect(renderedFilePaths(tester), [first.path]);
await tester.pump();
await finishImageTransition(tester);
expectVisibleBackdrop(tester, third.path);
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('pauses while the application is not resumed', (tester) async {
addTearDown(() => tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed));
await tester.pumpWidget(buildBackdrop([first.path, second.path]));
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused);
await tester.pump(_rotationInterval * 3);
expect(renderedFilePaths(tester), [first.path]);
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
await tester.pump(_rotationInterval - const Duration(milliseconds: 1));
expect(renderedFilePaths(tester), [first.path]);
await tester.pump(const Duration(milliseconds: 1));
await finishImageTransition(tester);
expect(renderedFilePaths(tester), [second.path]);
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('pauses while its TickerMode subtree is hidden', (tester) async {
final paths = [first.path, second.path];
await tester.pumpWidget(buildBackdrop(paths, tickerEnabled: false));
await tester.pump(_rotationInterval * 3);
expect(renderedFilePaths(tester), [first.path]);
await tester.pumpWidget(buildBackdrop(paths));
await tester.pump(_rotationInterval);
await finishImageTransition(tester);
expectVisibleBackdrop(tester, second.path);
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('keeps one backdrop static', (tester) async {
await tester.pumpWidget(buildBackdrop([first.path]));
await tester.pump(_rotationInterval * 3);
expect(renderedFilePaths(tester), [first.path]);
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('resets to the first backdrop when media changes', (tester) async {
await tester.pumpWidget(buildBackdrop([first.path, second.path]));
await tester.pump(_rotationInterval);
await finishImageTransition(tester);
expectVisibleBackdrop(tester, second.path);
await tester.pumpWidget(buildBackdrop([third.path, first.path], mediaKey: 'movie-2'));
await finishImageTransition(tester);
expectVisibleBackdrop(tester, third.path);
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('does not auto-rotate when reduced motion is requested', (tester) async {
await tester.pumpWidget(buildBackdrop([first.path, second.path], disableAnimations: true));
await tester.pump(_rotationInterval * 3);
expect(renderedFilePaths(tester), [first.path]);
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('TV spotlight rotates the Jellyfin item backdrop list', (tester) async {
final item = JellyfinMediaItem(
id: 'show-1',
kind: MediaKind.show,
artPath: first.path,
backdropPaths: [first.path, second.path],
serverId: 'server-1',
);
await tester.pumpWidget(
MaterialApp(
home: TvSpotlightBackground(
item: item,
client: null,
showInfo: false,
allowNetwork: false,
localArtworkPathResolver: (path) => path,
),
),
);
expect(renderedFilePaths(tester), [first.path]);
await tester.pump(const Duration(seconds: 10));
expect(renderedFilePaths(tester).last, second.path);
await finishImageTransition(tester, fadeDuration: const Duration(milliseconds: 280));
expectVisibleBackdrop(tester, second.path);
await tester.pumpWidget(const SizedBox.shrink());
});
}