import 'dart:async'; import 'dart:convert'; import 'dart:ui'; import 'package:extended_image/extended_image.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:yumi/app/constants/sc_global_config.dart'; import 'package:yumi/shared/tools/sc_network_image_utils.dart'; import 'package:yumi/ui_kit/components/sc_rotating_dots_loading.dart'; import 'package:yumi/ui_kit/widgets/svga/sc_network_svga_widget.dart'; const Duration _roomEmojiGifRestartInterval = Duration(milliseconds: 3500); String _roomEmojiCacheKey(String resource, Object token) { return 'room_emoji_${base64UrlEncode(utf8.encode('$resource|$token'))}'; } int? _resolveRoomEmojiImageCacheDimension(double? logicalSize) { if (logicalSize == null || !logicalSize.isFinite || logicalSize <= 0) { return null; } final devicePixelRatio = PlatformDispatcher.instance.implicitView?.devicePixelRatio ?? 2.0; final maxDimension = SCGlobalConfig.isLowPerformanceDevice ? 1280 : 1920; final pixelSize = (logicalSize * devicePixelRatio).round(); return pixelSize.clamp(1, maxDimension).toInt(); } class RoomEmojiAssetImage extends StatefulWidget { const RoomEmojiAssetImage({ super.key, required this.asset, required this.width, required this.height, this.fit = BoxFit.contain, }); final String asset; final double width; final double height; final BoxFit fit; @override State createState() => _RoomEmojiAssetImageState(); } class _RoomEmojiAssetImageState extends State with AutomaticKeepAliveClientMixin { static final Set _loggedNetworkLoading = {}; static final Set _loggedNetworkReady = {}; static final Set _loggedNetworkError = {}; AssetImage? _imageProvider; @override void initState() { super.initState(); _syncImageProvider(); } @override void didChangeDependencies() { super.didChangeDependencies(); _precache(); } @override void didUpdateWidget(covariant RoomEmojiAssetImage oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.asset != widget.asset) { _syncImageProvider(); _precache(); } } @override Widget build(BuildContext context) { super.build(context); final resource = widget.asset.trim(); if (resource.isEmpty) { return SizedBox(width: widget.width, height: widget.height); } if (SCNetworkSvgaWidget.isSvga(resource)) { return RepaintBoundary( child: SCNetworkSvgaWidget( resource: resource, width: widget.width, height: widget.height, loop: true, fit: widget.fit, fallback: _buildNetworkFailure(resource, 'svga failed'), ), ); } if (_isGif(resource)) { return RepaintBoundary( child: _RestartingGifImage( resource: resource, width: widget.width, height: widget.height, fit: widget.fit, isLocalAsset: _isLocalAsset(resource), ), ); } if (!_isLocalAsset(resource)) { return RepaintBoundary(child: _buildNetworkImage(resource)); } final imageProvider = _imageProvider; if (imageProvider == null) { return SizedBox(width: widget.width, height: widget.height); } return RepaintBoundary( child: Image( image: imageProvider, width: widget.width, height: widget.height, fit: widget.fit, gaplessPlayback: true, filterQuality: FilterQuality.low, ), ); } void _syncImageProvider() { final resource = widget.asset.trim(); _imageProvider = _isLocalAsset(resource) ? AssetImage(resource) : null; } void _precache() { final imageProvider = _imageProvider; if (imageProvider == null) { return; } precacheImage(imageProvider, context); } bool _isLocalAsset(String resource) { return resource.startsWith("assets/") || resource.startsWith("sc_images/"); } bool _isGif(String resource) { final lower = resource.trim().toLowerCase(); return lower.endsWith('.gif') || lower.contains('.gif?'); } Widget _buildNetworkImage(String resource) { final normalizedUrl = normalizeImageResourceUrl(resource); return ExtendedImage.network( normalizedUrl, headers: buildNetworkImageHeaders(normalizedUrl), cache: true, clearMemoryCacheWhenDispose: false, clearMemoryCacheIfFailed: true, cacheWidth: _resolveRoomEmojiImageCacheDimension(widget.width), cacheHeight: _resolveRoomEmojiImageCacheDimension(widget.height), width: widget.width, height: widget.height, fit: widget.fit, gaplessPlayback: true, loadStateChanged: (state) { switch (state.extendedImageLoadState) { case LoadState.loading: _logNetworkOnce(_loggedNetworkLoading, 'loading', normalizedUrl); return SizedBox( width: widget.width, height: widget.height, child: const Center(child: SCRotatingDotsLoading()), ); case LoadState.completed: final imageInfo = state.extendedImageInfo; final image = imageInfo?.image; final size = image == null ? 'unknown' : '${image.width}x${image.height}'; _logNetworkOnce( _loggedNetworkReady, 'ready size=$size', normalizedUrl, ); return null; case LoadState.failed: final error = state.lastException ?? 'unknown'; _logNetworkOnce( _loggedNetworkError, 'failed error=$error', normalizedUrl, ); return _buildNetworkFailure(normalizedUrl, 'image failed'); } }, ); } Widget _buildNetworkFailure(String resource, String reason) { _logNetworkOnce( _loggedNetworkError, reason, normalizeImageResourceUrl(resource), ); return SizedBox(width: widget.width, height: widget.height); } void _logNetworkOnce(Set bucket, String message, String url) { if (!kDebugMode || !bucket.add('$message|$url')) { return; } debugPrint('[RoomEmoji][image] $message url=$url'); } @override bool get wantKeepAlive => true; } class _RestartingGifImage extends StatefulWidget { const _RestartingGifImage({ required this.resource, required this.width, required this.height, required this.fit, required this.isLocalAsset, }); final String resource; final double width; final double height; final BoxFit fit; final bool isLocalAsset; @override State<_RestartingGifImage> createState() => _RestartingGifImageState(); } class _RestartingGifImageState extends State<_RestartingGifImage> { static final Set _loggedLoading = {}; static final Set _loggedReady = {}; static final Set _loggedError = {}; Timer? _restartTimer; int _restartToken = 0; int _visibleToken = 0; bool _hasCompletedOnce = false; @override void initState() { super.initState(); if (widget.isLocalAsset) { _startRestartTimer(); } } @override void didUpdateWidget(covariant _RestartingGifImage oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.resource != widget.resource || oldWidget.isLocalAsset != widget.isLocalAsset) { _restartToken = 0; _visibleToken = 0; _hasCompletedOnce = false; _restartTimer?.cancel(); if (widget.isLocalAsset) { _startRestartTimer(); } } } @override void dispose() { _restartTimer?.cancel(); super.dispose(); } @override Widget build(BuildContext context) { final resource = widget.resource.trim(); if (resource.isEmpty) { return SizedBox(width: widget.width, height: widget.height); } if (widget.isLocalAsset) { return Image.asset( resource, key: ValueKey('room_emoji_gif_asset_${resource}_$_restartToken'), width: widget.width, height: widget.height, fit: widget.fit, gaplessPlayback: true, filterQuality: FilterQuality.low, ); } final normalizedUrl = normalizeImageResourceUrl(resource); final foreground = _buildNetworkGifForeground(normalizedUrl); if (!_hasCompletedOnce) { return foreground; } return Stack( children: [ _buildNetworkGifPlaceholder(normalizedUrl, _visibleToken), foreground, ], ); } Widget _buildNetworkGifPlaceholder(String normalizedUrl, int token) { return ExtendedImage.network( normalizedUrl, key: ValueKey('room_emoji_gif_placeholder_${normalizedUrl}_$token'), headers: buildNetworkImageHeaders(normalizedUrl), cache: true, cacheKey: _roomEmojiCacheKey(normalizedUrl, token % 2), clearMemoryCacheWhenDispose: false, clearMemoryCacheIfFailed: true, cacheWidth: _resolveRoomEmojiImageCacheDimension(widget.width), cacheHeight: _resolveRoomEmojiImageCacheDimension(widget.height), width: widget.width, height: widget.height, fit: widget.fit, gaplessPlayback: true, loadStateChanged: (state) { switch (state.extendedImageLoadState) { case LoadState.completed: return null; case LoadState.loading: case LoadState.failed: return SizedBox(width: widget.width, height: widget.height); } }, ); } Widget _buildNetworkGifForeground(String normalizedUrl) { return ExtendedImage.network( normalizedUrl, key: ValueKey( 'room_emoji_gif_foreground_${normalizedUrl}_$_restartToken', ), headers: buildNetworkImageHeaders(normalizedUrl), cache: true, cacheKey: _roomEmojiCacheKey(normalizedUrl, _restartToken % 2), clearMemoryCacheWhenDispose: true, clearMemoryCacheIfFailed: true, cacheWidth: _resolveRoomEmojiImageCacheDimension(widget.width), cacheHeight: _resolveRoomEmojiImageCacheDimension(widget.height), width: widget.width, height: widget.height, fit: widget.fit, gaplessPlayback: true, loadStateChanged: (state) { switch (state.extendedImageLoadState) { case LoadState.loading: _debugGifOnce(_loggedLoading, 'loading', normalizedUrl); if (_hasCompletedOnce) { return SizedBox(width: widget.width, height: widget.height); } return SizedBox( width: widget.width, height: widget.height, child: const Center(child: SCRotatingDotsLoading()), ); case LoadState.completed: final image = state.extendedImageInfo?.image; final size = image == null ? 'unknown' : '${image.width}x${image.height}'; _debugGifOnce(_loggedReady, 'ready size=$size', normalizedUrl); if (!_hasCompletedOnce) { WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) { return; } setState(() { _hasCompletedOnce = true; _visibleToken = _restartToken; }); }); } else if (_visibleToken != _restartToken) { WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) { return; } setState(() { _visibleToken = _restartToken; }); }); } _startRestartTimer(); return null; case LoadState.failed: final error = state.lastException ?? 'unknown'; _debugGifOnce(_loggedError, 'failed error=$error', normalizedUrl); return SizedBox(width: widget.width, height: widget.height); } }, ); } void _startRestartTimer() { if (_restartTimer?.isActive == true) { return; } _restartTimer?.cancel(); _restartTimer = Timer.periodic(_roomEmojiGifRestartInterval, (_) { if (!mounted) { return; } setState(() { _restartToken += 1; }); }); } static void _debugGifOnce( Set bucket, String message, String resource, ) { if (!kDebugMode || !bucket.add('$message|$resource')) { return; } debugPrint('[RoomEmoji][gif] $message url=$resource'); } }