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 { int _currentFrame = 0; Timer? _timer; List imageFiles = []; List images = []; @override void initState() { super.initState(); 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() { super.dispose(); _timer?.cancel(); } @override Widget build(BuildContext context) { if (images.isEmpty) { return SizedBox(width: widget.width, height: widget.height); } 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 (_timer != null && _timer!.isActive) { return; } double speed = widget.speed ?? 1.0; _timer = Timer.periodic( Duration(milliseconds: 1000 ~/ (widget.frameRate * speed)), (_) { setState(() { int targetFrame = (_currentFrame + 1) % imageFiles.length; _currentFrame = targetFrame >= images.length ? images.length - 1 : targetFrame; }); }); } 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() { 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?._timer?.cancel(); } bool get isPlaying => _state?._timer?.isActive ?? false; void bindState(FrameAnimationViewState state) { _state = state; } }