feat: add kBlurArtwork mode for store screenshots
Blurs all artwork and rotates vowels in metadata when enabled.
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
|
import '../widgets/plex_optimized_image.dart' show kBlurArtwork, obfuscateText;
|
||||||
import 'plex_metadata.dart';
|
import 'plex_metadata.dart';
|
||||||
|
|
||||||
/// Represents a Plex hub/recommendation section (e.g., Trending Movies, Top Thrillers)
|
/// Represents a Plex hub/recommendation section (e.g., Trending Movies, Top Thrillers)
|
||||||
@@ -54,7 +55,7 @@ class PlexHub {
|
|||||||
|
|
||||||
return PlexHub(
|
return PlexHub(
|
||||||
hubKey: json['key'] as String? ?? '',
|
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',
|
type: json['type'] as String? ?? 'hub',
|
||||||
hubIdentifier: json['hubIdentifier'] as String?,
|
hubIdentifier: json['hubIdentifier'] as String?,
|
||||||
size: (json['size'] as num?)?.toInt() ?? metadataList.length,
|
size: (json['size'] as num?)?.toInt() ?? metadataList.length,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:json_annotation/json_annotation.dart';
|
import 'package:json_annotation/json_annotation.dart';
|
||||||
|
|
||||||
import '../services/settings_service.dart' show EpisodePosterMode;
|
import '../services/settings_service.dart' show EpisodePosterMode;
|
||||||
|
import '../widgets/plex_optimized_image.dart' show kBlurArtwork, obfuscateText;
|
||||||
import 'mixins/multi_server_fields.dart';
|
import 'mixins/multi_server_fields.dart';
|
||||||
import 'plex_role.dart';
|
import 'plex_role.dart';
|
||||||
|
|
||||||
@@ -400,7 +401,16 @@ class PlexMetadata with MultiServerFields {
|
|||||||
return viewCount != null && viewCount! > 0;
|
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);
|
Map<String, dynamic> toJson() => _$PlexMetadataToJson(this);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:json_annotation/json_annotation.dart';
|
import 'package:json_annotation/json_annotation.dart';
|
||||||
|
|
||||||
|
import '../widgets/plex_optimized_image.dart' show kBlurArtwork, obfuscateText;
|
||||||
import 'mixins/multi_server_fields.dart';
|
import 'mixins/multi_server_fields.dart';
|
||||||
|
|
||||||
part 'plex_playlist.g.dart';
|
part 'plex_playlist.g.dart';
|
||||||
@@ -94,7 +95,16 @@ class PlexPlaylist with MultiServerFields {
|
|||||||
/// Playlists don't track viewed leaf count
|
/// Playlists don't track viewed leaf count
|
||||||
int? get viewedLeafCount => null;
|
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);
|
Map<String, dynamic> toJson() => _$PlexPlaylistToJson(this);
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import '../focus/dpad_navigator.dart';
|
|||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import '../../services/plex_client.dart';
|
import '../../services/plex_client.dart';
|
||||||
import '../utils/plex_image_helper.dart';
|
import '../utils/plex_image_helper.dart';
|
||||||
|
import '../widgets/plex_optimized_image.dart' show blurArtwork;
|
||||||
import '../models/plex_metadata.dart';
|
import '../models/plex_metadata.dart';
|
||||||
import '../utils/content_utils.dart';
|
import '../utils/content_utils.dart';
|
||||||
import '../models/plex_hub.dart';
|
import '../models/plex_hub.dart';
|
||||||
@@ -1581,14 +1582,14 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
|||||||
imageType: ImageType.art,
|
imageType: ImageType.art,
|
||||||
);
|
);
|
||||||
|
|
||||||
return CachedNetworkImage(
|
return blurArtwork(CachedNetworkImage(
|
||||||
imageUrl: imageUrl,
|
imageUrl: imageUrl,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
placeholder: (context, url) =>
|
placeholder: (context, url) =>
|
||||||
Container(color: Theme.of(context).colorScheme.surfaceContainerHighest),
|
Container(color: Theme.of(context).colorScheme.surfaceContainerHighest),
|
||||||
errorWidget: (context, url, error) =>
|
errorWidget: (context, url, error) =>
|
||||||
Container(color: Theme.of(context).colorScheme.surfaceContainerHighest),
|
Container(color: Theme.of(context).colorScheme.surfaceContainerHighest),
|
||||||
);
|
));
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1649,7 +1650,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
|||||||
imageType: ImageType.logo,
|
imageType: ImageType.logo,
|
||||||
);
|
);
|
||||||
|
|
||||||
return CachedNetworkImage(
|
return blurArtwork(CachedNetworkImage(
|
||||||
imageUrl: logoUrl,
|
imageUrl: logoUrl,
|
||||||
filterQuality: FilterQuality.medium,
|
filterQuality: FilterQuality.medium,
|
||||||
fit: BoxFit.contain,
|
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 '../../utils/formatters.dart';
|
||||||
import '../../widgets/app_icon.dart';
|
import '../../widgets/app_icon.dart';
|
||||||
import '../../widgets/overlay_sheet.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).
|
/// Shows a bottom sheet with program details and actions (Record, Watch Channel, Play).
|
||||||
void showProgramDetailsSheet(
|
void showProgramDetailsSheet(
|
||||||
@@ -218,13 +219,13 @@ class _ProgramDetailsSheetContentState extends State<_ProgramDetailsSheetContent
|
|||||||
if (widget.posterUrl != null) ...[
|
if (widget.posterUrl != null) ...[
|
||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: const BorderRadius.all(Radius.circular(6)),
|
borderRadius: const BorderRadius.all(Radius.circular(6)),
|
||||||
child: Image.network(
|
child: blurArtwork(Image.network(
|
||||||
widget.posterUrl!,
|
widget.posterUrl!,
|
||||||
width: 80,
|
width: 80,
|
||||||
height: 120,
|
height: 120,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
errorBuilder: (_, _, _) => const SizedBox.shrink(),
|
errorBuilder: (_, _, _) => const SizedBox.shrink(),
|
||||||
),
|
)),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 14),
|
const SizedBox(width: 14),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1760,12 +1760,12 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> with WatchStateAw
|
|||||||
imageType: ImageType.art,
|
imageType: ImageType.art,
|
||||||
);
|
);
|
||||||
|
|
||||||
return CachedNetworkImage(
|
return blurArtwork(CachedNetworkImage(
|
||||||
imageUrl: imageUrl,
|
imageUrl: imageUrl,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
placeholder: (context, url) => const PlaceholderContainer(),
|
placeholder: (context, url) => const PlaceholderContainer(),
|
||||||
errorWidget: (context, url, error) => const PlaceholderContainer(),
|
errorWidget: (context, url, error) => const PlaceholderContainer(),
|
||||||
);
|
));
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
: const PlaceholderContainer(),
|
: const PlaceholderContainer(),
|
||||||
@@ -1844,7 +1844,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> with WatchStateAw
|
|||||||
imageType: ImageType.logo,
|
imageType: ImageType.logo,
|
||||||
);
|
);
|
||||||
|
|
||||||
return CachedNetworkImage(
|
return blurArtwork(CachedNetworkImage(
|
||||||
imageUrl: logoUrl,
|
imageUrl: logoUrl,
|
||||||
filterQuality: FilterQuality.medium,
|
filterQuality: FilterQuality.medium,
|
||||||
fit: BoxFit.contain,
|
fit: BoxFit.contain,
|
||||||
@@ -1868,7 +1868,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> with WatchStateAw
|
|||||||
errorWidget: (context, url, error) {
|
errorWidget: (context, url, error) {
|
||||||
return _buildTitleText(context, metadata.title);
|
return _buildTitleText(context, metadata.title);
|
||||||
},
|
},
|
||||||
);
|
), sigma: 10, clip: false);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:cached_network_image/cached_network_image.dart';
|
|||||||
import '../../models/plex_home_user.dart';
|
import '../../models/plex_home_user.dart';
|
||||||
import '../../theme/mono_tokens.dart';
|
import '../../theme/mono_tokens.dart';
|
||||||
import '../../i18n/strings.g.dart';
|
import '../../i18n/strings.g.dart';
|
||||||
|
import '../../widgets/plex_optimized_image.dart' show blurArtwork;
|
||||||
|
|
||||||
class UserAvatarWidget extends StatelessWidget {
|
class UserAvatarWidget extends StatelessWidget {
|
||||||
final PlexHomeUser user;
|
final PlexHomeUser user;
|
||||||
@@ -141,14 +142,14 @@ class UserAvatarWidget extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
// Avatar image
|
// Avatar image
|
||||||
ClipOval(
|
ClipOval(
|
||||||
child: CachedNetworkImage(
|
child: blurArtwork(CachedNetworkImage(
|
||||||
imageUrl: user.thumb,
|
imageUrl: user.thumb,
|
||||||
width: size,
|
width: size,
|
||||||
height: size,
|
height: size,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
placeholder: (ctx, url) => _buildPlaceholderAvatar(theme),
|
placeholder: (ctx, url) => _buildPlaceholderAvatar(theme),
|
||||||
errorWidget: (ctx, url, error) => _buildPlaceholderAvatar(theme),
|
errorWidget: (ctx, url, error) => _buildPlaceholderAvatar(theme),
|
||||||
),
|
)),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Indicators (only show icon indicators when not using text labels)
|
// Indicators (only show icon indicators when not using text labels)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
import 'dart:ui';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:plezy/widgets/app_icon.dart';
|
import 'package:plezy/widgets/app_icon.dart';
|
||||||
import 'package:material_symbols_icons/symbols.dart';
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
@@ -7,6 +8,32 @@ import '../../services/plex_client.dart';
|
|||||||
import '../utils/plex_image_helper.dart';
|
import '../utils/plex_image_helper.dart';
|
||||||
import 'media_card.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 {
|
class PlexOptimizedImage extends StatelessWidget {
|
||||||
final PlexClient? client;
|
final PlexClient? client;
|
||||||
final String? imagePath;
|
final String? imagePath;
|
||||||
@@ -230,14 +257,16 @@ class PlexOptimizedImage extends StatelessWidget {
|
|||||||
if (localFilePath != null) {
|
if (localFilePath != null) {
|
||||||
final file = File(localFilePath!);
|
final file = File(localFilePath!);
|
||||||
if (file.existsSync()) {
|
if (file.existsSync()) {
|
||||||
return Image.file(
|
return blurArtwork(
|
||||||
file,
|
Image.file(
|
||||||
width: width,
|
file,
|
||||||
height: height,
|
width: width,
|
||||||
fit: fit,
|
height: height,
|
||||||
filterQuality: filterQuality,
|
fit: fit,
|
||||||
alignment: alignment,
|
filterQuality: filterQuality,
|
||||||
errorBuilder: (context, error, stackTrace) => _buildErrorWidget(context, error),
|
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
|
// Fast path: skip LayoutBuilder when both dimensions are explicitly known
|
||||||
if (_hasKnownDimensions) {
|
if (_hasKnownDimensions) {
|
||||||
return _buildCachedImage(context, width!, height!);
|
return blurArtwork(_buildCachedImage(context, width!, height!));
|
||||||
}
|
}
|
||||||
|
|
||||||
return LayoutBuilder(
|
return blurArtwork(
|
||||||
builder: (context, constraints) {
|
LayoutBuilder(
|
||||||
final effectiveWidth = _resolvedDimension(width, constraints.maxWidth, 300.0);
|
builder: (context, constraints) {
|
||||||
final effectiveHeight = _resolvedDimension(height, constraints.maxHeight, 450.0);
|
final effectiveWidth = _resolvedDimension(width, constraints.maxWidth, 300.0);
|
||||||
return _buildCachedImage(context, effectiveWidth, effectiveHeight);
|
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 '../../../focus/focusable_wrapper.dart';
|
||||||
import '../../../utils/formatters.dart';
|
import '../../../utils/formatters.dart';
|
||||||
import '../painters/chapter_marker_painter.dart';
|
import '../painters/chapter_marker_painter.dart';
|
||||||
|
import '../../plex_optimized_image.dart' show blurArtwork;
|
||||||
|
|
||||||
/// Timeline slider with chapter markers for video playback
|
/// 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)],
|
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.5), blurRadius: 8, spreadRadius: 1)],
|
||||||
),
|
),
|
||||||
clipBehavior: Clip.antiAlias,
|
clipBehavior: Clip.antiAlias,
|
||||||
child: CachedNetworkImage(
|
child: blurArtwork(CachedNetworkImage(
|
||||||
imageUrl: thumbnailUrl,
|
imageUrl: thumbnailUrl,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
fadeInDuration: Duration.zero,
|
fadeInDuration: Duration.zero,
|
||||||
placeholder: (_, _) => const SizedBox.shrink(), // Show nothing for placeholder
|
placeholder: (_, _) => const SizedBox.shrink(), // Show nothing for placeholder
|
||||||
errorWidget: (_, _, _) => const SizedBox.shrink(), // Show nothing for errors
|
errorWidget: (_, _, _) => const SizedBox.shrink(), // Show nothing for errors
|
||||||
),
|
)),
|
||||||
),
|
),
|
||||||
if (hasThumbnail) const SizedBox(height: 4),
|
if (hasThumbnail) const SizedBox(height: 4),
|
||||||
Container(
|
Container(
|
||||||
|
|||||||
Reference in New Issue
Block a user