|
|
@@ -0,0 +1,252 @@
|
|
|
+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<StatefulWidget> createState() {
|
|
|
+ return FrameAnimationViewState();
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+class FrameAnimationViewState extends State<FrameAnimationView> {
|
|
|
+ int _currentFrame = 0;
|
|
|
+ Timer? _timer;
|
|
|
+ List<File> imageFiles = [];
|
|
|
+ List<ui.Image> 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<void> loadFrameFromAssets() {
|
|
|
+ if (widget.framePath.isEmpty) {
|
|
|
+ throw Exception('framePath is null or empty');
|
|
|
+ }
|
|
|
+ if (widget.framePath.endsWith('.zip')) {
|
|
|
+ return unZipToCache();
|
|
|
+ } else {
|
|
|
+ return copyToCache();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ Future<void> unZipToCache() async {
|
|
|
+ // Load the zip file from assets
|
|
|
+ final ByteData data = await rootBundle.load(widget.framePath);
|
|
|
+ final List<int> 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<int> fileData = file.content as List<int>;
|
|
|
+
|
|
|
+ final File newFile = File('${cacheDir.path}/$filename');
|
|
|
+ if (newFile.existsSync()) {
|
|
|
+ newFile.deleteSync();
|
|
|
+ } else {
|
|
|
+ newFile.createSync(recursive: true);
|
|
|
+ }
|
|
|
+ newFile.writeAsBytesSync(fileData);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ Future<void> copyToCache() async {
|
|
|
+ // Read AssetManifest.json file
|
|
|
+ final String manifestContent =
|
|
|
+ await rootBundle.loadString('AssetManifest.json');
|
|
|
+
|
|
|
+ // Parse JSON string into Map
|
|
|
+ final Map<String, dynamic> manifestMap = json.decode(manifestContent);
|
|
|
+
|
|
|
+ // Filter the Map to get file paths that start with folderPath
|
|
|
+ final List<String> 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<int> 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<Directory> 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<File>.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;
|
|
|
+ }
|
|
|
+}
|