From 2fe0fdb97527d27df8e2fe7aa9d93c061cca9884 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sat, 27 Jun 2026 07:21:45 +0200 Subject: [PATCH] fix: hide player queue spoilers close #1426 --- .../video_controls/sheets/queue_sheet.dart | 107 ++++++----- .../video_controls/widgets/content_strip.dart | 169 +++++++++-------- .../widgets/media_selector_thumbnail.dart | 32 ++-- test/widgets/player_queue_spoilers_test.dart | 172 ++++++++++++++++++ 4 files changed, 339 insertions(+), 141 deletions(-) create mode 100644 test/widgets/player_queue_spoilers_test.dart diff --git a/lib/widgets/video_controls/sheets/queue_sheet.dart b/lib/widgets/video_controls/sheets/queue_sheet.dart index 44bb6cda..d59ccabc 100644 --- a/lib/widgets/video_controls/sheets/queue_sheet.dart +++ b/lib/widgets/video_controls/sheets/queue_sheet.dart @@ -6,12 +6,15 @@ import 'package:provider/provider.dart'; import '../../../i18n/strings.g.dart'; import '../../../media/media_item.dart'; +import '../../../media/media_item_types.dart'; import '../../../providers/playback_state_provider.dart'; +import '../../../services/settings_service.dart'; import '../../../theme/mono_tokens.dart'; import '../../../utils/provider_extensions.dart'; import '../../../utils/scroll_utils.dart'; import '../../../widgets/focusable_list_tile.dart'; import '../../../widgets/overlay_sheet.dart'; +import '../../../widgets/settings_builder.dart'; import '../widgets/media_selector_thumbnail.dart'; import 'base_video_control_sheet.dart'; import '../../optimized_media_image.dart'; @@ -40,65 +43,68 @@ class _QueueSheetState extends State { @override Widget build(BuildContext context) { - return Consumer( - builder: (context, playbackState, _) { - final items = playbackState.loadedItems; - final currentItemID = playbackState.currentPlayQueueItemID; + return SettingValueBuilder( + pref: SettingsService.hideSpoilers, + builder: (context, hideSpoilers, _) => Consumer( + builder: (context, playbackState, _) { + final items = playbackState.loadedItems; + final currentItemID = playbackState.currentPlayQueueItemID; - Widget content; - if (items.isEmpty) { - content = Center( - child: Text(t.videoControls.noQueueItems, style: TextStyle(color: tokens(context).textMuted)), - ); - } else { - final currentIndex = items.indexWhere((item) => playbackState.playQueueItemIdFor(item) == currentItemID); - _initialScroll.maybeScrollTo(currentIndex); + Widget content; + if (items.isEmpty) { + content = Center( + child: Text(t.videoControls.noQueueItems, style: TextStyle(color: tokens(context).textMuted)), + ); + } else { + final currentIndex = items.indexWhere((item) => playbackState.playQueueItemIdFor(item) == currentItemID); + _initialScroll.maybeScrollTo(currentIndex); - content = ListView.builder( - controller: _initialScroll.controller, - itemCount: items.length, - itemBuilder: (context, index) { - final item = items[index]; - final isCurrent = playbackState.playQueueItemIdFor(item) == currentItemID; + content = ListView.builder( + controller: _initialScroll.controller, + itemCount: items.length, + itemBuilder: (context, index) { + final item = items[index]; + final isCurrent = playbackState.playQueueItemIdFor(item) == currentItemID; - final primaryColor = Theme.of(context).colorScheme.primary; - return FocusableListTile( - key: index == 0 ? _initialScroll.firstItemKey : null, - leading: _buildThumbnail(context, item, isCurrent), - title: Text( - item.title ?? '', - style: TextStyle( - color: isCurrent ? primaryColor : null, - fontWeight: isCurrent ? FontWeight.bold : FontWeight.normal, + final primaryColor = Theme.of(context).colorScheme.primary; + return FocusableListTile( + key: index == 0 ? _initialScroll.firstItemKey : null, + leading: _buildThumbnail(context, item, isCurrent, hideSpoilers: hideSpoilers), + title: Text( + item.title ?? '', + style: TextStyle( + color: isCurrent ? primaryColor : null, + fontWeight: isCurrent ? FontWeight.bold : FontWeight.normal, + ), + maxLines: 1, + overflow: .ellipsis, ), - maxLines: 1, - overflow: .ellipsis, - ), - subtitle: Text( - _buildSubtitle(item), - style: TextStyle( - color: isCurrent ? primaryColor.withValues(alpha: 0.7) : tokens(context).textMuted, - fontSize: 12, + subtitle: Text( + _buildSubtitle(item), + style: TextStyle( + color: isCurrent ? primaryColor.withValues(alpha: 0.7) : tokens(context).textMuted, + fontSize: 12, + ), + maxLines: 1, + overflow: .ellipsis, ), - maxLines: 1, - overflow: .ellipsis, - ), - trailing: isCurrent ? AppIcon(Symbols.play_circle_rounded, fill: 1, color: primaryColor) : null, - onTap: () { - widget.onItemSelected(item); - OverlaySheetController.of(context).close(); - }, - ); - }, - ); - } + trailing: isCurrent ? AppIcon(Symbols.play_circle_rounded, fill: 1, color: primaryColor) : null, + onTap: () { + widget.onItemSelected(item); + OverlaySheetController.of(context).close(); + }, + ); + }, + ); + } - return BaseVideoControlSheet(title: t.videoControls.queue, icon: Symbols.queue_rounded, child: content); - }, + return BaseVideoControlSheet(title: t.videoControls.queue, icon: Symbols.queue_rounded, child: content); + }, + ), ); } - Widget? _buildThumbnail(BuildContext context, MediaItem item, bool isCurrent) { + Widget? _buildThumbnail(BuildContext context, MediaItem item, bool isCurrent, {required bool hideSpoilers}) { if (item.thumbPath == null) return null; // Try to get client for thumbnails, may fail in offline mode @@ -118,6 +124,7 @@ class _QueueSheetState extends State { ), isCurrent: isCurrent, borderColor: Theme.of(context).colorScheme.primary, + blurThumbnail: hideSpoilers && item.shouldHideSpoiler, ); } diff --git a/lib/widgets/video_controls/widgets/content_strip.dart b/lib/widgets/video_controls/widgets/content_strip.dart index aebea991..cb50ae5f 100644 --- a/lib/widgets/video_controls/widgets/content_strip.dart +++ b/lib/widgets/video_controls/widgets/content_strip.dart @@ -10,10 +10,12 @@ import '../../../focus/dpad_navigator.dart'; import '../../../focus/focusable_wrapper.dart'; import '../../../i18n/strings.g.dart'; import '../../../media/media_item.dart'; +import '../../../media/media_item_types.dart'; import '../../../media/media_server_client.dart'; import '../../../mpv/mpv.dart'; import '../../../media/media_source_info.dart'; import '../../../providers/playback_state_provider.dart'; +import '../../../services/settings_service.dart'; import '../../../services/download_storage_service.dart'; import '../../../utils/formatters.dart'; import '../../../utils/player_utils.dart'; @@ -22,6 +24,7 @@ import '../../../utils/scroll_utils.dart'; import '../../app_icon.dart'; import '../../clickable_cursor.dart'; import '../../optimized_media_image.dart'; +import '../../settings_builder.dart'; import 'media_selector_thumbnail.dart'; /// Horizontal scrollable strip of chapter/queue items shown on swipe-up. @@ -475,93 +478,97 @@ class ContentStripState extends State { final thumbWidth = isTablet ? 200.0 : 120.0; final thumbHeight = isTablet ? 112.0 : 68.0; - return Consumer( - builder: (context, playbackState, _) { - final items = playbackState.loadedItems; - final currentItemID = playbackState.currentPlayQueueItemID; - final currentIndex = currentItemID == null - ? -1 - : items.indexWhere((item) => playbackState.playQueueItemIdFor(item) == currentItemID); + return SettingValueBuilder( + pref: SettingsService.hideSpoilers, + builder: (context, hideSpoilers, _) => Consumer( + builder: (context, playbackState, _) { + final items = playbackState.loadedItems; + final currentItemID = playbackState.currentPlayQueueItemID; + final currentIndex = currentItemID == null + ? -1 + : items.indexWhere((item) => playbackState.playQueueItemIdFor(item) == currentItemID); - _trimItemKeys(_queueItemKeys, items.length); + _trimItemKeys(_queueItemKeys, items.length); - if (currentIndex >= 0 && - (_lastAutoScrolledQueueItemID != currentItemID || _lastAutoScrolledQueueIndex != currentIndex)) { - _lastAutoScrolledQueueItemID = currentItemID; - _lastAutoScrolledQueueIndex = currentIndex; - _autoScrollTo( - _queueScrollController, - _queueItemKeys, - currentIndex, - isTablet: isTablet, - isCurrent: () => - _lastAutoScrolledQueueItemID == currentItemID && _lastAutoScrolledQueueIndex == currentIndex, - ); - } - - if (widget.useFocusNavigation) { - _ensureFocusNodes(_queueFocusNodes, items.length, 'QueueFocus'); - } - - return ListView.builder( - controller: _queueScrollController, - scrollDirection: Axis.horizontal, - clipBehavior: widget.useFocusNavigation ? Clip.none : Clip.hardEdge, - itemCount: items.length, - padding: .symmetric(horizontal: widget.useFocusNavigation ? 12 : 4), - itemBuilder: (context, index) { - final item = items[index]; - final isCurrent = playbackState.playQueueItemIdFor(item) == currentItemID; - - final client = item.serverId != null - ? context.tryGetMediaClientForServer(serverIdOrNull(item.serverId)) - : null; - - void onTap() => widget.onQueueItemSelected?.call(item); - - final itemKey = _itemKeyFor(_queueItemKeys, index); - final stripItem = _buildStripItem( - key: itemKey, - isCurrent: isCurrent, + if (currentIndex >= 0 && + (_lastAutoScrolledQueueItemID != currentItemID || _lastAutoScrolledQueueIndex != currentIndex)) { + _lastAutoScrolledQueueItemID = currentItemID; + _lastAutoScrolledQueueIndex = currentIndex; + _autoScrollTo( + _queueScrollController, + _queueItemKeys, + currentIndex, isTablet: isTablet, - thumbnail: item.thumbPath != null - ? OptimizedMediaImage.thumb( - client: client, - imagePath: item.thumbPath, - width: thumbWidth, - height: thumbHeight, - fit: BoxFit.cover, - errorWidget: (_, _, _) => - const AppIcon(Symbols.image_rounded, fill: 1, color: Colors.white54, size: 34), - ) - : null, - title: item.title ?? '', - subtitle: _buildQueueSubtitle(item), - onTap: onTap, + isCurrent: () => + _lastAutoScrolledQueueItemID == currentItemID && _lastAutoScrolledQueueIndex == currentIndex, ); + } - if (widget.useFocusNavigation) { - return Align( - alignment: .topCenter, - child: FocusableWrapper( - focusNode: _queueFocusNodes[index], - onSelect: onTap, - onKeyEvent: (_, event) => _handleFocusItemKeyEvent(event, index, items.length, _StripTab.queue), - onFocusChange: (hasFocus) { - if (hasFocus) widget.onFocusActivity?.call(); - }, - borderRadius: 6, - autoScroll: false, - useBackgroundFocus: true, - child: stripItem, - ), + if (widget.useFocusNavigation) { + _ensureFocusNodes(_queueFocusNodes, items.length, 'QueueFocus'); + } + + return ListView.builder( + controller: _queueScrollController, + scrollDirection: Axis.horizontal, + clipBehavior: widget.useFocusNavigation ? Clip.none : Clip.hardEdge, + itemCount: items.length, + padding: .symmetric(horizontal: widget.useFocusNavigation ? 12 : 4), + itemBuilder: (context, index) { + final item = items[index]; + final isCurrent = playbackState.playQueueItemIdFor(item) == currentItemID; + + final client = item.serverId != null + ? context.tryGetMediaClientForServer(serverIdOrNull(item.serverId)) + : null; + + void onTap() => widget.onQueueItemSelected?.call(item); + + final itemKey = _itemKeyFor(_queueItemKeys, index); + final stripItem = _buildStripItem( + key: itemKey, + isCurrent: isCurrent, + isTablet: isTablet, + thumbnail: item.thumbPath != null + ? OptimizedMediaImage.thumb( + client: client, + imagePath: item.thumbPath, + width: thumbWidth, + height: thumbHeight, + fit: BoxFit.cover, + errorWidget: (_, _, _) => + const AppIcon(Symbols.image_rounded, fill: 1, color: Colors.white54, size: 34), + ) + : null, + blurThumbnail: hideSpoilers && item.shouldHideSpoiler, + title: item.title ?? '', + subtitle: _buildQueueSubtitle(item), + onTap: onTap, ); - } - return stripItem; - }, - ); - }, + if (widget.useFocusNavigation) { + return Align( + alignment: .topCenter, + child: FocusableWrapper( + focusNode: _queueFocusNodes[index], + onSelect: onTap, + onKeyEvent: (_, event) => _handleFocusItemKeyEvent(event, index, items.length, _StripTab.queue), + onFocusChange: (hasFocus) { + if (hasFocus) widget.onFocusActivity?.call(); + }, + borderRadius: 6, + autoScroll: false, + useBackgroundFocus: true, + child: stripItem, + ), + ); + } + + return stripItem; + }, + ); + }, + ), ); } @@ -584,6 +591,7 @@ class ContentStripState extends State { required String title, required String subtitle, required VoidCallback onTap, + bool blurThumbnail = false, bool isTablet = false, }) { final itemWidth = isTablet ? 200.0 : 120.0; @@ -610,6 +618,7 @@ class ContentStripState extends State { isCurrent: isCurrent, borderColor: Colors.white, radius: 6, + blurThumbnail: blurThumbnail, ), const SizedBox(height: 4), Text( diff --git a/lib/widgets/video_controls/widgets/media_selector_thumbnail.dart b/lib/widgets/video_controls/widgets/media_selector_thumbnail.dart index 401b878d..e0ef6f64 100644 --- a/lib/widgets/video_controls/widgets/media_selector_thumbnail.dart +++ b/lib/widgets/video_controls/widgets/media_selector_thumbnail.dart @@ -1,3 +1,5 @@ +import 'dart:ui'; + import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; @@ -14,6 +16,7 @@ class MediaSelectorThumbnail extends StatelessWidget { final Color fallbackIconColor; final double fallbackIconSize; final IconData fallbackIcon; + final bool blurThumbnail; const MediaSelectorThumbnail({ super.key, @@ -27,27 +30,27 @@ class MediaSelectorThumbnail extends StatelessWidget { this.fallbackIconColor = Colors.white38, this.fallbackIconSize = 28, this.fallbackIcon = Symbols.movie_rounded, + this.blurThumbnail = false, }); @override Widget build(BuildContext context) { final borderRadius = BorderRadius.all(Radius.circular(radius)); + final child = thumbnail != null + ? _maybeBlurThumbnail(thumbnail!) + : Container( + color: fallbackBackgroundColor, + child: Center( + child: AppIcon(fallbackIcon, fill: 1, color: fallbackIconColor, size: fallbackIconSize), + ), + ); + return SizedBox( width: width, height: height, child: Stack( children: [ - ClipRRect( - borderRadius: borderRadius, - child: - thumbnail ?? - Container( - color: fallbackBackgroundColor, - child: Center( - child: AppIcon(fallbackIcon, fill: 1, color: fallbackIconColor, size: fallbackIconSize), - ), - ), - ), + ClipRRect(borderRadius: borderRadius, child: child), if (isCurrent) Positioned.fill( child: DecoratedBox( @@ -61,4 +64,11 @@ class MediaSelectorThumbnail extends StatelessWidget { ), ); } + + Widget _maybeBlurThumbnail(Widget child) { + if (!blurThumbnail) return child; + return ClipRect( + child: ImageFiltered(imageFilter: ImageFilter.blur(sigmaX: 12, sigmaY: 12), child: child), + ); + } } diff --git a/test/widgets/player_queue_spoilers_test.dart b/test/widgets/player_queue_spoilers_test.dart new file mode 100644 index 00000000..d43c4028 --- /dev/null +++ b/test/widgets/player_queue_spoilers_test.dart @@ -0,0 +1,172 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/i18n/strings.g.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/play_queue.dart'; +import 'package:plezy/mpv/mpv.dart'; +import 'package:plezy/providers/playback_state_provider.dart'; +import 'package:plezy/services/settings_service.dart'; +import 'package:plezy/theme/mono_tokens.dart'; +import 'package:plezy/widgets/video_controls/sheets/queue_sheet.dart'; +import 'package:plezy/widgets/video_controls/widgets/content_strip.dart'; +import 'package:plezy/widgets/video_controls/widgets/media_selector_thumbnail.dart'; +import 'package:provider/provider.dart'; + +import '../test_helpers/prefs.dart'; + +const _testTokens = MonoTokens( + radiusSm: 8, + radiusMd: 12, + space: 8, + fast: Duration(milliseconds: 1), + normal: Duration(milliseconds: 1), + slow: Duration(milliseconds: 1), + bg: Colors.black, + surface: Colors.black, + outline: Colors.white24, + text: Colors.white, + textMuted: Colors.white70, + splashFactory: NoSplash.splashFactory, +); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() async { + LocaleSettings.setLocaleSync(AppLocale.en); + resetSharedPreferencesForTest(); + SettingsService.resetForTesting(); + await SettingsService.getInstance(); + }); + + testWidgets('MediaSelectorThumbnail applies blur only to real thumbnails', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: MediaSelectorThumbnail( + width: 60, + height: 34, + thumbnail: ColoredBox(color: Colors.red), + isCurrent: true, + borderColor: Colors.white, + blurThumbnail: true, + ), + ), + ); + + expect(find.byType(ImageFiltered), findsOneWidget); + + await tester.pumpWidget( + const MaterialApp( + home: MediaSelectorThumbnail( + width: 60, + height: 34, + thumbnail: null, + isCurrent: true, + borderColor: Colors.white, + blurThumbnail: true, + ), + ), + ); + + expect(find.byType(ImageFiltered), findsNothing); + }); + + testWidgets('content strip queue blurs spoiler episode thumbnails', (tester) async { + await SettingsService.instance.write(SettingsService.hideSpoilers, true); + final playback = _playbackWithQueue(); + addTearDown(playback.dispose); + + await tester.pumpWidget( + _queueHarness( + playback: playback, + child: ContentStrip( + player: _FakePlayer(), + chapters: const [], + chaptersLoaded: true, + showQueueTab: true, + onQueueItemSelected: (_) {}, + ), + ), + ); + await tester.pump(); + + final thumbnails = tester.widgetList(find.byType(MediaSelectorThumbnail)).toList(); + + expect(thumbnails.map((thumbnail) => thumbnail.blurThumbnail), [true, false, false]); + }); + + testWidgets('queue sheet blurs spoiler episode thumbnails', (tester) async { + await SettingsService.instance.write(SettingsService.hideSpoilers, true); + final playback = _playbackWithQueue(); + addTearDown(playback.dispose); + + await tester.pumpWidget( + _queueHarness( + playback: playback, + child: QueueSheet(onItemSelected: (_) {}), + ), + ); + await tester.pump(); + + final thumbnails = tester.widgetList(find.byType(MediaSelectorThumbnail)).toList(); + + expect(thumbnails.map((thumbnail) => thumbnail.blurThumbnail), [true, false, false]); + }); +} + +Widget _queueHarness({required PlaybackStateProvider playback, required Widget child}) { + return ChangeNotifierProvider.value( + value: playback, + child: MaterialApp( + theme: ThemeData(extensions: const [_testTokens]), + home: Scaffold(body: SizedBox(width: 600, height: 400, child: child)), + ), + ); +} + +PlaybackStateProvider _playbackWithQueue() { + final playback = PlaybackStateProvider(); + playback.setPlaybackFromLocalQueue( + LocalPlayQueue( + id: 'test-queue', + backendId: MediaBackend.plex.id, + currentIndex: 0, + items: [ + _episode('spoiler-episode', title: 'Spoiler Episode'), + _episode('watched-episode', title: 'Watched Episode', viewCount: 1), + MediaItem( + id: 'movie', + backend: MediaBackend.plex, + kind: MediaKind.movie, + title: 'Movie', + thumbPath: 'https://example.invalid/movie.jpg', + ), + ], + ), + ); + return playback; +} + +MediaItem _episode(String id, {required String title, int? viewCount}) { + return MediaItem( + id: id, + backend: MediaBackend.plex, + kind: MediaKind.episode, + title: title, + grandparentTitle: 'Show', + parentIndex: 1, + index: 1, + viewCount: viewCount, + thumbPath: 'https://example.invalid/$id.jpg', + ); +} + +class _FakePlayer implements Player { + @override + PlayerState get state => PlayerState(); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +}