Initial commit
This commit is contained in:
@@ -0,0 +1,303 @@
|
||||
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],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user