yumi-flutter/lib/ui_kit/widgets/room/room_emoji_asset_image.dart
2026-06-02 19:57:41 +08:00

404 lines
11 KiB
Dart

import 'dart:async';
import 'package:extended_image/extended_image.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.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);
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<RoomEmojiAssetImage> createState() => _RoomEmojiAssetImageState();
}
class _RoomEmojiAssetImageState extends State<RoomEmojiAssetImage>
with AutomaticKeepAliveClientMixin {
static final Set<String> _loggedNetworkLoading = <String>{};
static final Set<String> _loggedNetworkReady = <String>{};
static final Set<String> _loggedNetworkError = <String>{};
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: _buildNetworkFallback(resource, 'svga fallback'),
),
);
}
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,
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 _buildNetworkFallback(normalizedUrl, 'image failed');
}
},
);
}
Widget _buildNetworkFallback(String resource, String reason) {
_logNetworkOnce(
_loggedNetworkError,
reason,
normalizeImageResourceUrl(resource),
);
return SizedBox(
width: widget.width,
height: widget.height,
child: const Center(child: SCRotatingDotsLoading()),
);
}
void _logNetworkOnce(Set<String> 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<String> _loggedLoading = <String>{};
static final Set<String> _loggedReady = <String>{};
static final Set<String> _loggedError = <String>{};
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: '$normalizedUrl#room_emoji_gif_${token % 2}',
clearMemoryCacheWhenDispose: false,
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: '$normalizedUrl#room_emoji_gif_${_restartToken % 2}',
clearMemoryCacheWhenDispose: true,
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,
child: const Center(child: SCRotatingDotsLoading()),
);
}
},
);
}
void _startRestartTimer() {
if (_restartTimer?.isActive == true) {
return;
}
_restartTimer?.cancel();
_restartTimer = Timer.periodic(_roomEmojiGifRestartInterval, (_) {
if (!mounted) {
return;
}
setState(() {
_restartToken += 1;
});
});
}
static void _debugGifOnce(
Set<String> bucket,
String message,
String resource,
) {
if (!kDebugMode || !bucket.add('$message|$resource')) {
return;
}
debugPrint('[RoomEmoji][gif] $message url=$resource');
}
}