Files
sm-emby-share/lib/shared/widgets/smart_image.dart
T

304 lines
8.4 KiB
Dart
Raw Normal View History

2026-07-14 11:11:36 +08:00
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../providers/emby_headers_provider.dart';
import '../../providers/session_provider.dart';
class SmartImage extends ConsumerStatefulWidget {
final String? url;
final List<String> fallbackUrls;
final BoxFit fit;
final Alignment alignment;
final double? aspectRatio;
final double borderRadius;
final IconData? fallbackIcon;
final VoidCallback? onLoad;
final VoidCallback? onError;
final Map<String, String>? httpHeaders;
final Map<String, String>? Function(String url)? resolveHttpHeaders;
final int? memCacheWidth;
final Widget? placeholder;
const SmartImage({
super.key,
required this.url,
this.fallbackUrls = const [],
this.fit = BoxFit.cover,
this.alignment = Alignment.center,
this.aspectRatio,
this.borderRadius = 8,
this.fallbackIcon = Icons.image_outlined,
this.onLoad,
this.onError,
this.httpHeaders,
this.resolveHttpHeaders,
this.memCacheWidth,
this.placeholder,
});
@override
ConsumerState<SmartImage> createState() => _SmartImageState();
}
class _SmartImageState extends ConsumerState<SmartImage> {
List<String> _displayedUrls = const [];
int _failedCount = 0;
String? _pendingPrimaryUrl;
int? _lastMemCacheWidth;
String? _embyBaseUrl;
Map<String, String>? _embyHeaders;
List<String> _resolveAllUrls() {
final urls = <String>[];
if (widget.url != null) urls.add(widget.url!);
urls.addAll(widget.fallbackUrls);
return urls;
}
Map<String, String>? _headersFor(String url) {
if (widget.resolveHttpHeaders != null) {
return widget.resolveHttpHeaders!(url);
}
if (widget.httpHeaders != null) return widget.httpHeaders;
final base = _embyBaseUrl;
if (base != null && url.startsWith(base)) return _embyHeaders;
return null;
}
@override
void initState() {
super.initState();
_displayedUrls = _resolveAllUrls();
}
@override
void didUpdateWidget(covariant SmartImage oldWidget) {
super.didUpdateWidget(oldWidget);
final newAll = _resolveAllUrls();
final newPrimary = newAll.isEmpty ? null : newAll.first;
final currentPrimary = _displayedUrls.isEmpty ? null : _displayedUrls.first;
if (newPrimary == null) {
if (_displayedUrls.isNotEmpty || _pendingPrimaryUrl != null) {
setState(() {
_displayedUrls = const [];
_failedCount = 0;
_pendingPrimaryUrl = null;
});
}
return;
}
if (currentPrimary == null) {
setState(() {
_displayedUrls = newAll;
_failedCount = 0;
_pendingPrimaryUrl = null;
});
return;
}
if (newPrimary == currentPrimary) {
_displayedUrls = newAll;
return;
}
_preloadAndSwap(newPrimary, newAll);
}
Future<void> _preloadAndSwap(String newUrl, List<String> newAll) async {
if (_pendingPrimaryUrl == newUrl) return;
_pendingPrimaryUrl = newUrl;
final provider = ResizeImage.resizeIfNeeded(
_lastMemCacheWidth,
null,
CachedNetworkImageProvider(newUrl, headers: _headersFor(newUrl)),
);
try {
await precacheImage(provider, context);
} catch (_) {
if (_pendingPrimaryUrl == newUrl) _pendingPrimaryUrl = null;
return;
}
if (!mounted) return;
if (_pendingPrimaryUrl != newUrl) return;
setState(() {
_displayedUrls = newAll;
_failedCount = 0;
_pendingPrimaryUrl = null;
});
}
@override
Widget build(BuildContext context) {
if (widget.httpHeaders == null && widget.resolveHttpHeaders == null) {
_embyBaseUrl = ref.watch(activeSessionProvider)?.serverUrl;
_embyHeaders = ref.watch(embyImageHeadersProvider).value;
} else {
_embyBaseUrl = null;
_embyHeaders = null;
}
final scheme = Theme.of(context).colorScheme;
final isLight = scheme.brightness == Brightness.light;
Widget placeholderWidget() =>
widget.placeholder ?? _ShimmerPlaceholder(isLight: isLight);
final urls = _displayedUrls;
final effectiveUrl = _failedCount < urls.length ? urls[_failedCount] : null;
final fallback = widget.fallbackIcon != null
? Container(
color: scheme.surfaceContainerHighest,
child: Center(
child: Icon(
widget.fallbackIcon,
size: 32,
color: scheme.onSurfaceVariant.withValues(alpha: 0.4),
),
),
)
: const SizedBox.shrink();
final Widget child;
if (effectiveUrl == null) {
child = KeyedSubtree(
key: const ValueKey<String>('_smart_image_empty'),
child: fallback,
);
} else {
final effectiveHeaders = _headersFor(effectiveUrl);
child = LayoutBuilder(
builder: (context, constraints) {
final memCacheWidth =
widget.memCacheWidth ??
(constraints.maxWidth.isFinite
? (constraints.maxWidth *
MediaQuery.devicePixelRatioOf(context))
.ceil()
: null);
_lastMemCacheWidth = memCacheWidth;
return CachedNetworkImage(
key: ValueKey<String>(effectiveUrl),
imageUrl: effectiveUrl,
httpHeaders: effectiveHeaders,
fit: widget.fit,
alignment: widget.alignment,
memCacheWidth: memCacheWidth,
fadeInDuration: const Duration(milliseconds: 300),
fadeInCurve: Curves.easeOut,
placeholder: (_, _) => placeholderWidget(),
errorWidget: (_, e, st) {
if (_failedCount + 1 < urls.length) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) setState(() => _failedCount++);
});
return placeholderWidget();
}
WidgetsBinding.instance.addPostFrameCallback((_) {
widget.onError?.call();
});
return fallback;
},
imageBuilder: (ctx, imageProvider) {
widget.onLoad?.call();
return Image(
image: imageProvider,
fit: widget.fit,
alignment: widget.alignment,
);
},
);
},
);
}
final switcher = AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
switchInCurve: Curves.easeOut,
switchOutCurve: Curves.easeOut,
layoutBuilder: (currentChild, previousChildren) {
return Stack(
fit: StackFit.passthrough,
children: [
...previousChildren,
if (currentChild != null) currentChild,
],
);
},
child: child,
);
final clipped = ClipRRect(
borderRadius: BorderRadius.circular(widget.borderRadius),
child: switcher,
);
if (widget.aspectRatio != null) {
return AspectRatio(aspectRatio: widget.aspectRatio!, child: clipped);
}
return clipped;
}
}
class _ShimmerPlaceholder extends StatefulWidget {
final bool isLight;
const _ShimmerPlaceholder({required this.isLight});
@override
State<_ShimmerPlaceholder> createState() => _ShimmerPlaceholderState();
}
class _ShimmerPlaceholderState extends State<_ShimmerPlaceholder>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1500),
)..repeat();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (context, child) {
final base = widget.isLight
? const Color(0xFFF3F4F6)
: const Color(0xFF2A2A2A);
final highlight = widget.isLight
? const Color(0xFFE5E7EB)
: const Color(0xFF3A3A3A);
return Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment(-1.0 + 2.0 * _controller.value, 0),
end: Alignment(-1.0 + 2.0 * _controller.value + 1.0, 0),
colors: [base, highlight, base],
stops: const [0.0, 0.5, 1.0],
),
),
);
},
);
}
}