feat(player): show the next episode's thumbnail in the play-next prompt

The end-of-episode autoplay prompt only named the next episode; Plex shows
a visual preview, which is what non-readers navigate by. The prompt card now
paints the episode's 16:9 still edge-to-edge behind the text under a bottom
gradient scrim, falling back to the text-only card when no thumbnail can be
served (no thumb, or no client and no downloaded artwork). Unwatched stills
respect the hide-spoilers blur, and the lookup only runs while the prompt is
visible to keep the playback-path build cheap.

close #2166
This commit is contained in:
edde746
2026-08-28 18:43:26 +02:00
parent ff1064d35f
commit 2f2016fdbe
2 changed files with 210 additions and 8 deletions
@@ -1,3 +1,5 @@
import 'dart:ui' show ImageFilter;
import 'package:flutter/foundation.dart' show ValueListenable, listEquals;
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
@@ -5,13 +7,20 @@ import 'package:provider/provider.dart';
import '../../../focus/focusable_button.dart';
import '../../../i18n/strings.g.dart';
import '../../../media/ids.dart';
import '../../../media/media_item.dart';
import '../../../media/media_item_types.dart';
import '../../../providers/playback_state_provider.dart';
import '../../../services/download_storage_service.dart';
import '../../../services/pip_service.dart';
import '../../../services/settings_service.dart';
import '../../../utils/platform_detector.dart';
import '../../../utils/provider_extensions.dart';
import '../../../watch_together/providers/watch_together_provider.dart';
import '../../../watch_together/widgets/watch_together_overlay.dart';
import '../../../widgets/app_icon.dart';
import '../../../widgets/optimized_media_image.dart';
import '../../../widgets/settings_builder.dart';
import '../../../widgets/video_controls/player_chrome_controller.dart';
class VideoPlayerMacPipPlaceholder extends StatelessWidget {
@@ -212,11 +221,18 @@ class VideoPlayerPlayNextOverlay extends StatelessWidget {
Widget build(BuildContext context) {
final episode = nextEpisode;
if (episode == null) return const SizedBox.shrink();
// Resolved only while shown: the overlay sits in the player build with
// visible=false for the whole episode, and the lookup hashes the artwork
// path per call.
final backdrop = visible ? _buildThumbnailBackdrop(context, episode) : null;
return _VideoPlayerPromptShell(
visible: visible,
chromeController: chromeController,
focusNodes: [cancelFocusNode, confirmFocusNode],
backdrop: backdrop,
children: [
// Clear region that keeps the still visible above the scrimmed text.
if (backdrop != null) const SizedBox(height: 110),
_PlayNextEpisodeHeader(episode: episode),
const SizedBox(height: 12),
_VideoPlayerPromptActions(
@@ -245,6 +261,43 @@ class VideoPlayerPlayNextOverlay extends StatelessWidget {
],
);
}
/// The next episode's 16:9 video-frame still, painted edge-to-edge behind
/// the prompt text under the card's scrim (#2166).
///
/// Null keeps the original text-only card: the item has no thumb, or
/// nothing could serve one (no live client and no artwork directory for a
/// downloaded copy). A load that starts and fails degrades to the dark card
/// via the shrinking placeholder/error widgets instead of a fallback icon.
Widget? _buildThumbnailBackdrop(BuildContext context, MediaItem episode) {
final thumbPath = episode.thumbPath;
if (thumbPath == null) return null;
final serverId = serverIdOrNull(episode.serverId);
final client = context.tryGetMediaClientForServer(serverId);
final localFilePath = serverId == null
? null
: DownloadStorageService.instance.getArtworkPathSync(serverId, thumbPath);
if (client == null && localFilePath == null) return null;
final image = OptimizedMediaImage.thumb(
client: client,
imagePath: thumbPath,
localFilePath: localFilePath,
fit: BoxFit.cover,
placeholder: (_, _) => const SizedBox.shrink(),
errorWidget: (_, _, _) => const SizedBox.shrink(),
);
// The next episode is unwatched by definition, so hide-spoilers users get
// the same blurred still as the queue strip and episode cards.
return SettingValueBuilder<bool>(
pref: SettingsService.hideSpoilers,
builder: (context, hideSpoilers, _) {
if (!hideSpoilers || !episode.shouldHideSpoiler) return image;
return ClipRect(
child: ImageFiltered(imageFilter: ImageFilter.blur(sigmaX: 12, sigmaY: 12), child: image),
);
},
);
}
}
class _PlayNextEpisodeHeader extends StatelessWidget {
@@ -364,12 +417,16 @@ class _VideoPlayerPromptShell extends StatelessWidget {
final bool visible;
final PlayerChromeController chromeController;
final List<FocusNode> focusNodes;
/// Full-bleed artwork painted behind [children] under a darkening scrim.
final Widget? backdrop;
final List<Widget> children;
const _VideoPlayerPromptShell({
required this.visible,
required this.chromeController,
required this.focusNodes,
this.backdrop,
required this.children,
});
@@ -387,6 +444,7 @@ class _VideoPlayerPromptShell extends StatelessWidget {
chromeController: chromeController,
focusNodes: focusNodes,
child: _VideoPlayerPromptCard(
backdrop: backdrop,
child: Column(mainAxisSize: .min, crossAxisAlignment: .start, children: children),
),
),
@@ -487,20 +545,49 @@ class _VideoPlayerPromptPosition extends StatelessWidget {
}
class _VideoPlayerPromptCard extends StatelessWidget {
/// See [_VideoPlayerPromptShell.backdrop].
final Widget? backdrop;
final Widget child;
const _VideoPlayerPromptCard({required this.child});
const _VideoPlayerPromptCard({this.backdrop, required this.child});
@override
Widget build(BuildContext context) {
final backdrop = this.backdrop;
final content = Padding(padding: const EdgeInsets.all(16), child: child);
final decoration = BoxDecoration(
color: Colors.black.withValues(alpha: 0.9),
borderRadius: const BorderRadius.all(Radius.circular(12)),
);
if (backdrop == null) {
return Container(width: 320, decoration: decoration, child: content);
}
return Container(
width: 320,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.9),
borderRadius: const BorderRadius.all(Radius.circular(12)),
clipBehavior: Clip.antiAlias,
decoration: decoration,
child: Stack(
children: [
Positioned.fill(child: backdrop),
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
stops: const [0.0, 0.35, 0.78],
colors: [
Colors.black.withValues(alpha: 0.05),
Colors.black.withValues(alpha: 0.25),
Colors.black.withValues(alpha: 0.92),
],
),
),
),
),
content,
],
),
child: child,
);
}
}
@@ -2,15 +2,22 @@ import 'dart:ui' show PointerDeviceKind;
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/media/ids.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_item.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/media/media_server_client.dart';
import 'package:plezy/providers/multi_server_provider.dart';
import 'package:plezy/providers/playback_state_provider.dart';
import 'package:plezy/screens/video_player/widgets/player_prompt_overlays.dart';
import 'package:plezy/services/pip_service.dart';
import 'package:plezy/services/settings_service.dart';
import 'package:plezy/widgets/optimized_media_image.dart';
import 'package:plezy/widgets/video_controls/player_chrome_controller.dart';
import 'package:provider/provider.dart';
import '../../test_helpers/media_items.dart';
import '../../test_helpers/multi_server_fixtures.dart';
import '../../test_helpers/prefs.dart';
/// The overlays read the countdown through a `ValueListenable` so the
/// per-second tick no longer rebuilds the whole player chrome. Tests own the
@@ -276,15 +283,94 @@ void main() {
expect(find.text('-1'), findsNothing);
expect(find.text('2'), findsNothing);
});
// The play-next card paints the next episode's video-frame still behind the
// text under a scrim (#2166), falling back to the original text-only card
// when no thumbnail can be served.
group('next episode thumbnail backdrop', () {
setUp(() async {
resetSharedPreferencesForTest();
SettingsService.resetForTesting();
await SettingsService.getInstance();
});
Widget pumpablePrompt(MediaItem episode, {MultiServerProvider? servers}) {
PipService().isPipActive.value = false;
final chromeController = PlayerChromeController();
final cancelFocusNode = FocusNode(debugLabel: 'TestCancel');
final confirmFocusNode = FocusNode(debugLabel: 'TestConfirm');
addTearDown(chromeController.dispose);
addTearDown(cancelFocusNode.dispose);
addTearDown(confirmFocusNode.dispose);
return _wrapPrompt(
VideoPlayerPlayNextOverlay(
visible: true,
nextEpisode: episode,
autoPlayCountdown: _countdown(),
cancelFocusNode: cancelFocusNode,
confirmFocusNode: confirmFocusNode,
chromeController: chromeController,
onCancel: () {},
onPlayNext: () {},
),
servers: servers,
);
}
testWidgets('renders the still behind the prompt when a client can serve the thumb', (tester) async {
final servers = testMultiServer(clients: [_StubThumbClient()]);
await tester.pumpWidget(pumpablePrompt(_thumbEpisode(), servers: servers.provider));
expect(find.byType(OptimizedMediaImage), findsOneWidget);
// Hide-spoilers is off, so the still is not blurred.
expect(find.byType(ImageFiltered), findsNothing);
// The text card content is unchanged on top of the backdrop.
expect(find.text('S1 E2 · Episode 2'), findsOneWidget);
expect(find.text('Cancel'), findsOneWidget);
});
testWidgets('keeps the text-only card when the episode has no thumb', (tester) async {
final servers = testMultiServer(clients: [_StubThumbClient()]);
await tester.pumpWidget(pumpablePrompt(_episode(), servers: servers.provider));
expect(find.byType(OptimizedMediaImage), findsNothing);
expect(find.text('S1 E2 · Episode 2'), findsOneWidget);
});
testWidgets('keeps the text-only card when nothing could serve the thumb', (tester) async {
// A thumb path but no registered client and no downloaded artwork:
// the card must not reserve backdrop space it cannot fill.
await tester.pumpWidget(pumpablePrompt(_thumbEpisode()));
expect(find.byType(OptimizedMediaImage), findsNothing);
expect(find.text('S1 E2 · Episode 2'), findsOneWidget);
});
testWidgets('blurs the unwatched still when hide-spoilers is on', (tester) async {
await SettingsService.instance.write(SettingsService.hideSpoilers, true);
final servers = testMultiServer(clients: [_StubThumbClient()]);
await tester.pumpWidget(pumpablePrompt(_thumbEpisode(), servers: servers.provider));
expect(find.byType(OptimizedMediaImage), findsOneWidget);
expect(find.byType(ImageFiltered), findsOneWidget);
});
});
}
Widget _wrapPrompt(Widget child) {
return ChangeNotifierProvider(
Widget _wrapPrompt(Widget child, {MultiServerProvider? servers}) {
Widget app = ChangeNotifierProvider(
create: (_) => PlaybackStateProvider(),
child: MaterialApp(
home: Scaffold(body: Stack(children: [child])),
),
);
if (servers != null) {
app = ChangeNotifierProvider<MultiServerProvider>.value(value: servers, child: app);
}
return app;
}
AnimatedPositioned _promptPosition(WidgetTester tester) {
@@ -301,3 +387,32 @@ MediaItem _episode() {
index: 2,
);
}
MediaItem _thumbEpisode() {
return testMediaItem(
id: 'episode-2',
backend: MediaBackend.plex,
kind: MediaKind.episode,
title: 'Episode 2',
parentIndex: 1,
index: 2,
serverId: 'server-1',
thumbPath: '/library/metadata/episode-2/thumb/1',
);
}
/// Registered under `server-1`; deliberately returns an unresolvable image URL
/// so the backdrop exercises the widget path without a network fetch.
class _StubThumbClient implements MediaServerClient {
@override
ServerId get serverId => ServerId('server-1');
@override
String thumbnailUrl(String? path, {int? width, int? height, bool cover = true}) => '';
@override
void close() {}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}