Files
plezy/lib/profiles/profile_avatar.dart
edde746 5d83d4833c fix(images): stop decoding artwork nothing ever displays
The image pipeline funnels through `MediaImageHelper.serverArtworkProvider`, which
sizes the request, bounds the decode and namespaces the disk key. Five places
escaped that funnel, and on the reduced tier they compete for a 64 MiB image cache.

- Jellyfin trickplay prefetch built a bare `CachedNetworkImageProvider` and called
  `resolve()` on it, fully decoding a sprite sheet -- roughly 22 MiB of RGBA for a
  10x10 grid of 320-wide tiles. None of it was ever painted: the render path wraps
  the same provider in `ResizeImage`, so it decodes again under a different cache
  key. The comment already said the intent was only to warm the disk cache, so it
  now does exactly that through the cache manager and resolves no image stream.
- `CyclingMediaBackdrop` re-fetched and re-decoded a full-screen backdrop every
  10 s forever, with no `DevicePerformance` term in `_canRotate` even though its
  own fade duration is tier-gated. At the reduced tier's 1280x720 art cap that is
  3.52 MiB per rotation, ~7 MiB live across the crossfade, churning while the
  viewer sits still and evicting the rail posters behind it. The fade is already
  zero-duration there, so the rotation bought variety and nothing else.
- Catalog detail passed `item.posterUrl`/`backdropUrl` with no client, which
  `getOptimizedImageUrl` deliberately returns unmodified -- so a 40x60 slot pulled
  a 600x900 TMDB asset and a 320-tall backdrop pulled 1920x800. `posterFor`/
  `backdropFor` already existed for this; the sites now use them.
- The season strip, the Live TV programme sheet and the profile avatar built
  `CachedNetworkImage` directly, keeping the sized request but losing the
  `plex_optimized_<sha1>` disk key, so the same artwork was cached twice and
  re-downloaded on detail open. The season strip also discarded its height bound.

Sources without published variants (Trakt, AniList, MAL, MDBList) still fall back
to the base URL.
2026-08-23 17:51:27 +02:00

96 lines
3.2 KiB
Dart

import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../utils/initials_palette.dart';
import '../utils/media_image_helper.dart';
import '../widgets/app_icon.dart';
import 'profile.dart';
class ProfileAvatar extends StatelessWidget {
final Profile? profile;
final double size;
final bool showLockBadge;
/// The derived picture for this profile (see [resolveProfileAvatarUrls]).
///
/// Falls back to [Profile.avatarThumbUrl] when null.
final String? avatarUrl;
const ProfileAvatar({super.key, required this.profile, this.size = 40, this.showLockBadge = true, this.avatarUrl});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final p = profile;
final lockBadgeSize = size * 0.34;
final memCacheSize = (size * MediaQuery.devicePixelRatioOf(context)).round();
return SizedBox(
width: size,
height: size,
child: Stack(
clipBehavior: Clip.none,
children: [
ClipOval(
child: SizedBox(width: size, height: size, child: _buildContent(theme, p, memCacheSize)),
),
if (showLockBadge && p != null && p.isPinProtected)
Positioned(
right: -2,
bottom: -2,
child: Container(
width: lockBadgeSize,
height: lockBadgeSize,
alignment: .center,
decoration: BoxDecoration(
color: theme.colorScheme.surface,
shape: BoxShape.circle,
border: Border.all(color: theme.colorScheme.surface, width: 1),
),
child: AppIcon(
Symbols.lock_rounded,
fill: 1,
size: lockBadgeSize * 0.7,
color: theme.colorScheme.onSurface,
),
),
),
],
),
);
}
Widget _buildContent(ThemeData theme, Profile? p, int memCacheSize) {
if (p == null) {
return Container(color: theme.colorScheme.surfaceContainerHighest);
}
// An empty override means "nothing derived", not "suppress the profile's
// own picture" — same absent-or-blank rule the thumb itself uses below.
final override = avatarUrl;
final thumb = override != null && override.isNotEmpty ? override : p.avatarThumbUrl;
if (thumb != null && thumb.isNotEmpty) {
return Image(
image: MediaImageHelper.serverArtworkProvider(imageUrl: thumb, memWidth: memCacheSize, memHeight: memCacheSize),
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) => _initialFallback(theme, p),
frameBuilder: (context, child, frame, wasSynchronouslyLoaded) {
if (wasSynchronouslyLoaded || frame != null) return child;
return _initialFallback(theme, p);
},
);
}
return _initialFallback(theme, p);
}
Widget _initialFallback(ThemeData theme, Profile p) {
return Container(
color: colorForName(p.displayName, theme),
alignment: .center,
child: Text(
initialOf(p.displayName),
style: TextStyle(color: Colors.white, fontSize: size * 0.42, fontWeight: .w600, height: 1.0),
),
);
}
}