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.
96 lines
3.2 KiB
Dart
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),
|
|
),
|
|
);
|
|
}
|
|
}
|