feat: переделал lottie -> rlottie, с первым прогревом кадров в фоне, как в тг, чтоб лагов не было
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
export 'rlottie_engine_stub.dart'
|
||||
if (dart.library.io) 'rlottie_engine.dart';
|
||||
@@ -0,0 +1,184 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:isolate';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import '../../utils/logger.dart';
|
||||
|
||||
class DiskClip {
|
||||
DiskClip({
|
||||
required this.px,
|
||||
required this.frameCount,
|
||||
required this.frameRate,
|
||||
required this.durationMs,
|
||||
required this.frames,
|
||||
});
|
||||
|
||||
final int px;
|
||||
final int frameCount;
|
||||
final double frameRate;
|
||||
final int durationMs;
|
||||
final List<Uint8List> frames;
|
||||
}
|
||||
|
||||
class RlottieDiskCache {
|
||||
RlottieDiskCache._();
|
||||
static final RlottieDiskCache instance = RlottieDiskCache._();
|
||||
|
||||
static const _magic = 0x4b524c46;
|
||||
static const _version = 1;
|
||||
static const int _maxBytes = 256 * 1024 * 1024;
|
||||
|
||||
Directory? _dir;
|
||||
Future<Directory>? _dirFuture;
|
||||
|
||||
Future<Directory> _directory() {
|
||||
return _dirFuture ??= () async {
|
||||
final base = await getApplicationSupportDirectory();
|
||||
final dir = Directory('${base.path}/rlottie_frames');
|
||||
if (!await dir.exists()) await dir.create(recursive: true);
|
||||
_dir = dir;
|
||||
return dir;
|
||||
}();
|
||||
}
|
||||
|
||||
String _key(String url, int px) {
|
||||
final digest = sha1.convert(url.codeUnits).toString().substring(0, 20);
|
||||
return '${digest}_$px.krlf';
|
||||
}
|
||||
|
||||
Future<File> _file(String url, int px) async {
|
||||
final dir = await _directory();
|
||||
return File('${dir.path}/${_key(url, px)}');
|
||||
}
|
||||
|
||||
Future<DiskClip?> load(String url, int px) async {
|
||||
try {
|
||||
final file = await _file(url, px);
|
||||
if (!await file.exists()) return null;
|
||||
final bytes = await file.readAsBytes();
|
||||
final clip = await _decode(bytes);
|
||||
if (clip != null) {
|
||||
unawaited(file.setLastModified(DateTime.now()).catchError((_) {}));
|
||||
}
|
||||
return clip;
|
||||
} catch (e) {
|
||||
logger.w('RlottieDiskCache.load failed: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> store({
|
||||
required String url,
|
||||
required int px,
|
||||
required int frameCount,
|
||||
required double frameRate,
|
||||
required int durationMs,
|
||||
required List<Uint8List> frames,
|
||||
}) async {
|
||||
if (frames.isEmpty || frames.length != frameCount) return;
|
||||
try {
|
||||
final bytes = await _encode(px, frameCount, frameRate, durationMs, frames);
|
||||
final file = await _file(url, px);
|
||||
await file.writeAsBytes(bytes, flush: false);
|
||||
unawaited(_evict());
|
||||
} catch (e) {
|
||||
logger.w('RlottieDiskCache.store failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
static Future<Uint8List> _encode(
|
||||
int px,
|
||||
int frameCount,
|
||||
double frameRate,
|
||||
int durationMs,
|
||||
List<Uint8List> frames,
|
||||
) {
|
||||
return Isolate.run(() {
|
||||
final frameBytes = px * px * 4;
|
||||
final payload = Uint8List(frameBytes * frameCount);
|
||||
for (var i = 0; i < frameCount; i++) {
|
||||
payload.setRange(i * frameBytes, (i + 1) * frameBytes, frames[i]);
|
||||
}
|
||||
final compressed = gzip.encode(payload);
|
||||
final header = ByteData(28);
|
||||
header.setUint32(0, _magic);
|
||||
header.setUint8(4, _version);
|
||||
header.setUint32(8, px);
|
||||
header.setUint32(12, frameCount);
|
||||
header.setFloat64(16, frameRate);
|
||||
header.setUint32(24, durationMs);
|
||||
final out = BytesBuilder();
|
||||
out.add(header.buffer.asUint8List());
|
||||
out.add(compressed);
|
||||
return out.toBytes();
|
||||
});
|
||||
}
|
||||
|
||||
static Future<DiskClip?> _decode(Uint8List bytes) {
|
||||
return Isolate.run(() {
|
||||
if (bytes.length < 28) return null;
|
||||
final header = ByteData.sublistView(bytes, 0, 28);
|
||||
if (header.getUint32(0) != _magic) return null;
|
||||
if (header.getUint8(4) != _version) return null;
|
||||
final px = header.getUint32(8);
|
||||
final frameCount = header.getUint32(12);
|
||||
final frameRate = header.getFloat64(16);
|
||||
final durationMs = header.getUint32(24);
|
||||
final frameBytes = px * px * 4;
|
||||
final payload = Uint8List.fromList(gzip.decode(bytes.sublist(28)));
|
||||
if (payload.length != frameBytes * frameCount) return null;
|
||||
final frames = <Uint8List>[];
|
||||
for (var i = 0; i < frameCount; i++) {
|
||||
frames.add(Uint8List.sublistView(
|
||||
payload, i * frameBytes, (i + 1) * frameBytes));
|
||||
}
|
||||
return DiskClip(
|
||||
px: px,
|
||||
frameCount: frameCount,
|
||||
frameRate: frameRate,
|
||||
durationMs: durationMs,
|
||||
frames: frames,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _evict() async {
|
||||
try {
|
||||
final dir = _dir ?? await _directory();
|
||||
final files = await dir
|
||||
.list()
|
||||
.where((e) => e is File && e.path.endsWith('.krlf'))
|
||||
.cast<File>()
|
||||
.toList();
|
||||
var total = 0;
|
||||
final stats = <(File, FileStat)>[];
|
||||
for (final f in files) {
|
||||
final st = await f.stat();
|
||||
total += st.size;
|
||||
stats.add((f, st));
|
||||
}
|
||||
if (total <= _maxBytes) return;
|
||||
stats.sort((a, b) => a.$2.modified.compareTo(b.$2.modified));
|
||||
for (final (file, st) in stats) {
|
||||
if (total <= _maxBytes) break;
|
||||
total -= st.size;
|
||||
await file.delete().catchError((_) => file);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.w('RlottieDiskCache.evict failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> clear() async {
|
||||
try {
|
||||
final dir = await _directory();
|
||||
if (await dir.exists()) await dir.delete(recursive: true);
|
||||
_dir = null;
|
||||
_dirFuture = null;
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
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<ui.Image?> _images = const [];
|
||||
ui.Image? _lastImage;
|
||||
|
||||
final ValueNotifier<int> 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<ui.Image?>.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<RlottieClip?> completer;
|
||||
List<Uint8List?> 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<String, RlottieClip> _clips = {};
|
||||
final Map<String, Future<RlottieClip?>> _loading = {};
|
||||
final Map<int, _Job> _jobs = {};
|
||||
|
||||
int _totalBytes = 0;
|
||||
int _clock = 0;
|
||||
int _nextJobId = 1;
|
||||
|
||||
bool? _available;
|
||||
Future<SendPort>? _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<SendPort> _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<Uint8List?>.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<Uint8List>(),
|
||||
));
|
||||
}
|
||||
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<RlottieClip?> 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<RlottieClip?> _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<RlottieClip?>();
|
||||
_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<RlottieClip> _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<ui.Image> _decode(Uint8List rgba, int px) {
|
||||
final completer = Completer<ui.Image>();
|
||||
ui.decodeImageFromPixels(
|
||||
rgba, px, px, ui.PixelFormat.rgba8888, completer.complete);
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
Future<String?> _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<void> 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class RlottieClip {
|
||||
RlottieClip({this.px = 0});
|
||||
|
||||
final int px;
|
||||
int frameCount = 0;
|
||||
int durationMs = 1000;
|
||||
double frameRate = 60;
|
||||
final ValueNotifier<int> ready = ValueNotifier(0);
|
||||
|
||||
ui.Image? frameAt(int index) => null;
|
||||
}
|
||||
|
||||
class RlottieEngine {
|
||||
RlottieEngine._();
|
||||
static final RlottieEngine instance = RlottieEngine._();
|
||||
|
||||
static String? debugLibraryPath;
|
||||
|
||||
bool get available => false;
|
||||
|
||||
Future<RlottieClip?> acquire(String url, int px, {String? inlineJson}) async =>
|
||||
null;
|
||||
|
||||
Future<void> prewarm(String url, int px) async {}
|
||||
|
||||
void release(RlottieClip clip) {}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import 'dart:ffi';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:ffi/ffi.dart';
|
||||
|
||||
typedef _InitNative = Void Function();
|
||||
typedef _VoidFn = void Function();
|
||||
|
||||
typedef _FromDataNative = Pointer<Void> Function(
|
||||
Pointer<Utf8>, Pointer<Utf8>, Pointer<Utf8>);
|
||||
|
||||
typedef _SizeGetterNative = Size Function(Pointer<Void>);
|
||||
typedef _SizeGetter = int Function(Pointer<Void>);
|
||||
|
||||
typedef _DoubleGetterNative = Double Function(Pointer<Void>);
|
||||
typedef _DoubleGetter = double Function(Pointer<Void>);
|
||||
|
||||
typedef _RenderNative = Void Function(
|
||||
Pointer<Void>, Size, Pointer<Uint32>, Size, Size, Size);
|
||||
typedef _Render = void Function(
|
||||
Pointer<Void>, int, Pointer<Uint32>, int, int, int);
|
||||
|
||||
typedef _DestroyNative = Void Function(Pointer<Void>);
|
||||
typedef _Destroy = void Function(Pointer<Void>);
|
||||
|
||||
typedef _CacheSizeNative = Void Function(Size);
|
||||
typedef _CacheSize = void Function(int);
|
||||
|
||||
class RlottieBindings {
|
||||
RlottieBindings._(this._lib) {
|
||||
_init = _lib.lookupFunction<_InitNative, _VoidFn>('lottie_init');
|
||||
_shutdown = _lib.lookupFunction<_InitNative, _VoidFn>('lottie_shutdown');
|
||||
_fromData = _lib.lookupFunction<_FromDataNative, _FromDataNative>(
|
||||
'lottie_animation_from_data');
|
||||
_totalFrame = _lib.lookupFunction<_SizeGetterNative, _SizeGetter>(
|
||||
'lottie_animation_get_totalframe');
|
||||
_frameRate = _lib.lookupFunction<_DoubleGetterNative, _DoubleGetter>(
|
||||
'lottie_animation_get_framerate');
|
||||
_duration = _lib.lookupFunction<_DoubleGetterNative, _DoubleGetter>(
|
||||
'lottie_animation_get_duration');
|
||||
_render =
|
||||
_lib.lookupFunction<_RenderNative, _Render>('lottie_animation_render');
|
||||
_destroy = _lib
|
||||
.lookupFunction<_DestroyNative, _Destroy>('lottie_animation_destroy');
|
||||
_cacheSize = _lib.lookupFunction<_CacheSizeNative, _CacheSize>(
|
||||
'lottie_configure_model_cache_size');
|
||||
_init();
|
||||
}
|
||||
|
||||
final DynamicLibrary _lib;
|
||||
late final _VoidFn _init;
|
||||
late final _VoidFn _shutdown;
|
||||
late final _FromDataNative _fromData;
|
||||
late final _SizeGetter _totalFrame;
|
||||
late final _DoubleGetter _frameRate;
|
||||
late final _DoubleGetter _duration;
|
||||
late final _Render _render;
|
||||
late final _Destroy _destroy;
|
||||
late final _CacheSize _cacheSize;
|
||||
|
||||
static RlottieBindings? open({String? path}) {
|
||||
try {
|
||||
final lib = _openLibrary(path);
|
||||
return lib == null ? null : RlottieBindings._(lib);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static DynamicLibrary? _openLibrary(String? path) {
|
||||
if (path != null) return DynamicLibrary.open(path);
|
||||
if (Platform.isMacOS || Platform.isIOS) return DynamicLibrary.process();
|
||||
try {
|
||||
return DynamicLibrary.open(rlottieLibraryName);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Pointer<Void>? loadFromData(String data, String key) {
|
||||
final dataC = data.toNativeUtf8();
|
||||
final keyC = key.toNativeUtf8();
|
||||
final resC = ''.toNativeUtf8();
|
||||
try {
|
||||
final anim = _fromData(dataC, keyC, resC);
|
||||
return anim == nullptr ? null : anim;
|
||||
} finally {
|
||||
calloc.free(dataC);
|
||||
calloc.free(keyC);
|
||||
calloc.free(resC);
|
||||
}
|
||||
}
|
||||
|
||||
int totalFrame(Pointer<Void> anim) => _totalFrame(anim);
|
||||
double frameRate(Pointer<Void> anim) => _frameRate(anim);
|
||||
double duration(Pointer<Void> anim) => _duration(anim);
|
||||
|
||||
void render(Pointer<Void> anim, int frameNo, Pointer<Uint32> buffer, int px) {
|
||||
_render(anim, frameNo, buffer, px, px, px * 4);
|
||||
}
|
||||
|
||||
void destroy(Pointer<Void> anim) => _destroy(anim);
|
||||
|
||||
void configureModelCache(int bytes) => _cacheSize(bytes);
|
||||
|
||||
void shutdown() => _shutdown();
|
||||
}
|
||||
|
||||
String get rlottieLibraryName {
|
||||
if (Platform.isWindows) return 'rlottie.dll';
|
||||
return 'librlottie.so';
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import 'dart:ffi';
|
||||
import 'dart:isolate';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:ffi/ffi.dart';
|
||||
|
||||
import 'rlottie_ffi.dart';
|
||||
|
||||
class RenderJob {
|
||||
const RenderJob({
|
||||
required this.jobId,
|
||||
required this.json,
|
||||
required this.cacheKey,
|
||||
required this.px,
|
||||
this.libPath,
|
||||
});
|
||||
|
||||
final int jobId;
|
||||
final String json;
|
||||
final String cacheKey;
|
||||
final int px;
|
||||
final String? libPath;
|
||||
}
|
||||
|
||||
class ClipMeta {
|
||||
const ClipMeta({
|
||||
required this.jobId,
|
||||
required this.totalFrame,
|
||||
required this.frameRate,
|
||||
required this.durationMs,
|
||||
});
|
||||
|
||||
final int jobId;
|
||||
final int totalFrame;
|
||||
final double frameRate;
|
||||
final int durationMs;
|
||||
}
|
||||
|
||||
class RenderedFrame {
|
||||
const RenderedFrame({
|
||||
required this.jobId,
|
||||
required this.index,
|
||||
required this.data,
|
||||
required this.px,
|
||||
});
|
||||
|
||||
final int jobId;
|
||||
final int index;
|
||||
final TransferableTypedData data;
|
||||
final int px;
|
||||
}
|
||||
|
||||
class RenderDone {
|
||||
const RenderDone(this.jobId);
|
||||
final int jobId;
|
||||
}
|
||||
|
||||
class RenderError {
|
||||
const RenderError(this.jobId, this.message);
|
||||
final int jobId;
|
||||
final String message;
|
||||
}
|
||||
|
||||
class CancelJob {
|
||||
const CancelJob(this.jobId);
|
||||
final int jobId;
|
||||
}
|
||||
|
||||
void rlottieWorkerMain(SendPort toMain) {
|
||||
final port = ReceivePort();
|
||||
toMain.send(port.sendPort);
|
||||
|
||||
final cancelled = <int>{};
|
||||
RlottieBindings? bindings;
|
||||
String? boundLibPath;
|
||||
|
||||
port.listen((message) {
|
||||
if (message is CancelJob) {
|
||||
cancelled.add(message.jobId);
|
||||
return;
|
||||
}
|
||||
if (message is! RenderJob) return;
|
||||
|
||||
final job = message;
|
||||
cancelled.remove(job.jobId);
|
||||
|
||||
if (bindings == null || boundLibPath != job.libPath) {
|
||||
bindings = RlottieBindings.open(path: job.libPath);
|
||||
boundLibPath = job.libPath;
|
||||
}
|
||||
final rl = bindings;
|
||||
if (rl == null) {
|
||||
toMain.send(RenderError(job.jobId, 'rlottie unavailable'));
|
||||
return;
|
||||
}
|
||||
|
||||
final anim = rl.loadFromData(job.json, job.cacheKey);
|
||||
if (anim == null) {
|
||||
toMain.send(RenderError(job.jobId, 'parse failed'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final total = rl.totalFrame(anim);
|
||||
final fps = rl.frameRate(anim);
|
||||
final durationMs = fps <= 0 ? 1000 : (total / fps * 1000).round();
|
||||
toMain.send(ClipMeta(
|
||||
jobId: job.jobId,
|
||||
totalFrame: total,
|
||||
frameRate: fps,
|
||||
durationMs: durationMs,
|
||||
));
|
||||
|
||||
final px = job.px;
|
||||
final buffer = calloc<Uint32>(px * px);
|
||||
try {
|
||||
for (var i = 0; i < total; i++) {
|
||||
if (cancelled.contains(job.jobId)) break;
|
||||
rl.render(anim, i, buffer, px);
|
||||
final rgba = _bgraToRgba(buffer, px * px);
|
||||
toMain.send(RenderedFrame(
|
||||
jobId: job.jobId,
|
||||
index: i,
|
||||
data: TransferableTypedData.fromList([rgba]),
|
||||
px: px,
|
||||
));
|
||||
}
|
||||
} finally {
|
||||
calloc.free(buffer);
|
||||
}
|
||||
toMain.send(RenderDone(job.jobId));
|
||||
} catch (e) {
|
||||
toMain.send(RenderError(job.jobId, e.toString()));
|
||||
} finally {
|
||||
rl.destroy(anim);
|
||||
cancelled.remove(job.jobId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Uint8List _bgraToRgba(Pointer<Uint32> buffer, int pixels) {
|
||||
final src = buffer.asTypedList(pixels);
|
||||
final out = Uint8List(pixels * 4);
|
||||
for (var i = 0; i < pixels; i++) {
|
||||
final v = src[i];
|
||||
final o = i * 4;
|
||||
out[o] = (v >> 16) & 0xff;
|
||||
out[o + 1] = (v >> 8) & 0xff;
|
||||
out[o + 2] = v & 0xff;
|
||||
out[o + 3] = (v >> 24) & 0xff;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import '../../../backend/modules/animoji.dart';
|
||||
import '../../../models/animoji.dart';
|
||||
import '../../../backend/modules/complaints.dart';
|
||||
import '../../../core/calls/call_controller.dart';
|
||||
import '../../../core/media/rlottie/rlottie.dart';
|
||||
import '../calls/call_screen.dart';
|
||||
import '../../../core/protocol/opcode_map.dart';
|
||||
import '../../../core/protocol/packet.dart';
|
||||
@@ -460,13 +461,29 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
|
||||
bool get _selectionMode => _selectedIds.value.isNotEmpty;
|
||||
|
||||
void _prewarmQuickReactions() {
|
||||
if (!mounted || !RlottieEngine.instance.available) return;
|
||||
final dpr =
|
||||
(MediaQuery.maybeOf(context)?.devicePixelRatio ?? 2.0).clamp(1.0, 2.0);
|
||||
final px = ((44.0 * dpr).clamp(96.0, 512.0) / 32).ceil() * 32;
|
||||
for (final a in animojiModule.quickAnimojis) {
|
||||
final url = a.lottieUrl;
|
||||
if (url != null && url.isNotEmpty) {
|
||||
unawaited(RlottieEngine.instance.prewarm(url, px));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_chatController.chatId = widget.chatId;
|
||||
_chatController.isMounted = () => mounted;
|
||||
unawaited(PushService.clearChatNotification(widget.chatId));
|
||||
unawaited(animojiModule.ensureLoaded().catchError((_) {}));
|
||||
unawaited(animojiModule
|
||||
.ensureLoaded()
|
||||
.then((_) => _prewarmQuickReactions())
|
||||
.catchError((_) {}));
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
chats.chatsChanged.addListener(_onChatsBump);
|
||||
_messageController.addListener(_onTextChanged);
|
||||
|
||||
@@ -6,6 +6,8 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:lottie/lottie.dart';
|
||||
|
||||
import '../../core/media/rlottie/rlottie.dart';
|
||||
|
||||
class LottieLoadGovernor {
|
||||
LottieLoadGovernor._() {
|
||||
_budgetMs = _resolveBudgetMs();
|
||||
@@ -43,158 +45,6 @@ class LottieLoadGovernor {
|
||||
}
|
||||
}
|
||||
|
||||
class _LottieFrames {
|
||||
final LottieDrawable drawable;
|
||||
final int frameCount;
|
||||
final Duration duration;
|
||||
final int pxSize;
|
||||
final List<ui.Image?> _images;
|
||||
ui.Image? _lastImage;
|
||||
int bytes = 0;
|
||||
int lastUsed = 0;
|
||||
int active = 0;
|
||||
|
||||
_LottieFrames({
|
||||
required this.drawable,
|
||||
required this.frameCount,
|
||||
required this.duration,
|
||||
required this.pxSize,
|
||||
}) : _images = List<ui.Image?>.filled(frameCount, null);
|
||||
|
||||
ui.Image frameAt(int index) {
|
||||
final existing = _images[index];
|
||||
if (existing != null) {
|
||||
_lastImage = existing;
|
||||
return existing;
|
||||
}
|
||||
|
||||
final last = _lastImage;
|
||||
if (last != null && LottieLoadGovernor.instance.throttled.value) {
|
||||
return last;
|
||||
}
|
||||
|
||||
final progress = frameCount <= 1 ? 0.0 : index / (frameCount - 1);
|
||||
final recorder = ui.PictureRecorder();
|
||||
final canvas = Canvas(recorder);
|
||||
drawable.setProgress(progress);
|
||||
drawable.draw(
|
||||
canvas,
|
||||
Rect.fromLTWH(0, 0, pxSize.toDouble(), pxSize.toDouble()),
|
||||
fit: BoxFit.contain,
|
||||
);
|
||||
final picture = recorder.endRecording();
|
||||
final image = picture.toImageSync(pxSize, pxSize);
|
||||
picture.dispose();
|
||||
|
||||
_images[index] = image;
|
||||
_lastImage = image;
|
||||
final added = pxSize * pxSize * 4;
|
||||
bytes += added;
|
||||
_LottieFrameCache.instance._onBytesAdded(added);
|
||||
return image;
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
for (final image in _images) {
|
||||
image?.dispose();
|
||||
}
|
||||
_images.fillRange(0, _images.length, null);
|
||||
_lastImage = null;
|
||||
bytes = 0;
|
||||
}
|
||||
}
|
||||
|
||||
class _LottieFrameCache {
|
||||
_LottieFrameCache._();
|
||||
static final _LottieFrameCache instance = _LottieFrameCache._();
|
||||
|
||||
static const int _maxBytes = 384 * 1024 * 1024;
|
||||
static const double _fps = 30;
|
||||
|
||||
final Map<String, _LottieFrames> _entries = {};
|
||||
final Map<String, Future<_LottieFrames?>> _loading = {};
|
||||
int _totalBytes = 0;
|
||||
int _clock = 0;
|
||||
|
||||
int _tick() => ++_clock;
|
||||
|
||||
Future<_LottieFrames?> acquire(String url, int pxSize) async {
|
||||
final key = '$url@$pxSize';
|
||||
final cached = _entries[key];
|
||||
if (cached != null) {
|
||||
cached.lastUsed = _tick();
|
||||
cached.active++;
|
||||
return cached;
|
||||
}
|
||||
final pending = _loading[key];
|
||||
if (pending != null) {
|
||||
final entry = await pending;
|
||||
if (entry != null) {
|
||||
entry.lastUsed = _tick();
|
||||
entry.active++;
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
final future = _load(url, pxSize, key);
|
||||
_loading[key] = future;
|
||||
final entry = await future;
|
||||
_loading.remove(key);
|
||||
if (entry != null) {
|
||||
entry.lastUsed = _tick();
|
||||
entry.active++;
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
void release(_LottieFrames frames) {
|
||||
if (frames.active > 0) frames.active--;
|
||||
frames.lastUsed = _tick();
|
||||
_evictIfNeeded();
|
||||
}
|
||||
|
||||
Future<_LottieFrames?> _load(String url, int pxSize, String key) async {
|
||||
try {
|
||||
final composition = await NetworkLottie(
|
||||
url,
|
||||
backgroundLoading: true,
|
||||
).load();
|
||||
final durationMs = composition.duration.inMilliseconds;
|
||||
var frameCount = (durationMs / 1000 * _fps).round();
|
||||
frameCount = frameCount.clamp(1, 120);
|
||||
final entry = _LottieFrames(
|
||||
drawable: LottieDrawable(composition),
|
||||
frameCount: frameCount,
|
||||
duration: durationMs <= 0
|
||||
? const Duration(seconds: 1)
|
||||
: composition.duration,
|
||||
pxSize: pxSize,
|
||||
);
|
||||
_entries[key] = entry;
|
||||
return entry;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
void _onBytesAdded(int bytes) {
|
||||
_totalBytes += bytes;
|
||||
_evictIfNeeded();
|
||||
}
|
||||
|
||||
void _evictIfNeeded() {
|
||||
if (_totalBytes <= _maxBytes) return;
|
||||
final candidates =
|
||||
_entries.entries.where((e) => e.value.active <= 0).toList()
|
||||
..sort((a, b) => a.value.lastUsed.compareTo(b.value.lastUsed));
|
||||
for (final candidate in candidates) {
|
||||
if (_totalBytes <= _maxBytes) break;
|
||||
_totalBytes -= candidate.value.bytes;
|
||||
candidate.value.dispose();
|
||||
_entries.remove(candidate.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class LottieScrollScope extends InheritedWidget {
|
||||
final ValueListenable<bool> isScrolling;
|
||||
|
||||
@@ -233,9 +83,12 @@ class LottiePlayer extends StatefulWidget {
|
||||
|
||||
class _LottiePlayerState extends State<LottiePlayer>
|
||||
with SingleTickerProviderStateMixin {
|
||||
static const int _leadFrames = 6;
|
||||
|
||||
final ValueNotifier<int> _frameIndex = ValueNotifier(0);
|
||||
late final Ticker _ticker;
|
||||
_LottieFrames? _frames;
|
||||
late final bool _native;
|
||||
RlottieClip? _clip;
|
||||
ValueListenable<bool>? _scrollState;
|
||||
int? _px;
|
||||
bool _started = false;
|
||||
@@ -248,6 +101,7 @@ class _LottiePlayerState extends State<LottiePlayer>
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_native = RlottieEngine.instance.available;
|
||||
_ticker = createTicker(_onTick);
|
||||
LottieLoadGovernor.instance.throttled.addListener(_onGateChanged);
|
||||
}
|
||||
@@ -268,9 +122,7 @@ class _LottiePlayerState extends State<LottiePlayer>
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.lottieUrl != widget.lottieUrl) {
|
||||
_ticker.stop();
|
||||
final previous = _frames;
|
||||
if (previous != null) _LottieFrameCache.instance.release(previous);
|
||||
_frames = null;
|
||||
_releaseClip();
|
||||
_started = false;
|
||||
_showedFrames = false;
|
||||
}
|
||||
@@ -281,21 +133,29 @@ class _LottiePlayerState extends State<LottiePlayer>
|
||||
LottieLoadGovernor.instance.throttled.removeListener(_onGateChanged);
|
||||
_scrollState?.removeListener(_onGateChanged);
|
||||
_ticker.dispose();
|
||||
final frames = _frames;
|
||||
if (frames != null) _LottieFrameCache.instance.release(frames);
|
||||
_releaseClip();
|
||||
_frameIndex.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _releaseClip() {
|
||||
final clip = _clip;
|
||||
if (clip != null) {
|
||||
clip.ready.removeListener(_onReady);
|
||||
RlottieEngine.instance.release(clip);
|
||||
_clip = null;
|
||||
}
|
||||
}
|
||||
|
||||
void _onTick(Duration elapsed) {
|
||||
final frames = _frames;
|
||||
if (frames == null || frames.frameCount <= 1) return;
|
||||
final periodMs = frames.duration.inMilliseconds;
|
||||
final clip = _clip;
|
||||
if (clip == null || clip.frameCount <= 1) return;
|
||||
final periodMs = clip.durationMs;
|
||||
if (periodMs <= 0) return;
|
||||
final t = (elapsed.inMilliseconds % periodMs) / periodMs;
|
||||
final index = (t * (frames.frameCount - 1)).round().clamp(
|
||||
final index = (t * (clip.frameCount - 1)).round().clamp(
|
||||
0,
|
||||
frames.frameCount - 1,
|
||||
clip.frameCount - 1,
|
||||
);
|
||||
if (index != _frameIndex.value) _frameIndex.value = index;
|
||||
}
|
||||
@@ -306,16 +166,29 @@ class _LottiePlayerState extends State<LottiePlayer>
|
||||
if (_ticker.isActive) _ticker.stop();
|
||||
return;
|
||||
}
|
||||
final frames = _frames;
|
||||
if (frames != null) {
|
||||
if (!_ticker.isActive && frames.frameCount > 1) _ticker.start();
|
||||
final clip = _clip;
|
||||
if (clip != null) {
|
||||
_maybeStartTicker(clip);
|
||||
} else if (_canLoad && !_started) {
|
||||
_startLoad();
|
||||
}
|
||||
}
|
||||
|
||||
void _onReady() {
|
||||
if (!mounted) return;
|
||||
final clip = _clip;
|
||||
if (clip != null) _maybeStartTicker(clip);
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
void _maybeStartTicker(RlottieClip clip) {
|
||||
if (_isScrolling || clip.frameCount <= 1) return;
|
||||
final lead = clip.frameCount < _leadFrames ? clip.frameCount : _leadFrames;
|
||||
if (clip.ready.value >= lead && !_ticker.isActive) _ticker.start();
|
||||
}
|
||||
|
||||
void _ensure(double box) {
|
||||
if (_frames != null) return;
|
||||
if (_clip != null) return;
|
||||
final dpr = MediaQuery.devicePixelRatioOf(context);
|
||||
final raw = (box * dpr.clamp(1.0, 2.0)).clamp(96.0, 512.0);
|
||||
_px = (raw / 32).ceil() * 32;
|
||||
@@ -327,19 +200,21 @@ class _LottiePlayerState extends State<LottiePlayer>
|
||||
final px = _px;
|
||||
if (_started || px == null) return;
|
||||
_started = true;
|
||||
_LottieFrameCache.instance.acquire(widget.lottieUrl, px).then((frames) {
|
||||
if (frames == null) return;
|
||||
RlottieEngine.instance.acquire(widget.lottieUrl, px).then((clip) {
|
||||
if (clip == null) return;
|
||||
if (!mounted) {
|
||||
_LottieFrameCache.instance.release(frames);
|
||||
RlottieEngine.instance.release(clip);
|
||||
return;
|
||||
}
|
||||
setState(() => _frames = frames);
|
||||
if (!_isScrolling && frames.frameCount > 1) _ticker.start();
|
||||
clip.ready.addListener(_onReady);
|
||||
setState(() => _clip = clip);
|
||||
_maybeStartTicker(clip);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!_native) return _nativeFallback();
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final box =
|
||||
@@ -348,15 +223,17 @@ class _LottiePlayerState extends State<LottiePlayer>
|
||||
? constraints.biggest.shortestSide
|
||||
: 96.0);
|
||||
_ensure(box);
|
||||
final frames = _frames;
|
||||
if (frames == null || (_isScrolling && !_showedFrames)) {
|
||||
return _fallback(box);
|
||||
final clip = _clip;
|
||||
if (clip == null ||
|
||||
clip.ready.value == 0 ||
|
||||
(_isScrolling && !_showedFrames)) {
|
||||
return _staticFallback(box);
|
||||
}
|
||||
_showedFrames = true;
|
||||
return ValueListenableBuilder<int>(
|
||||
valueListenable: _frameIndex,
|
||||
builder: (_, index, _) => RawImage(
|
||||
image: frames.frameAt(index),
|
||||
image: clip.frameAt(index),
|
||||
width: box,
|
||||
height: box,
|
||||
fit: BoxFit.contain,
|
||||
@@ -366,7 +243,18 @@ class _LottiePlayerState extends State<LottiePlayer>
|
||||
);
|
||||
}
|
||||
|
||||
Widget _fallback(double box) {
|
||||
Widget _nativeFallback() {
|
||||
return Lottie.network(
|
||||
widget.lottieUrl,
|
||||
width: widget.size,
|
||||
height: widget.size,
|
||||
fit: BoxFit.contain,
|
||||
frameRate: FrameRate.max,
|
||||
errorBuilder: (context, _, _) => _staticFallback(widget.size ?? 96.0),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _staticFallback(double box) {
|
||||
final url = widget.fallbackUrl ?? '';
|
||||
final blank = SizedBox(width: box, height: box);
|
||||
if (url.isEmpty) return blank;
|
||||
|
||||
Reference in New Issue
Block a user