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;