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:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user