feat: add kBlurArtwork mode for store screenshots

Blurs all artwork and rotates vowels in metadata when enabled.
This commit is contained in:
edde746
2026-02-19 05:45:46 +01:00
parent d9bfb73146
commit bfe7d54dcf
9 changed files with 88 additions and 32 deletions
+2 -1
View File
@@ -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,
+11 -1
View File
@@ -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<String, dynamic> json) => _$PlexMetadataFromJson(json);
factory PlexMetadata.fromJson(Map<String, dynamic> json) =>
_$PlexMetadataFromJson(kBlurArtwork ? _obfuscateJson(json) : json);
static Map<String, dynamic> _obfuscateJson(Map<String, dynamic> json) {
final copy = Map<String, dynamic>.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<String, dynamic> toJson() => _$PlexMetadataToJson(this);
}
+11 -1
View File
@@ -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<String, dynamic> json) => _$PlexPlaylistFromJson(json);
factory PlexPlaylist.fromJson(Map<String, dynamic> json) {
if (kBlurArtwork) {
final copy = Map<String, dynamic>.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<String, dynamic> toJson() => _$PlexPlaylistToJson(this);
+5 -4
View File
@@ -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<DiscoverScreen>
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<DiscoverScreen>
imageType: ImageType.logo,
);
return CachedNetworkImage(
return blurArtwork(CachedNetworkImage(
imageUrl: logoUrl,
filterQuality: FilterQuality.medium,
fit: BoxFit.contain,
@@ -1696,7 +1697,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
),
);
},
);
), sigma: 10, clip: false);
},
),
)
@@ -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),
],
+4 -4
View File
@@ -1760,12 +1760,12 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> 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<MediaDetailScreen> 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<MediaDetailScreen> with WatchStateAw
errorWidget: (context, url, error) {
return _buildTitleText(context, metadata.title);
},
);
), sigma: 10, clip: false);
},
),
)
+3 -2
View File
@@ -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)
+46 -15
View File
@@ -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);
},
),
);
}
@@ -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<TimelineSlider> {
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(