fix(mobile): play short-delay animation frames at browser speed (#9432)

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 <kalvin@block.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kalvin C
2026-11-12 17:43:04 -08:00
committed by GitHub
parent 279a31daac
commit 0bbcf3e7c2
2 changed files with 87 additions and 1 deletions

View File

@@ -248,7 +248,7 @@ class MediaImageProvider extends ImageProvider<MediaImageProvider> {
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<ui.FrameInfo> 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

View File

@@ -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<int> 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<List<Duration>> durations(Uint8List bytes) async {
final codec = withBrowserFrameTiming(
await ui.instantiateImageCodec(bytes),
);
final result = <Duration>[];
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;