From 0bbcf3e7c24c6bb46a4bd3f5fd7fb94aa391a3f3 Mon Sep 17 00:00:00 2001 From: Kalvin C Date: Thu, 12 Nov 2026 17:43:04 -0800 Subject: [PATCH] fix(mobile): play short-delay animation frames at browser speed (#9432) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some animated custom emoji play far faster on mobile than on desktop. Browsers show any animation frame with a delay of 10 ms or less for 100 ms. Firefox started this ([bug 207059](https://bugzilla.mozilla.org/show_bug.cgi?id=207059)). Chromium copied it (decoder comment: "use a duration of 100 ms for any frames that specify a duration of <= 10 ms"), and so did WebKit ([changeset 73295](https://trac.webkit.org/changeset/73295/webkit)). Flutter uses the delay as written ([flutter/flutter#130684](https://github.com/flutter/flutter/issues/130684)), so a GIF with 0 or 1 centisecond delays runs at the display refresh rate on mobile and at 10 fps on desktop. `MediaImageProvider` now wraps multi-frame codecs so frames of 10 ms or less get 100 ms. Every other frame keeps its own delay. Still images are passed through unchanged. Custom emoji, avatars and attachments all render through this provider. **Tests:** `flutter test test/shared/relay/media_image_test.dart` passes (24/24), and `flutter analyze` reports no issues on the changed files. The new test decodes a GIF with delays of 0, 1, 2 and 5 cs and expects 100, 100, 20 and 50 ms. It fails when the wrapper is removed. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Kalvin Chau Co-authored-by: Claude Opus 5.5 --- mobile/lib/shared/relay/media_image.dart | 43 +++++++++++++++++- .../test/shared/relay/media_image_test.dart | 45 +++++++++++++++++++ 2 files changed, 87 insertions(+), 1 deletion(-) diff --git a/mobile/lib/shared/relay/media_image.dart b/mobile/lib/shared/relay/media_image.dart index 7a2305e73a..049c5fef0c 100644 --- a/mobile/lib/shared/relay/media_image.dart +++ b/mobile/lib/shared/relay/media_image.dart @@ -248,7 +248,7 @@ class MediaImageProvider extends ImageProvider { if (wasAbandoned) throw StateError('Image load abandoned'); onBytesLoaded?.call(bytes); final buffer = await ui.ImmutableBuffer.fromUint8List(bytes); - return await decode(buffer); + return withBrowserFrameTiming(await decode(buffer)); } catch (_) { // An abandoned load has no listener left to report to, and an error // would surface as an unhandled image error. The consumer already @@ -525,6 +525,47 @@ class _MediaImageStreamCompleter extends MultiFrameImageStreamCompleter { } } +/// Shows animation frames that ask for 10 ms or less for 100 ms instead, as +/// browsers do (Firefox first, then Chromium and WebKit), so an animated +/// emoji plays at the same speed as on desktop. Flutter uses the delay as +/// written, so a GIF with 0 or 1 centisecond delays otherwise runs at the +/// display refresh rate. +@visibleForTesting +ui.Codec withBrowserFrameTiming(ui.Codec codec) => + codec.frameCount > 1 ? _BrowserFrameTimingCodec(codec) : codec; + +class _BrowserFrameTimingCodec implements ui.Codec { + _BrowserFrameTimingCodec(this._codec); + + final ui.Codec _codec; + + @override + int get frameCount => _codec.frameCount; + + @override + int get repetitionCount => _codec.repetitionCount; + + @override + Future getNextFrame() async { + final frame = await _codec.getNextFrame(); + if (frame.duration > const Duration(milliseconds: 10)) return frame; + return _SlowedFrame(frame.image); + } + + @override + void dispose() => _codec.dispose(); +} + +class _SlowedFrame implements ui.FrameInfo { + _SlowedFrame(this.image); + + @override + final ui.Image image; + + @override + Duration get duration => const Duration(milliseconds: 100); +} + /// One download shared by every load of the same URL, auth scope and role /// (direct or thumbnail fallback). It is aborted once every load waiting for /// it has been abandoned, and a load that arrives after that starts a new diff --git a/mobile/test/shared/relay/media_image_test.dart b/mobile/test/shared/relay/media_image_test.dart index 789fa9dd02..dc97a293bb 100644 --- a/mobile/test/shared/relay/media_image_test.dart +++ b/mobile/test/shared/relay/media_image_test.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'dart:async'; import 'dart:typed_data'; +import 'dart:ui' as ui; import 'package:buzz/shared/relay/media_auth.dart'; import 'package:buzz/shared/relay/media_image.dart'; @@ -34,6 +35,50 @@ void main() { PaintingBinding.instance.imageCache.clearLiveImages(); }); + group('withBrowserFrameTiming', () { + // A 1x1 GIF with one frame per delay, in centiseconds. + Uint8List gif(List delays) => Uint8List.fromList([ + ...ascii.encode('GIF89a'), + 1, 0, 1, 0, 0x80, 0, 0, // 1x1, two-colour global table + 0, 0, 0, 0xff, 0xff, 0xff, + for (final delay in delays) ...[ + 0x21, 0xf9, 4, 0, delay & 0xff, delay >> 8, 0, 0, // frame delay + 0x2c, 0, 0, 0, 0, 1, 0, 1, 0, 0, // 1x1 image + 2, 2, 0x44, 1, 0, // one pixel + ], + 0x3b, + ]); + + Future> durations(Uint8List bytes) async { + final codec = withBrowserFrameTiming( + await ui.instantiateImageCodec(bytes), + ); + final result = []; + for (var i = 0; i < codec.frameCount; i++) { + final frame = await codec.getNextFrame(); + result.add(frame.duration); + frame.image.dispose(); + } + codec.dispose(); + return result; + } + + test('shows frames of 10 ms or less for 100 ms, like browsers', () async { + expect(await durations(gif([0, 1, 2, 5])), const [ + Duration(milliseconds: 100), + Duration(milliseconds: 100), + Duration(milliseconds: 20), + Duration(milliseconds: 50), + ]); + }); + + test('leaves a still image alone', () async { + final codec = await ui.instantiateImageCodec(_pngBytes); + expect(withBrowserFrameTiming(codec), same(codec)); + codec.dispose(); + }); + }); + group('MediaGetAuthService mint-per-request', () { test('consecutive calls return distinct headers (mint-per-request)', () { final nsec = nostr.Keys.generate().nsec;