Files
Ensemble/lib/widgets/podcast_card.dart
ChrisandClaude Opus 4.5 ff34479169 feat: Provider icons and library performance optimizations
Provider Icons:
- Add provider icon overlays to series detail screen (grid/list)
- Add provider icon overlays to library playlists (grid/list)
- Add ProviderIconOverlay widget and provider manifest model

Performance Optimizations:
- Fix empty setState in SyncService listener - only rebuild on sync complete
- Add cached sorting for albums, artists, authors (avoid O(n log n) per rebuild)
- Add cached track sort keys for LetterScrollbar
- Fix image cache size mismatches (author 128px, audiobook list 128px, series 150px)
- Simplify provider toggle setState to avoid double rebuild
- Remove redundant text Hero animations from card widgets
- Defer color extraction from card tap to detail screen initState
- Increase fallback image debounce from 200ms to 400ms

Other Changes:
- Update screenshots
- Add new localizations
- Various detail screen improvements

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 19:24:37 +00:00

165 lines
5.9 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:material_design_icons_flutter/material_design_icons_flutter.dart';
import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart';
import '../screens/podcast_detail_screen.dart';
import '../constants/hero_tags.dart';
import '../constants/timings.dart';
import '../theme/theme_provider.dart';
import '../utils/page_transitions.dart';
import 'provider_icon.dart';
class PodcastCard extends StatefulWidget {
final MediaItem podcast;
final VoidCallback? onTap;
final String? heroTagSuffix;
/// Image decode size in pixels. Defaults to 256.
/// Use smaller values (e.g., 128) for list views, larger for grids.
final int? imageCacheSize;
const PodcastCard({
super.key,
required this.podcast,
this.onTap,
this.heroTagSuffix,
this.imageCacheSize,
});
@override
State<PodcastCard> createState() => _PodcastCardState();
}
class _PodcastCardState extends State<PodcastCard> {
bool _isNavigating = false;
@override
Widget build(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>();
// Use provider's getPodcastImageUrl which includes iTunes cache
final imageUrl = maProvider.getPodcastImageUrl(widget.podcast, size: 256);
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
final suffix = widget.heroTagSuffix != null ? '_${widget.heroTagSuffix}' : '';
// PERF: Use appropriate cache size based on display size
final cacheSize = widget.imageCacheSize ?? 256;
return RepaintBoundary(
child: GestureDetector(
onTap: widget.onTap ?? () {
// Prevent double-tap navigation
if (_isNavigating) return;
_isNavigating = true;
// PERF: Color extraction deferred to detail screen's initState
// to avoid competing with Hero animation for GPU resources
Navigator.push(
context,
FadeSlidePageRoute(
child: PodcastDetailScreen(
podcast: widget.podcast,
heroTagSuffix: widget.heroTagSuffix,
initialImageUrl: imageUrl,
),
),
).then((_) {
// Reset after navigation debounce delay
Future.delayed(Timings.navigationDebounce, () {
if (mounted) _isNavigating = false;
});
});
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Podcast artwork - square with rounded corners
AspectRatio(
aspectRatio: 1.0,
child: Hero(
tag: HeroTags.podcastCover + (widget.podcast.uri ?? widget.podcast.itemId) + suffix,
child: ClipRRect(
borderRadius: BorderRadius.circular(12.0),
child: Stack(
fit: StackFit.expand,
children: [
Container(
color: colorScheme.surfaceVariant,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
memCacheWidth: cacheSize,
memCacheHeight: cacheSize,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (context, url) => const SizedBox(),
errorWidget: (context, url, error) => Center(
child: Icon(
MdiIcons.podcast,
size: 64,
color: colorScheme.onSurfaceVariant,
),
),
)
: Center(
child: Icon(
MdiIcons.podcast,
size: 64,
color: colorScheme.onSurfaceVariant,
),
),
),
// Provider icon overlay
if (widget.podcast.providerMappings?.isNotEmpty == true)
ProviderIconOverlay(
domain: widget.podcast.providerMappings!.first.providerDomain,
),
],
),
),
),
),
const SizedBox(height: 8),
// Podcast title
// PERF: Removed Hero - text animations provide minimal benefit but add overhead
Text(
widget.podcast.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textTheme.titleSmall?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
),
// Podcast author (if available)
Text(
_getAuthor(widget.podcast),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.7),
),
),
],
),
),
);
}
/// Extract author from podcast metadata
String _getAuthor(MediaItem podcast) {
final metadata = podcast.metadata;
if (metadata == null) return '';
// Try different metadata fields for author
final author = metadata['author'] ??
metadata['artist'] ??
metadata['owner'] ?? '';
return author.toString();
}
}