// lib/remote/remote_image_tile.dart import 'dart:ui' show FontFeature; import 'package:flutter/material.dart'; import 'package:aves/model/entry/entry.dart'; import 'package:aves/model/entry/extensions/props.dart'; // entry.isVideo, durationText import 'package:aves/remote/remote_http.dart'; /// Miniatura per contenuti **remoti** con overlay coerente a quello dei locali: /// - Icona Play in basso-sinistra se è video /// - Chip durata in basso-destra se `entry.durationMillis` è disponibile /// /// Fix principali: /// 1) Mai "grigio stuck": retry automatico se la prima richiesta fallisce /// 2) Preload: precache del thumb corrente + (opzionale) prefetch dei prossimi N thumb class RemoteImageTile extends StatefulWidget { final AvesEntry entry; final double borderRadius; final BoxFit fit; // personalizzazioni overlay final Color? overlayIconBg; final Color? overlayIconFg; final Color? durationBg; final Color? durationFg; /// (Opzionale) lista di path relativi (thumb/path) da precaricare /// Es: [nextRel1, nextRel2, ...] in ordine di priorità. final List? prefetchRelPaths; /// Quanti prefetch eseguire al massimo (se prefetchRelPaths != null) final int prefetchCount; /// Quanti tentativi di retry per la stessa tile (consigliato 1 o 2) final int maxRetry; const RemoteImageTile({ super.key, required this.entry, this.borderRadius = 12.0, this.fit = BoxFit.cover, this.overlayIconBg, this.overlayIconFg, this.durationBg, this.durationFg, this.prefetchRelPaths, this.prefetchCount = 18, this.maxRetry = 1, }); @override State createState() => _RemoteImageTileState(); } class _RemoteImageTileState extends State { late Future> _headersFuture; int _attempt = 0; bool _selfPrecached = false; bool _neighborsPrecached = false; bool get _isRemote => widget.entry.origin == 1; @override void initState() { super.initState(); // headers() dovrebbe essere già “cacheata” nel tuo RemoteHttp, ma la // memorizziamo per non ricreare il Future ad ogni build. _headersFuture = RemoteHttp.headers(); } @override void didUpdateWidget(covariant RemoteImageTile oldWidget) { super.didUpdateWidget(oldWidget); // Se cambia entry o cambia url base, resettare stato retry/precache if (oldWidget.entry.id != widget.entry.id || oldWidget.entry.remoteThumb2 != widget.entry.remoteThumb2 || oldWidget.entry.remoteThumb1 != widget.entry.remoteThumb1 || oldWidget.entry.remotePath != widget.entry.remotePath || oldWidget.entry.path != widget.entry.path) { _attempt = 0; _selfPrecached = false; _neighborsPrecached = false; _headersFuture = RemoteHttp.headers(); } // Se cambia lista prefetch, consentiamo di rifarla if (oldWidget.prefetchRelPaths != widget.prefetchRelPaths) { _neighborsPrecached = false; } } @override Widget build(BuildContext context) { final entry = widget.entry; final rel = entry.remoteThumb2 ?? entry.remoteThumb1 ?? entry.remotePath ?? entry.path; if (!_isRemote || rel == null || rel.isEmpty) { return _frame(context, const ColoredBox(color: Colors.black12)); } final url = RemoteHttp.absUrl(rel); final ar = (entry.displayAspectRatio > 0) ? entry.displayAspectRatio : 1.0; return AspectRatio( aspectRatio: ar, child: FutureBuilder>( future: _headersFuture, builder: (context, snap) { if (snap.connectionState != ConnectionState.done) { return _frame(context, const ColoredBox(color: Colors.black12), showProgress: true); } final hdrs = snap.data ?? const {}; // ImageProvider “canonico” (serve anche per precache) final provider = NetworkImage(url, headers: hdrs.isEmpty ? null : hdrs); // ✅ Precache del thumb corrente (una volta sola) if (!_selfPrecached) { _selfPrecached = true; WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; precacheImage(provider, context); }); } // ✅ Prefetch dei prossimi N (se forniti) if (!_neighborsPrecached && widget.prefetchRelPaths != null && widget.prefetchRelPaths!.isNotEmpty) { _neighborsPrecached = true; final next = widget.prefetchRelPaths! .where((p) => p.isNotEmpty) .take(widget.prefetchCount) .map((p) => RemoteHttp.absUrl(p)) .toList(growable: false); WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; for (final u in next) { precacheImage(NetworkImage(u, headers: hdrs.isEmpty ? null : hdrs), context); } }); } final img = Image( image: provider, fit: widget.fit, // ✅ mentre scarica: spinner (così non è "grigio stuck") loadingBuilder: (context, child, progress) { if (progress == null) return child; return _frame(context, const ColoredBox(color: Colors.black12), showProgress: true); }, // ✅ se fallisce: retry automatico (1 volta di default) poi fallback soft errorBuilder: (_, __, ___) { if (_attempt < widget.maxRetry) { // piccolo delay per evitare loop immediati Future.delayed(const Duration(milliseconds: 150), () { if (!mounted) return; setState(() => _attempt++); }); // nel frattempo spinner (non grigio fisso) return _frame(context, const ColoredBox(color: Colors.black12), showProgress: true); } // fallback definitivo: box neutro (ma NON rimane bloccato al primo errore) return _frame(context, const ColoredBox(color: Colors.black26)); }, ); return _frame(context, img); }, ), ); } Widget _frame(BuildContext context, Widget child, {bool showProgress = false}) { final theme = Theme.of(context); final radius = BorderRadius.circular(widget.borderRadius); // Video detection robusta (isVideo + mime + estensione path) final mime = (widget.entry.sourceMimeType ?? widget.entry.mimeType ?? '').toLowerCase(); final p = (widget.entry.path ?? widget.entry.remotePath ?? '').toLowerCase(); final looksVideo = p.endsWith('.mp4') || p.endsWith('.mov') || p.endsWith('.m4v') || p.endsWith('.mkv') || p.endsWith('.webm'); final isVideo = widget.entry.isVideo || mime.startsWith('video/') || looksVideo; final showDuration = isVideo && (widget.entry.durationMillis ?? 0) > 0; return ClipRRect( borderRadius: radius, child: Stack( fit: StackFit.expand, children: [ Positioned.fill(child: child), if (showProgress) const Center( child: SizedBox( width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 1.5), ), ), if (isVideo) ...[ Positioned( left: 6, bottom: 6, child: _PlayBadge( bg: widget.overlayIconBg ?? Colors.black.withOpacity(.55), fg: widget.overlayIconFg ?? Colors.white, ), ), if (showDuration) Positioned( right: 6, bottom: 6, child: _DurationChip( text: widget.entry.durationText, bg: widget.durationBg ?? Colors.black.withOpacity(.65), fg: widget.durationFg ?? Colors.white, borderRadius: 10, padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), textStyle: theme.textTheme.labelSmall?.copyWith( color: widget.durationFg ?? Colors.white, fontFeatures: const [FontFeature.tabularFigures()], ), ), ), ], ], ), ); } } class _PlayBadge extends StatelessWidget { final Color bg; final Color fg; const _PlayBadge({ required this.bg, required this.fg, }); @override Widget build(BuildContext context) { return DecoratedBox( decoration: BoxDecoration(color: bg, shape: BoxShape.circle), child: Padding( padding: const EdgeInsets.all(6), child: Icon(Icons.play_arrow_rounded, color: fg, size: 16), ), ); } } class _DurationChip extends StatelessWidget { final String text; final Color bg; final Color fg; final double borderRadius; final EdgeInsets padding; final TextStyle? textStyle; const _DurationChip({ super.key, required this.text, required this.bg, required this.fg, this.borderRadius = 10, this.padding = const EdgeInsets.symmetric(horizontal: 6, vertical: 2), this.textStyle, }); @override Widget build(BuildContext context) { return DecoratedBox( decoration: BoxDecoration( color: bg, borderRadius: BorderRadius.circular(borderRadius), ), child: Padding( padding: padding, child: Text( text, style: textStyle ?? Theme.of(context).textTheme.labelSmall?.copyWith(color: fg), ), ), ); } }