diff --git a/lib/screens/video_player/widgets/player_prompt_overlays.dart b/lib/screens/video_player/widgets/player_prompt_overlays.dart index dcb6e3689..267ad498b 100644 --- a/lib/screens/video_player/widgets/player_prompt_overlays.dart +++ b/lib/screens/video_player/widgets/player_prompt_overlays.dart @@ -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( + 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 focusNodes; + + /// Full-bleed artwork painted behind [children] under a darkening scrim. + final Widget? backdrop; final List 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, ); } } diff --git a/test/screens/video_player/player_prompt_overlays_test.dart b/test/screens/video_player/player_prompt_overlays_test.dart index 72f638249..f22a5d0ef 100644 --- a/test/screens/video_player/player_prompt_overlays_test.dart +++ b/test/screens/video_player/player_prompt_overlays_test.dart @@ -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.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); +}