import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_svga/flutter_svga.dart'; typedef SCSvgaMovieConfigurer = FutureOr Function(MovieEntity movieEntity); class SCSvgaAssetWidget extends StatefulWidget { static const int _maxCacheEntries = 8; static final Map _cache = {}; static final Map> _loadingTasks = >{}; static final Map _activeUseCounts = {}; const SCSvgaAssetWidget({ super.key, required this.assetPath, this.width, this.height, this.active = true, this.loop = false, this.fit = BoxFit.contain, this.filterQuality = FilterQuality.low, this.allowDrawingOverflow = false, this.fallback, this.clearsAfterStop = false, this.onPlaybackStarted, this.onPlaybackCompleted, this.dynamicIdentity, this.movieConfigurer, }); final String assetPath; final double? width; final double? height; final bool active; final bool loop; final BoxFit fit; final FilterQuality filterQuality; final bool allowDrawingOverflow; final Widget? fallback; final bool clearsAfterStop; final VoidCallback? onPlaybackStarted; final VoidCallback? onPlaybackCompleted; final Object? dynamicIdentity; final SCSvgaMovieConfigurer? movieConfigurer; static Future preloadAsset(String assetPath) { return _obtainMovieEntity(assetPath); } static void clearMemoryCache({bool includeActive = false}) { final entries = _cache.entries.toList(growable: false); for (final entry in entries) { final entity = entry.value; if (!includeActive && _isActive(entity)) { continue; } _cache.remove(entry.key); _disposeMovieEntity(entity); } } static Future _acquireMovieEntity(String assetPath) async { final entity = await _obtainMovieEntity(assetPath); _retainMovieEntity(entity); return entity; } static Future _obtainMovieEntity(String assetPath) async { final cached = _touchCachedMovieEntity(assetPath); if (cached != null) { return cached; } final loadingTask = _loadingTasks[assetPath]; if (loadingTask != null) { return loadingTask; } final future = () async { MovieEntity entity; try { entity = await SVGAParser.shared.decodeFromAssets(assetPath); } catch (_) { final byteData = await rootBundle.load(assetPath); entity = await SVGAParser.shared.decodeFromBuffer( byteData.buffer.asUint8List(), ); } entity.autorelease = false; _cacheMovieEntity(assetPath, entity); return entity; }(); _loadingTasks[assetPath] = future; try { return await future; } finally { _loadingTasks.remove(assetPath); } } static MovieEntity? _touchCachedMovieEntity(String assetPath) { final cached = _cache.remove(assetPath); if (cached != null) { _cache[assetPath] = cached; } return cached; } static void _cacheMovieEntity(String assetPath, MovieEntity entity) { final previous = _cache.remove(assetPath); if (previous != null && !identical(previous, entity)) { _disposeMovieEntityIfInactive(previous); } _cache[assetPath] = entity; _trimCache(); } static void _trimCache() { while (_cache.length > _maxCacheEntries) { final removableEntry = _cache.entries.firstWhere( (entry) => !_isActive(entry.value), orElse: () => _cache.entries.first, ); if (_isActive(removableEntry.value)) { _cache.remove(removableEntry.key); _cache[removableEntry.key] = removableEntry.value; break; } _cache.remove(removableEntry.key); _disposeMovieEntity(removableEntry.value); } } static void _retainMovieEntity(MovieEntity entity) { _activeUseCounts[entity] = (_activeUseCounts[entity] ?? 0) + 1; } static void _releaseMovieEntity(MovieEntity? entity) { if (entity == null) { return; } final count = _activeUseCounts[entity] ?? 0; if (count <= 1) { _activeUseCounts.remove(entity); if (!_cache.containsValue(entity)) { _disposeMovieEntity(entity); } else { _trimCache(); } return; } _activeUseCounts[entity] = count - 1; } static bool _isActive(MovieEntity entity) => (_activeUseCounts[entity] ?? 0) > 0; static void _disposeMovieEntityIfInactive(MovieEntity entity) { if (_isActive(entity)) { return; } _disposeMovieEntity(entity); } static void _disposeMovieEntity(MovieEntity entity) { try { _activeUseCounts.remove(entity); entity.dispose(); } catch (_) {} } @override State createState() => _SCSvgaAssetWidgetState(); } class _SCSvgaAssetWidgetState extends State with SingleTickerProviderStateMixin { late final SVGAAnimationController _controller; String? _loadedAssetPath; MovieEntity? _retainedMovieEntity; String? _retainedAssetPath; bool _hasError = false; @override void initState() { super.initState(); _controller = SVGAAnimationController(vsync: this); _controller.addStatusListener(_handleAnimationStatusChanged); _loadAsset(); } void _handleAnimationStatusChanged(AnimationStatus status) { if (status == AnimationStatus.forward) { widget.onPlaybackStarted?.call(); return; } if (status == AnimationStatus.completed) { widget.onPlaybackCompleted?.call(); } } @override void didUpdateWidget(covariant SCSvgaAssetWidget oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.assetPath != widget.assetPath || oldWidget.dynamicIdentity != widget.dynamicIdentity) { _loadAsset(); return; } if (oldWidget.active != widget.active || oldWidget.loop != widget.loop) { _syncPlayback( restartIfActive: widget.active && oldWidget.active != widget.active, ); } } Future _loadAsset() async { final assetPath = widget.assetPath; setState(() { _hasError = false; }); MovieEntity? acquiredMovieEntity; try { final movieEntity = _retainedAssetPath == assetPath && _retainedMovieEntity != null ? _retainedMovieEntity! : await SCSvgaAssetWidget._acquireMovieEntity(assetPath); final acquiredNewEntity = !identical(movieEntity, _retainedMovieEntity); if (acquiredNewEntity) { acquiredMovieEntity = movieEntity; } if (widget.movieConfigurer != null) { movieEntity.dynamicItem.reset(); await widget.movieConfigurer!(movieEntity); } if (!mounted || widget.assetPath != assetPath) { if (acquiredNewEntity) { SCSvgaAssetWidget._releaseMovieEntity(movieEntity); acquiredMovieEntity = null; } return; } setState(() { _releaseRetainedMovieEntity(except: movieEntity); _retainedMovieEntity = movieEntity; _retainedAssetPath = assetPath; _loadedAssetPath = assetPath; _controller.videoItem = movieEntity; }); acquiredMovieEntity = null; _syncPlayback(restartIfActive: true); } catch (error) { SCSvgaAssetWidget._releaseMovieEntity(acquiredMovieEntity); if (!mounted || widget.assetPath != assetPath) { return; } setState(() { _controller.videoItem = null; _releaseRetainedMovieEntity(); _hasError = true; }); } } void _syncPlayback({bool restartIfActive = false}) { if (_controller.videoItem == null) { return; } if (!widget.active) { _controller.stop(); _controller.reset(); widget.onPlaybackCompleted?.call(); return; } if (widget.loop) { if (restartIfActive || !_controller.isAnimating) { _controller.reset(); _controller.repeat(); } return; } if (restartIfActive || !_controller.isAnimating) { _controller.reset(); _controller.repeat(count: 1); } } @override void dispose() { _controller.removeStatusListener(_handleAnimationStatusChanged); _controller.dispose(); _releaseRetainedMovieEntity(); super.dispose(); } void _releaseRetainedMovieEntity({MovieEntity? except}) { final entity = _retainedMovieEntity; if (entity == null || identical(entity, except)) { return; } _retainedMovieEntity = null; _retainedAssetPath = null; SCSvgaAssetWidget._releaseMovieEntity(entity); } @override Widget build(BuildContext context) { if (_hasError) { return _buildFallback(); } final videoItem = _controller.videoItem; if (videoItem == null || _loadedAssetPath != widget.assetPath) { return _buildFallback(); } return SizedBox( width: widget.width, height: widget.height, child: IgnorePointer( child: SVGAImage( _controller, fit: widget.fit, clearsAfterStop: widget.clearsAfterStop, filterQuality: widget.filterQuality, allowDrawingOverflow: widget.allowDrawingOverflow, preferredSize: widget.width != null && widget.height != null ? Size(widget.width!, widget.height!) : null, ), ), ); } Widget _buildFallback() { return SizedBox( width: widget.width, height: widget.height, child: widget.fallback ?? const SizedBox.shrink(), ); } }