From bfe7d54dcf11b09478bb139f0cedd36ed979bc1f Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Thu, 19 Feb 2026 05:45:46 +0100 Subject: [PATCH] feat: add kBlurArtwork mode for store screenshots Blurs all artwork and rotates vowels in metadata when enabled. --- lib/models/plex_hub.dart | 3 +- lib/models/plex_metadata.dart | 12 +++- lib/models/plex_playlist.dart | 12 +++- lib/screens/discover_screen.dart | 9 +-- lib/screens/livetv/program_details_sheet.dart | 5 +- lib/screens/media_detail_screen.dart | 8 +-- lib/screens/profile/user_avatar_widget.dart | 5 +- lib/widgets/plex_optimized_image.dart | 61 ++++++++++++++----- .../widgets/timeline_slider.dart | 5 +- 9 files changed, 88 insertions(+), 32 deletions(-) diff --git a/lib/models/plex_hub.dart b/lib/models/plex_hub.dart index ba792473..89eb6520 100644 --- a/lib/models/plex_hub.dart +++ b/lib/models/plex_hub.dart @@ -1,3 +1,4 @@ +import '../widgets/plex_optimized_image.dart' show kBlurArtwork, obfuscateText; import 'plex_metadata.dart'; /// Represents a Plex hub/recommendation section (e.g., Trending Movies, Top Thrillers) @@ -54,7 +55,7 @@ class PlexHub { return PlexHub( hubKey: json['key'] as String? ?? '', - title: json['title'] as String? ?? 'Unknown', + title: kBlurArtwork ? obfuscateText(json['title'] as String? ?? 'Unknown') : json['title'] as String? ?? 'Unknown', type: json['type'] as String? ?? 'hub', hubIdentifier: json['hubIdentifier'] as String?, size: (json['size'] as num?)?.toInt() ?? metadataList.length, diff --git a/lib/models/plex_metadata.dart b/lib/models/plex_metadata.dart index 2e5a93a5..1070ae57 100644 --- a/lib/models/plex_metadata.dart +++ b/lib/models/plex_metadata.dart @@ -1,6 +1,7 @@ import 'package:json_annotation/json_annotation.dart'; import '../services/settings_service.dart' show EpisodePosterMode; +import '../widgets/plex_optimized_image.dart' show kBlurArtwork, obfuscateText; import 'mixins/multi_server_fields.dart'; import 'plex_role.dart'; @@ -400,7 +401,16 @@ class PlexMetadata with MultiServerFields { return viewCount != null && viewCount! > 0; } - factory PlexMetadata.fromJson(Map json) => _$PlexMetadataFromJson(json); + factory PlexMetadata.fromJson(Map json) => + _$PlexMetadataFromJson(kBlurArtwork ? _obfuscateJson(json) : json); + + static Map _obfuscateJson(Map json) { + final copy = Map.from(json); + for (final key in const ['title', 'summary', 'tagline', 'grandparentTitle', 'parentTitle', 'studio']) { + if (copy[key] is String) copy[key] = obfuscateText(copy[key] as String); + } + return copy; + } Map toJson() => _$PlexMetadataToJson(this); } diff --git a/lib/models/plex_playlist.dart b/lib/models/plex_playlist.dart index 81154f6a..8f72e874 100644 --- a/lib/models/plex_playlist.dart +++ b/lib/models/plex_playlist.dart @@ -1,5 +1,6 @@ import 'package:json_annotation/json_annotation.dart'; +import '../widgets/plex_optimized_image.dart' show kBlurArtwork, obfuscateText; import 'mixins/multi_server_fields.dart'; part 'plex_playlist.g.dart'; @@ -94,7 +95,16 @@ class PlexPlaylist with MultiServerFields { /// Playlists don't track viewed leaf count int? get viewedLeafCount => null; - factory PlexPlaylist.fromJson(Map json) => _$PlexPlaylistFromJson(json); + factory PlexPlaylist.fromJson(Map json) { + if (kBlurArtwork) { + final copy = Map.from(json); + for (final key in const ['title', 'summary']) { + if (copy[key] is String) copy[key] = obfuscateText(copy[key] as String); + } + return _$PlexPlaylistFromJson(copy); + } + return _$PlexPlaylistFromJson(json); + } Map toJson() => _$PlexPlaylistToJson(this); diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index c02ef729..8a4ad358 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -9,6 +9,7 @@ import '../focus/dpad_navigator.dart'; import 'package:cached_network_image/cached_network_image.dart'; import '../../services/plex_client.dart'; import '../utils/plex_image_helper.dart'; +import '../widgets/plex_optimized_image.dart' show blurArtwork; import '../models/plex_metadata.dart'; import '../utils/content_utils.dart'; import '../models/plex_hub.dart'; @@ -1581,14 +1582,14 @@ class _DiscoverScreenState extends State imageType: ImageType.art, ); - return CachedNetworkImage( + return blurArtwork(CachedNetworkImage( imageUrl: imageUrl, fit: BoxFit.cover, placeholder: (context, url) => Container(color: Theme.of(context).colorScheme.surfaceContainerHighest), errorWidget: (context, url, error) => Container(color: Theme.of(context).colorScheme.surfaceContainerHighest), - ); + )); }, ), ), @@ -1649,7 +1650,7 @@ class _DiscoverScreenState extends State imageType: ImageType.logo, ); - return CachedNetworkImage( + return blurArtwork(CachedNetworkImage( imageUrl: logoUrl, filterQuality: FilterQuality.medium, fit: BoxFit.contain, @@ -1696,7 +1697,7 @@ class _DiscoverScreenState extends State ), ); }, - ); + ), sigma: 10, clip: false); }, ), ) diff --git a/lib/screens/livetv/program_details_sheet.dart b/lib/screens/livetv/program_details_sheet.dart index 5065ee31..adea1db6 100644 --- a/lib/screens/livetv/program_details_sheet.dart +++ b/lib/screens/livetv/program_details_sheet.dart @@ -8,6 +8,7 @@ import '../../models/livetv_program.dart'; import '../../utils/formatters.dart'; import '../../widgets/app_icon.dart'; import '../../widgets/overlay_sheet.dart'; +import '../../widgets/plex_optimized_image.dart' show blurArtwork; /// Shows a bottom sheet with program details and actions (Record, Watch Channel, Play). void showProgramDetailsSheet( @@ -218,13 +219,13 @@ class _ProgramDetailsSheetContentState extends State<_ProgramDetailsSheetContent if (widget.posterUrl != null) ...[ ClipRRect( borderRadius: const BorderRadius.all(Radius.circular(6)), - child: Image.network( + child: blurArtwork(Image.network( widget.posterUrl!, width: 80, height: 120, fit: BoxFit.cover, errorBuilder: (_, _, _) => const SizedBox.shrink(), - ), + )), ), const SizedBox(width: 14), ], diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index fcea8def..810771c5 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -1760,12 +1760,12 @@ class _MediaDetailScreenState extends State with WatchStateAw imageType: ImageType.art, ); - return CachedNetworkImage( + return blurArtwork(CachedNetworkImage( imageUrl: imageUrl, fit: BoxFit.cover, placeholder: (context, url) => const PlaceholderContainer(), errorWidget: (context, url, error) => const PlaceholderContainer(), - ); + )); }, ) : const PlaceholderContainer(), @@ -1844,7 +1844,7 @@ class _MediaDetailScreenState extends State with WatchStateAw imageType: ImageType.logo, ); - return CachedNetworkImage( + return blurArtwork(CachedNetworkImage( imageUrl: logoUrl, filterQuality: FilterQuality.medium, fit: BoxFit.contain, @@ -1868,7 +1868,7 @@ class _MediaDetailScreenState extends State with WatchStateAw errorWidget: (context, url, error) { return _buildTitleText(context, metadata.title); }, - ); + ), sigma: 10, clip: false); }, ), ) diff --git a/lib/screens/profile/user_avatar_widget.dart b/lib/screens/profile/user_avatar_widget.dart index a4f0d662..d0ec608f 100644 --- a/lib/screens/profile/user_avatar_widget.dart +++ b/lib/screens/profile/user_avatar_widget.dart @@ -5,6 +5,7 @@ import 'package:cached_network_image/cached_network_image.dart'; import '../../models/plex_home_user.dart'; import '../../theme/mono_tokens.dart'; import '../../i18n/strings.g.dart'; +import '../../widgets/plex_optimized_image.dart' show blurArtwork; class UserAvatarWidget extends StatelessWidget { final PlexHomeUser user; @@ -141,14 +142,14 @@ class UserAvatarWidget extends StatelessWidget { children: [ // Avatar image ClipOval( - child: CachedNetworkImage( + child: blurArtwork(CachedNetworkImage( imageUrl: user.thumb, width: size, height: size, fit: BoxFit.cover, placeholder: (ctx, url) => _buildPlaceholderAvatar(theme), errorWidget: (ctx, url, error) => _buildPlaceholderAvatar(theme), - ), + )), ), // Indicators (only show icon indicators when not using text labels) diff --git a/lib/widgets/plex_optimized_image.dart b/lib/widgets/plex_optimized_image.dart index 304468b6..abe25620 100644 --- a/lib/widgets/plex_optimized_image.dart +++ b/lib/widgets/plex_optimized_image.dart @@ -1,4 +1,5 @@ import 'dart:io'; +import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:plezy/widgets/app_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; @@ -7,6 +8,32 @@ import '../../services/plex_client.dart'; import '../utils/plex_image_helper.dart'; import 'media_card.dart'; +/// Set to `true` to blur all artwork (for store screenshots). +const kBlurArtwork = false; + +/// Wraps [child] with a blur filter when [kBlurArtwork] is `true`. +/// Rotates vowels (a→e, e→i, i→o, o→u, u→a) when [kBlurArtwork] is `true`. +String obfuscateText(String text) { + if (!kBlurArtwork) return text; + const from = 'aeiouAEIOU'; + const to = 'eiouaEIOUA'; + final buf = StringBuffer(); + for (var i = 0; i < text.length; i++) { + final idx = from.indexOf(text[i]); + buf.write(idx >= 0 ? to[idx] : text[i]); + } + return buf.toString(); +} + +Widget blurArtwork(Widget child, {double sigma = 30, bool clip = true}) { + if (!kBlurArtwork) return child; + final filtered = ImageFiltered( + imageFilter: ImageFilter.blur(sigmaX: sigma, sigmaY: sigma), + child: child, + ); + return clip ? ClipRect(child: filtered) : filtered; +} + class PlexOptimizedImage extends StatelessWidget { final PlexClient? client; final String? imagePath; @@ -230,14 +257,16 @@ class PlexOptimizedImage extends StatelessWidget { if (localFilePath != null) { final file = File(localFilePath!); if (file.existsSync()) { - return Image.file( - file, - width: width, - height: height, - fit: fit, - filterQuality: filterQuality, - alignment: alignment, - errorBuilder: (context, error, stackTrace) => _buildErrorWidget(context, error), + return blurArtwork( + Image.file( + file, + width: width, + height: height, + fit: fit, + filterQuality: filterQuality, + alignment: alignment, + errorBuilder: (context, error, stackTrace) => _buildErrorWidget(context, error), + ), ); } } @@ -249,15 +278,17 @@ class PlexOptimizedImage extends StatelessWidget { // Fast path: skip LayoutBuilder when both dimensions are explicitly known if (_hasKnownDimensions) { - return _buildCachedImage(context, width!, height!); + return blurArtwork(_buildCachedImage(context, width!, height!)); } - return LayoutBuilder( - builder: (context, constraints) { - final effectiveWidth = _resolvedDimension(width, constraints.maxWidth, 300.0); - final effectiveHeight = _resolvedDimension(height, constraints.maxHeight, 450.0); - return _buildCachedImage(context, effectiveWidth, effectiveHeight); - }, + return blurArtwork( + LayoutBuilder( + builder: (context, constraints) { + final effectiveWidth = _resolvedDimension(width, constraints.maxWidth, 300.0); + final effectiveHeight = _resolvedDimension(height, constraints.maxHeight, 450.0); + return _buildCachedImage(context, effectiveWidth, effectiveHeight); + }, + ), ); } diff --git a/lib/widgets/video_controls/widgets/timeline_slider.dart b/lib/widgets/video_controls/widgets/timeline_slider.dart index b70c51f4..90d99b53 100644 --- a/lib/widgets/video_controls/widgets/timeline_slider.dart +++ b/lib/widgets/video_controls/widgets/timeline_slider.dart @@ -7,6 +7,7 @@ import '../../../i18n/strings.g.dart'; import '../../../focus/focusable_wrapper.dart'; import '../../../utils/formatters.dart'; import '../painters/chapter_marker_painter.dart'; +import '../../plex_optimized_image.dart' show blurArtwork; /// Timeline slider with chapter markers for video playback /// @@ -125,13 +126,13 @@ class _TimelineSliderState extends State { boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.5), blurRadius: 8, spreadRadius: 1)], ), clipBehavior: Clip.antiAlias, - child: CachedNetworkImage( + child: blurArtwork(CachedNetworkImage( imageUrl: thumbnailUrl, fit: BoxFit.cover, fadeInDuration: Duration.zero, placeholder: (_, _) => const SizedBox.shrink(), // Show nothing for placeholder errorWidget: (_, _, _) => const SizedBox.shrink(), // Show nothing for errors - ), + )), ), if (hasThumbnail) const SizedBox(height: 4), Container(