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>
165 lines
5.9 KiB
Dart
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();
|
|
}
|
|
}
|