import 'dart:async'; import 'dart:isolate'; import 'dart:ui' as ui; import 'package:flutter/foundation.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import '../../utils/logger.dart'; import 'rlottie_disk_cache.dart'; import 'rlottie_ffi.dart'; import 'rlottie_worker.dart'; class RlottieClip { RlottieClip({required this.key, required this.px}); final String key; final int px; int frameCount = 0; int durationMs = 1000; double frameRate = 60; List _images = const []; ui.Image? _lastImage; final ValueNotifier ready = ValueNotifier(0); bool complete = false; int bytes = 0; int lastUsed = 0; int active = 0; bool get playable => frameCount > 0; ui.Image? frameAt(int index) { if (index < 0 || index >= _images.length) return _lastImage; return _images[index] ?? _lastImage; } void _allocate(int count) { _images = List.filled(count, null); } void _setFrame(int index, ui.Image image) { if (index < 0 || index >= _images.length) { image.dispose(); return; } _images[index] = image; _lastImage = image; bytes += px * px * 4; var r = ready.value; while (r < _images.length && _images[r] != null) { r++; } if (r != ready.value) ready.value = r; } void dispose() { for (final img in _images) { img?.dispose(); } _images = const []; _lastImage = null; bytes = 0; ready.dispose(); } } class _Job { _Job(this.clip, this.url, this.completer); final RlottieClip clip; final String url; final Completer completer; List rawFrames = const []; } class RlottieEngine { RlottieEngine._(); static final RlottieEngine instance = RlottieEngine._(); static String? debugLibraryPath; static const int _maxBytes = 384 * 1024 * 1024; static const int _modelCacheBytes = 20 * 1024 * 1024; final Map _clips = {}; final Map> _loading = {}; final Map _jobs = {}; int _totalBytes = 0; int _clock = 0; int _nextJobId = 1; bool? _available; Future? _workerFuture; int _tick() => ++_clock; String _keyFor(String url, int px) => '$url@$px'; bool get available { return _available ??= () { final bindings = RlottieBindings.open(path: debugLibraryPath); if (bindings == null) return false; bindings.configureModelCache(_modelCacheBytes); return true; }(); } Future _worker() { return _workerFuture ??= () async { final receive = ReceivePort(); await Isolate.spawn(rlottieWorkerMain, receive.sendPort); final broadcast = receive.asBroadcastStream(); final port = await broadcast.first as SendPort; broadcast.listen(_onWorkerMessage); return port; }(); } void _onWorkerMessage(dynamic message) { if (message is ClipMeta) { _onMeta(message); } else if (message is RenderedFrame) { _onFrame(message); } else if (message is RenderDone) { _onDone(message); } else if (message is RenderError) { _onError(message); } } void _onMeta(ClipMeta meta) { final job = _jobs[meta.jobId]; if (job == null) return; final clip = job.clip ..frameCount = meta.totalFrame ..frameRate = meta.frameRate ..durationMs = meta.durationMs; clip._allocate(meta.totalFrame); job.rawFrames = List.filled(meta.totalFrame, null); if (!job.completer.isCompleted) { job.completer.complete(clip); } } void _onFrame(RenderedFrame frame) { final job = _jobs[frame.jobId]; if (job == null) return; final bytes = frame.data.materialize().asUint8List(); if (frame.index < job.rawFrames.length) { job.rawFrames[frame.index] = bytes; } ui.decodeImageFromPixels( bytes, frame.px, frame.px, ui.PixelFormat.rgba8888, (image) { job.clip._setFrame(frame.index, image); _totalBytes += frame.px * frame.px * 4; _evictIfNeeded(); }, ); } void _onDone(RenderDone done) { final job = _jobs.remove(done.jobId); if (job == null) return; final clip = job.clip..complete = true; final raw = job.rawFrames; if (raw.length == clip.frameCount && !raw.contains(null)) { unawaited(RlottieDiskCache.instance.store( url: job.url, px: clip.px, frameCount: clip.frameCount, frameRate: clip.frameRate, durationMs: clip.durationMs, frames: raw.cast(), )); } job.rawFrames = const []; } void _onError(RenderError error) { final job = _jobs.remove(error.jobId); if (job == null) return; logger.w('rlottie render failed (${job.url}): ${error.message}'); if (!job.completer.isCompleted) job.completer.complete(null); if (job.clip.frameCount == 0) { _clips.remove(job.clip.key); job.clip.dispose(); } } Future acquire(String url, int px, {String? inlineJson}) async { if (!available) return null; final key = _keyFor(url, px); final cached = _clips[key]; if (cached != null) { cached.lastUsed = _tick(); cached.active++; return cached; } final pending = _loading[key]; if (pending != null) { final clip = await pending; if (clip != null) { clip.lastUsed = _tick(); clip.active++; } return clip; } final future = _load(url, px, key, inlineJson); _loading[key] = future; final clip = await future; _loading.remove(key); if (clip != null) { clip.lastUsed = _tick(); clip.active++; } return clip; } Future _load( String url, int px, String key, String? inlineJson) async { if (inlineJson == null) { final disk = await RlottieDiskCache.instance.load(url, px); if (disk != null) { final clip = await _clipFromDisk(key, px, disk); _clips[key] = clip; return clip; } } final json = inlineJson ?? await _fetchJson(url); if (json == null) return null; final clip = RlottieClip(key: key, px: px); _clips[key] = clip; final jobId = _nextJobId++; final completer = Completer(); _jobs[jobId] = _Job(clip, url, completer); final port = await _worker(); port.send(RenderJob( jobId: jobId, json: json, cacheKey: url, px: px, libPath: debugLibraryPath, )); return completer.future; } Future _clipFromDisk(String key, int px, DiskClip disk) async { final clip = RlottieClip(key: key, px: px) ..frameCount = disk.frameCount ..frameRate = disk.frameRate ..durationMs = disk.durationMs ..complete = true; clip._allocate(disk.frameCount); for (var i = 0; i < disk.frameCount; i++) { final image = await _decode(disk.frames[i], px); clip._setFrame(i, image); _totalBytes += px * px * 4; } _evictIfNeeded(); return clip; } Future _decode(Uint8List rgba, int px) { final completer = Completer(); ui.decodeImageFromPixels( rgba, px, px, ui.PixelFormat.rgba8888, completer.complete); return completer.future; } Future _fetchJson(String url) async { try { final file = await DefaultCacheManager().getSingleFile(url); return await file.readAsString(); } catch (e) { logger.w('rlottie fetch failed ($url): $e'); return null; } } Future prewarm(String url, int px) async { if (!available) return; final clip = await acquire(url, px); if (clip != null) release(clip); } void release(RlottieClip clip) { if (clip.active > 0) clip.active--; clip.lastUsed = _tick(); _evictIfNeeded(); } void _evictIfNeeded() { if (_totalBytes <= _maxBytes) return; final candidates = _clips.values.where((c) => c.active <= 0).toList() ..sort((a, b) => a.lastUsed.compareTo(b.lastUsed)); for (final clip in candidates) { if (_totalBytes <= _maxBytes) break; _totalBytes -= clip.bytes; _clips.remove(clip.key); clip.dispose(); } } }