304 lines
8.4 KiB
Dart
304 lines
8.4 KiB
Dart
|
|
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],
|
||
|
|
),
|
||
|
|
),
|
||
|
|
);
|
||
|
|
},
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|