import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'dart:ui' as ui; import 'package:archive/archive.dart'; import 'package:crypto/crypto.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/services.dart'; import 'package:path_provider/path_provider.dart'; class FrameAnimationView extends StatefulWidget { final String framePath; final int frameRate; final FrameAnimationController? controller; final double? speed; final double? width; final double? height; const FrameAnimationView( {super.key, required this.framePath, this.frameRate = 25, this.controller, this.speed, this.width, this.height}); @override State createState() { return FrameAnimationViewState(); } } class FrameAnimationViewState extends State with SingleTickerProviderStateMixin { late final AnimationController _frameAnimationController; List imageFiles = []; List images = []; StreamSubscription? _precacheStreamSubscription; @override void initState() { super.initState(); _frameAnimationController = AnimationController( vsync: this, ); _frameAnimationController.addListener(() => setState(() {})); widget.controller?.bindState(this); loadFrameFromAssets() .then((_) => initializeImageFiles()) .then((_) => precacheImageFiles()) .then((_) => widget.controller == null || widget.controller!.autoPlay ? startAnimation() : null) .catchError((error) => debugPrint('FrameAnimationView error: $error')); } @override void dispose() { _frameAnimationController.dispose(); super.dispose(); _precacheStreamSubscription?.cancel(); imageFiles.clear(); for (var image in images) { image.dispose(); } images.clear(); } @override Widget build(BuildContext context) { if (imageFiles.isEmpty || !_frameAnimationController.isAnimating) { return SizedBox(width: widget.width, height: widget.height); } final currentFrame = (_frameAnimationController.value * imageFiles.length) .floor() .clamp(0, images.length - 1); debugPrint('currentFrame: $currentFrame'); return RawImage( image: images[currentFrame], width: widget.width, height: widget.height, ); } Future loadFrameFromAssets() { if (widget.framePath.isEmpty) { throw Exception('framePath is null or empty'); } if (widget.framePath.endsWith('.zip')) { return unZipToCache(); } else { return copyToCache(); } } Future unZipToCache() async { // Load the zip file from assets final ByteData data = await rootBundle.load(widget.framePath); final List bytes = data.buffer.asUint8List(); // Decode the zip file final Archive archive = ZipDecoder().decodeBytes(bytes); // Create a cache directory final Directory cacheDir = await createCacheDirectory(); // if the cache directory is not empty and frame images count is equal to the zip file's files count if (cacheDir.listSync().isNotEmpty && cacheDir.listSync().length == archive.length) { return; } // Extract the contents of the zip file for (final ArchiveFile file in archive) { final String filename = file.name; final List fileData = file.content as List; final File newFile = File('${cacheDir.path}/$filename'); if (newFile.existsSync()) { newFile.deleteSync(); } else { newFile.createSync(recursive: true); } newFile.writeAsBytesSync(fileData); } } Future copyToCache() async { // Read AssetManifest.json file final String manifestContent = await rootBundle.loadString('AssetManifest.json'); // Parse JSON string into Map final Map manifestMap = json.decode(manifestContent); // Filter the Map to get file paths that start with folderPath final List filePaths = manifestMap.keys .where((String key) => key.startsWith(widget.framePath)) .toList(); // Create a cache directory final Directory cacheDir = await createCacheDirectory(); // if the cache directory is not empty and frame images count is equal to the zip file's files count if (cacheDir.listSync().isNotEmpty && cacheDir.listSync().length == filePaths.length) { return; } // Copy the files to the cache directory for (final String path in filePaths) { final ByteData data = await rootBundle.load(path); final List bytes = data.buffer.asUint8List(); final File newFile = File('${cacheDir.path}/${path.split('/').last}'); if (newFile.existsSync()) { newFile.deleteSync(); } else { newFile.createSync(recursive: true); } newFile.writeAsBytesSync(bytes); imageFiles.add(newFile); } } Future createCacheDirectory() async { // Get the temporary directory final Directory tempDir = await getTemporaryDirectory(); // Create a new directory within the temporary directory final Directory cacheDir = Directory('${tempDir.path}/frame_anim_cache'); // Check if the directory exists, if not, create it if (!await cacheDir.exists()) { await cacheDir.create(recursive: true); } // create unique directory by framePath's md5 final Directory frameDir = Directory( '${cacheDir.path}/${md5.convert(utf8.encode(widget.framePath)).toString()}'); // Check if the directory exists, if not, create it if (!await frameDir.exists()) { await frameDir.create(recursive: true); } return frameDir; } startAnimation() { if (imageFiles.isEmpty) { return; } if (_frameAnimationController.isAnimating) { return; } double speed = widget.speed ?? 1.0; int duration = (1000 ~/ widget.frameRate * imageFiles.length ~/ speed); debugPrint('frame animation duration: $duration'); _frameAnimationController.duration = Duration(milliseconds: duration); _frameAnimationController.repeat(); } initializeImageFiles() async { final Directory cacheDir = await createCacheDirectory(); imageFiles = cacheDir .listSync() .map((file) => File(file.path)) .where((file) => file.path.endsWith('.png') || file.path.endsWith('.jpg') || file.path.endsWith('.jpeg') || file.path.endsWith('.webp')) .toList(); if (imageFiles.isEmpty) { throw Exception('frame images not found'); } } precacheImageFiles() { _precacheStreamSubscription = Stream.fromIterable(imageFiles).asyncMap((file) async { final Uint8List bytes = await file.readAsBytes(); final ui.Codec codec = await ui.instantiateImageCodec(bytes); final ui.FrameInfo frameInfo = await codec.getNextFrame(); return frameInfo.image; }).listen((image) { images.add(image); }); } } class FrameAnimationController { final bool autoPlay; FrameAnimationViewState? _state; FrameAnimationController({this.autoPlay = true}); void play() { _state?.startAnimation(); } void stop() { _state?._frameAnimationController.stop(); } bool get isPlaying => _state?._frameAnimationController.isAnimating ?? false; void bindState(FrameAnimationViewState state) { _state = state; } }