Files
buzz/mobile/lib/shared/relay/media_image.dart
Tom Brow fa395e9e80 feat(mobile): open a prefilled Create agent form from Builderlab agent cards (#8912)
Builderlab agent cards (printed for the event, and generated at the
kiosk) carry a QR to a share page, `app.builderlab.xyz/a/<id>`. Its
**Add to Buzz** button opens `buzz://agent/new?card=<id>`, which no Buzz
client handled until now. This PR makes mobile handle it.

### Behavior

- Opening the link shows the Agents page, then a Create agent form
prefilled from the card. Everything stays editable, and nothing is
created until the user taps Create.
- The app fetches the card's `agent.json` from the avatars CDN for this
build's Builderlab environment (production or staging). The link carries
only the card id, never a host, so the QR stays small no matter how long
a card's instructions get.
- `agent.json` is validated against the card schema. `id` (matching the
link), `name` and `avatarUrl` (an https URL) are required.
`instructions`, `model` and `reasoningEffort` are optional (published
cards don't have them yet) and left for the user when absent. A missing
required field or a wrongly typed known field rejects the card; any
other key is ignored whatever its type, including `v` and `description`
(Buzz agents have no description to prefill), so Builderlab can add
fields without breaking released apps. The model and effort are
preselected only if the live catalog offers them. The card id is opaque:
any non-empty id is accepted and percent-encoded into the URL.
- The fetch uses the same limits as image downloads, now shared in
`bounded_fetch.dart`: aborted after 20 s without a byte or 5 min
overall, and rejected over 2 MB. `MediaImageProvider` now calls the same
helper, with no behavior change.
- The link waits for sign-in, then reloads the registry for the
signed-in account before anything else. It's dropped with a message if
the card is unavailable (403/404), invalid, can't be loaded (any other
response), or an agent setup is unfinished, including a setup saved
before a sign-in the link triggered.
- A link that arrives while Agents shows only its list replaces that
page instead of stacking a second one. If a form or sheet is open there,
the link is dropped with "Finish or close what’s open in Agents, then
reopen the link." and the open work is kept.

### Demo

Simulator (iPhone 17 Pro, debug build of this branch plus the sign-in
fix from [#8914](https://github.com/block/buzz/pull/8914) and the hidden
debug banner from [#8915](https://github.com/block/buzz/pull/8915),
signed in to Builderlab, remote-agent-pilot), recorded in one take with
idle time trimmed: the card's share page in Safari, Add to Buzz, the
prefilled Create agent form, a model, thinking level and instructions,
Create, then a message and the agent's reply.


https://github.com/user-attachments/assets/e66aae17-884a-4e68-a1b8-ecb40562be1d

### Validation

Added tests:
- Table tests for the link shape (only `?card=<id>` with an opaque id;
the inline `#` form, extra parameters and dot segments are rejected) and
for reading `agent.json` (a published card, a card with instructions and
model defaults, unknown keys of any type ignored, including `v`,
`description`, arrays, objects and null, and invalid cards: missing or
mismatched required fields, wrong types, and non-https or relative
avatars).
- Table tests for fetching: the request goes to this build's CDN without
following redirects, with the id percent-encoded as one path segment;
403/404, a redirect, a 500, a network error, declared and streamed
oversize bodies, non-JSON and a JSON array each give a specific message.
The image loader's existing stall, deadline, cap and cancel tests now
cover the shared helper.
- A journey test from link to Create against a fake CDN: prefill and
edit, then submit; an unavailable card; a failed model catalog whose
Retry then preselects the card's model; a Builderlab account change
while the card loads, which drops the draft; a v1 card with no
instructions and an unknown model; an unfinished setup; a link arriving
while a Create form with edits is open, which keeps the form and its
edits; and a link arriving while Agents is open. Disabling the https or
id check makes the table test fail, and removing either the account
recheck or the loaded-catalog check fails the journey. The journey runs
through the production root scope (removing its link handler fails it)
and runs in CI's configured-authentication-domains step.

Full mobile check and test suite pass locally.

Live data: a real staging card's `agent.json`
(`av_MioTAG6dm7q3Zcm0UV2j8A`) has the v1 shape the table test uses, and
an unknown id returns 403 from the CDN.

Live run on an iPhone SE (build of 63ed54c749, Builderlab sign-in
enabled, remote-agent-pilot, signed in as builderlab@tombrow.com) with a
real production card,
[ToastyMacbeth](https://app.builderlab.xyz/a/av_lrmRlJR6ylrQKvJkoLWCOQ)
(a v1 card with no instructions and an extra `description` key):
- Opening `buzz://agent/new?card=av_lrmRlJR6ylrQKvJkoLWCOQ` from a cold
start opens Create agent prefilled with the card's name and avatar.
- After choosing a model and adding instructions, Create adds the agent
with the card avatar, and it replies in a DM.
- An unknown card id shows "This agent card isn’t available."

| Prefilled | Created | Reply | Unknown card |
| --- | --- | --- | --- |
| <img
src="https://raw.githubusercontent.com/block/buzz/b9222db494758c3e536b8e2011032ba7cec86daa/pr-8912--01-prefilled.png"
width="200"> | <img
src="https://raw.githubusercontent.com/block/buzz/b9222db494758c3e536b8e2011032ba7cec86daa/pr-8912--02-created.png"
width="200"> | <img
src="https://raw.githubusercontent.com/block/buzz/b9222db494758c3e536b8e2011032ba7cec86daa/pr-8912--03-reply.png"
width="200"> | <img
src="https://raw.githubusercontent.com/block/buzz/b9222db494758c3e536b8e2011032ba7cec86daa/pr-8912--04-unavailable.png"
width="200"> |

Before this PR, the app had no handler for `buzz://agent/new`, so the
link did nothing.

A separate widget test covers a link that needs Builderlab sign-in
first: once sign-in lands, a setup saved for that account blocks the
card, which is never fetched. Removing the registry reload fails it, as
does removing the busy-Agents guard for the journey step above.

### Before merge

- [x] Human live check waived under the repository owner's standing
testing exception. In its place, agent-driven full-app runs exercised
the flow end to end: the iPhone SE device run and the simulator demo
above (a real card's share page, **Add to Buzz**, the prefilled form,
Create and a reply from the agent). Limitation: those runs predate
[638f5ecac7](638f5ecac7),
whose two guards are covered by the widget tests above rather than a
live run.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 13:00:08 -08:00

598 lines
21 KiB
Dart

import 'dart:async';
import 'dart:ui' as ui;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:http/http.dart' as http;
import '../hosted/hosted_relay_auth.dart';
import '../utils/bounded_fetch.dart';
import 'media_auth.dart';
/// Shared, keep-alive HTTP client for media fetches. One client per container
/// so TLS connections are reused across images instead of re-handshaking per
/// fetch. Override in tests to stub the network.
final mediaHttpClientProvider = Provider<http.Client>((ref) {
final client = ref.watch(hostedHttpClientFactoryProvider)();
ref.onDispose(client.close);
return client;
});
/// An [ImageProvider] for relay-hosted (and arbitrary remote) media that fixes
/// the fetch-stampede failure modes of `Image.network` + per-build auth
/// headers:
///
/// 1. **Stable cache identity.** Flutter's [NetworkImage] includes the full
/// headers map in its `==`/`hashCode`, so a freshly signed Authorization
/// header on every widget build made the *same URL* a new cache key and
/// bypassed the in-memory [ImageCache] entirely. This provider keys on
/// (url, scale, auth scope, isThumbnailFallback) and injects auth headers
/// at *fetch* time, so header refreshes never invalidate cached images.
/// 2. **Failure cooldown.** A URL that fails to load is not retried until a
/// cooldown elapses (honoring `Retry-After` on 429), so error retries on
/// rebuild cannot amplify into a request storm against the rate limiter.
///
/// The auth scope (relay base URL + signing identity) is part of key equality:
/// switching relay or account can never reuse another identity's cached bytes.
class MediaImageProvider extends ImageProvider<MediaImageProvider> {
final String url;
final double scale;
final MediaGetAuthService auth;
/// Optional observer for bytes already fetched by the foreground image path.
/// Excluded from equality because it is a side effect, not cache identity.
final ValueChanged<Uint8List>? onBytesLoaded;
/// Set when [url] is a thumbnail [MediaImage] chose in place of an
/// original: its 404 then records a missing thumbnail, so later builds load
/// the original, rather than a failure cooldown. Any other 404, including a
/// profile URL that is itself a thumbnail, keeps the cooldown. Part of
/// equality, the shared download's key and the cooldown's key, so a
/// fallback and a direct load of the same URL each keep their own failure
/// handling.
final bool isThumbnailFallback;
/// Excluded from equality: transport, not identity.
final http.Client client;
/// Longest wait for response headers or the next body chunk. A slow link
/// that keeps delivering bytes finishes; one that stops is aborted without
/// closing the shared client.
final Duration stallTimeout;
/// Longest a fetch may take even while bytes keep arriving, so a server
/// that trickles bytes cannot hold a connection indefinitely.
final Duration maxFetchDuration;
const MediaImageProvider({
required this.url,
required this.auth,
required this.client,
this.onBytesLoaded,
this.isThumbnailFallback = false,
this.scale = 1.0,
this.stallTimeout = defaultStallTimeout,
this.maxFetchDuration = defaultMaxFetchDuration,
});
/// Maximum encoded image body retained before decoding (20 MiB).
static const maxResponseBytes = 20 * 1024 * 1024;
static const _defaultCooldown = Duration(seconds: 30);
static const _maxRetryAfter = Duration(minutes: 5);
/// Keyed by (url, isThumbnailFallback), like [_inFlight] minus auth: a
/// direct load's failure must not stop a fallback to the same URL from
/// recording a missing thumbnail.
static final Map<(String, bool), DateTime> _cooldownUntil = {};
static final Map<(String, MediaGetAuthService, bool), _SharedFetch>
_inFlight = {};
/// Injectable clock for cooldown tests.
@visibleForTesting
static DateTime Function() debugNow = DateTime.now;
@visibleForTesting
static void debugResetCooldowns() => _cooldownUntil.clear();
@visibleForTesting
static Iterable<String> get debugCooldownUrls =>
_cooldownUntil.keys.map((key) => key.$1);
(String, bool) get _cooldownKey => (url, isThumbnailFallback);
@override
Future<MediaImageProvider> obtainKey(ImageConfiguration configuration) {
return SynchronousFuture<MediaImageProvider>(this);
}
@override
ImageStreamCompleter loadImage(
MediaImageProvider key,
ImageDecoderCallback decode,
) {
final abandoned = Completer<void>();
// The image cache listens to a pending load, so this fires mid-download
// only once a consumer evicts the entry and stops listening (see
// [abandonPendingImage]), as [MediaImage] does when it stops showing an
// image before its first frame. Abort the download then.
return _MediaImageStreamCompleter(
codec: _loadAsync(key, decode, abandoned.future),
scale: key.scale,
debugLabel: url,
)..addOnLastListenerRemovedCallback(() {
if (!abandoned.isCompleted) abandoned.complete();
});
}
Future<ui.Codec> _loadAsync(
MediaImageProvider key,
ImageDecoderCallback decode,
Future<void> abandoned,
) async {
var wasAbandoned = false;
try {
// Each decode size is its own cache entry (see [ResizeImage]), so one
// URL shown at two sizes resolves twice. Share the download instead of
// fetching the same bytes once per size.
final fetchKey = (url, auth, isThumbnailFallback);
final fetch = _inFlight[fetchKey] ??= _SharedFetch(
_fetchBytes,
onFinished: (fetch) {
if (_inFlight[fetchKey] == fetch) _inFlight.remove(fetchKey);
},
);
fetch.join();
var left = false;
void leave() {
if (left) return;
left = true;
fetch.leave();
}
unawaited(
abandoned.then((_) {
wasAbandoned = true;
leave();
}),
);
final Uint8List bytes;
try {
bytes = await fetch.bytes;
} finally {
leave();
}
// Another size kept the download going, but this load's consumer left.
if (wasAbandoned) throw StateError('Image load abandoned');
onBytesLoaded?.call(bytes);
final buffer = await ui.ImmutableBuffer.fromUint8List(bytes);
return 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
// evicted it; leave the codec unresolved.
if (wasAbandoned) return Completer<ui.Codec>().future;
// Match NetworkImage: make sure an errored key is not retained in the
// cache. The cooldown map (not the cache) throttles retries.
scheduleMicrotask(() {
PaintingBinding.instance.imageCache.evict(key);
});
rethrow;
}
}
/// Downloads [url]. Completing [abort] means every load waiting for this
/// download was abandoned, so the URL is not put on cooldown.
Future<Uint8List> _fetchBytes(Future<void> abort) async {
final until = _cooldownUntil[_cooldownKey];
if (until != null) {
if (debugNow().isBefore(until)) {
throw MediaImageCooldownException(url: url, until: until);
}
_cooldownUntil.remove(_cooldownKey);
}
final uri = Uri.parse(url);
final BoundedResponse response;
try {
response = await boundedGet(
client,
uri,
maxBytes: maxResponseBytes,
headers: auth.headersFor(url),
stallTimeout: stallTimeout,
maxFetchDuration: maxFetchDuration,
cancel: abort,
);
} catch (error) {
final wasAbandoned = error is FetchCancelledException;
// Nobody wanted the image any more; the URL itself did not fail.
if (!wasAbandoned) {
_cooldownUntil[_cooldownKey] = debugNow().add(_defaultCooldown);
}
rethrow;
}
if (response.statusCode == 404 && isThumbnailFallback) {
// Older or externally populated media may have no generated thumbnail.
// Record that here rather than in a widget's error builder: the widget
// that asked may be gone by the time a slow 404 arrives, and a
// cooldown would then keep every later build off the original.
MediaImage._rememberMissingThumbnail(url);
throw NetworkImageLoadException(statusCode: 404, uri: uri);
}
if (response.statusCode != 200) {
_cooldownUntil[_cooldownKey] = debugNow().add(_cooldownFor(response));
throw NetworkImageLoadException(
statusCode: response.statusCode,
uri: uri,
);
}
final bytes = response.bytes;
if (bytes.isEmpty) {
_cooldownUntil[_cooldownKey] = debugNow().add(_defaultCooldown);
throw NetworkImageLoadException(statusCode: 200, uri: uri);
}
return bytes;
}
Duration _cooldownFor(BoundedResponse response) {
final retryAfter = int.tryParse(response.headers['retry-after'] ?? '');
if (retryAfter != null && retryAfter > 0) {
final requested = Duration(seconds: retryAfter);
return requested > _maxRetryAfter ? _maxRetryAfter : requested;
}
return _defaultCooldown;
}
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType) return false;
return other is MediaImageProvider &&
other.url == url &&
other.scale == scale &&
other.auth == auth &&
other.isThumbnailFallback == isThumbnailFallback;
}
@override
int get hashCode => Object.hash(url, scale, auth, isThumbnailFallback);
@override
String toString() =>
'MediaImageProvider("$url", scale: ${scale.toStringAsFixed(1)})';
}
/// Stops waiting for [provider]'s image before its first frame arrives.
///
/// Call after removing your own listener from [stream]. Until the first frame
/// the image cache holds its own listener on the load, so removing a caller's
/// listener alone leaves the download running. When no other consumer is
/// listening, evicting the pending entry drops that last listener and a
/// [MediaImageProvider] load aborts its download. When another consumer still
/// listens, the entry stays so the finished image is still cached.
void abandonPendingImage(
ImageStream stream,
ImageProvider<Object> provider,
ImageConfiguration config,
) {
final completer = stream.completer;
if (completer is _MediaImageStreamCompleter &&
completer._listeners.length != 1) {
// Someone besides the cache still wants it, or it already finished.
return;
}
unawaited(
provider
.obtainKey(config)
.then(
(key) => PaintingBinding.instance.imageCache.evict(
key,
includeLive: false,
),
),
);
}
/// Tracks its listeners so [abandonPendingImage] can tell whether anyone
/// besides the image cache is still waiting for the load.
class _MediaImageStreamCompleter extends MultiFrameImageStreamCompleter {
_MediaImageStreamCompleter({
required super.codec,
required super.scale,
super.debugLabel,
});
final _listeners = <ImageStreamListener>[];
@override
void addListener(ImageStreamListener listener) {
_listeners.add(listener);
super.addListener(listener);
}
@override
void removeListener(ImageStreamListener listener) {
_listeners.remove(listener);
super.removeListener(listener);
}
}
/// 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
/// download.
class _SharedFetch {
_SharedFetch(
Future<Uint8List> Function(Future<void> abort) fetch, {
required this.onFinished,
}) {
bytes = fetch(_abort.future).whenComplete(() {
_done = true;
onFinished(this);
});
// Waiting loads handle errors; an abandoned download has none left.
bytes.ignore();
}
final void Function(_SharedFetch) onFinished;
final _abort = Completer<void>();
late final Future<Uint8List> bytes;
var _waiting = 0;
var _done = false;
void join() => _waiting++;
/// Called once per joined load, when it is abandoned or has its result.
void leave() {
_waiting--;
if (_waiting == 0 && !_done && !_abort.isCompleted) {
_abort.complete();
onFinished(this);
}
}
}
/// Thrown when a fetch is suppressed because the URL recently failed.
class MediaImageCooldownException implements Exception {
final String url;
final DateTime until;
const MediaImageCooldownException({required this.url, required this.until});
@override
String toString() =>
'MediaImageCooldownException: $url is cooling down until $until';
}
final _relayBlobPath = RegExp(r'^/media/([0-9a-f]{64})(?:\.[a-z0-9]{1,8})?$');
/// The relay's generated thumbnail for a relay-hosted image, or null when
/// [url] is not one. Every image uploaded to a relay gets a JPEG thumbnail at
/// `/media/<sha256>.thumb.jpg`, at most 320px on its longer side. This
/// matches desktop's small avatars (buzz-app `mediaUrl`), including the cost:
/// the thumbnail is a still JPEG, so transparent areas render black and
/// animation is lost.
String? relayThumbnailUrl(String url, MediaGetAuthService auth) {
if (!auth.isRelayMediaUrl(url)) return null;
final uri = Uri.parse(url);
if (uri.hasQuery || uri.hasFragment) return null;
final match = _relayBlobPath.firstMatch(uri.path);
if (match == null) return null;
return uri.replace(path: '/media/${match[1]}.thumb.jpg').toString();
}
/// Drop-in replacement for the media `Image.network` call sites.
///
/// Renders [url] through [MediaImageProvider] (stable cache key, fetch-time
/// auth, failure cooldown) and bounds the decode size so a handful of large
/// originals cannot thrash the global [ImageCache]:
///
/// - [decodeWidth] set: decode at that logical width (x device pixel ratio).
/// - otherwise, when [boundDecodeToLayout] is true (default): decode at the
/// layout width reported by [LayoutBuilder], when finite.
/// - [boundDecodeToLayout] false and no [decodeWidth]: full-resolution decode
/// (the zoomable full-screen viewer).
///
/// With [preferThumbnail], a relay image laid out small enough loads the
/// relay's generated thumbnail instead of the original upload, falling back
/// to the original when the relay has no thumbnail for it.
class MediaImage extends ConsumerWidget {
final String url;
final BoxFit? fit;
final double? width;
final double? height;
final String? semanticLabel;
final ImageErrorWidgetBuilder? errorBuilder;
final ImageFrameBuilder? frameBuilder;
final FilterQuality filterQuality;
final double? decodeWidth;
final bool boundDecodeToLayout;
final ValueChanged<Uint8List>? onBytesLoaded;
/// Load the relay's thumbnail when the laid-out image is at most
/// [thumbnailMaxPhysicalSize] pixels on each side. Avatars set this: an
/// original can be megabytes while it renders a few dozen points wide.
final bool preferThumbnail;
/// Relay thumbnails are at most 320px on their longer side, so a 4:3 one is
/// 240px on its shorter side. At or below this size, a cover-cropped
/// thumbnail of a 4:3 or squarer image is not upscaled.
static const thumbnailMaxPhysicalSize = 240.0;
const MediaImage({
super.key,
required this.url,
this.fit,
this.width,
this.height,
this.semanticLabel,
this.errorBuilder,
this.frameBuilder,
this.filterQuality = FilterQuality.medium,
this.decodeWidth,
this.boundDecodeToLayout = true,
this.onBytesLoaded,
this.preferThumbnail = false,
}) : assert(
!preferThumbnail || (decodeWidth == null && boundDecodeToLayout),
'preferThumbnail needs the layout size to choose a source',
),
assert(
!preferThumbnail || onBytesLoaded == null,
'thumbnail bytes are not the original upload',
),
assert(
!preferThumbnail || errorBuilder != null,
'a thumbnail failure other than 404 needs an error widget',
);
@override
Widget build(BuildContext context, WidgetRef ref) {
final auth = ref.watch(mediaGetAuthServiceProvider);
final client = ref.watch(mediaHttpClientProvider);
MediaImageProvider providerFor(
String source, {
bool isThumbnailFallback = false,
}) => MediaImageProvider(
url: source,
auth: auth,
client: client,
onBytesLoaded: onBytesLoaded,
isThumbnailFallback: isThumbnailFallback,
);
if (decodeWidth != null) {
return _image(providerFor(url), _physicalWidth(context, decodeWidth!));
}
if (!boundDecodeToLayout) {
return _image(providerFor(url), null);
}
return LayoutBuilder(
builder: (context, constraints) {
final maxWidth = constraints.maxWidth;
final cacheWidth = maxWidth.isFinite && maxWidth > 0
? _physicalWidth(context, maxWidth)
: null;
final candidate =
preferThumbnail && _fitsThumbnail(context, constraints)
? relayThumbnailUrl(url, auth)
: null;
final thumbnail = _missingThumbnails.contains(candidate)
? null
: candidate;
if (thumbnail == null) return _image(providerFor(url), cacheWidth);
return _image(
providerFor(thumbnail, isThumbnailFallback: true),
cacheWidth,
errorBuilder: (context, error, stackTrace) {
if (error is! NetworkImageLoadException ||
error.statusCode != 404) {
return errorBuilder!(context, error, stackTrace);
}
// The fetch already recorded the missing thumbnail, so later
// builds go straight to the original.
return _image(providerFor(url), cacheWidth);
},
);
},
);
}
/// Thumbnails the relay returned 404 for, oldest first. Bounded so a
/// community full of pre-thumbnail media cannot grow it without limit; an
/// evicted entry costs one more 404 before the original loads again.
static final Set<String> _missingThumbnails = <String>{};
static const _maxMissingThumbnails = 256;
/// Called by the fetch that got the 404, which puts no failure cooldown on
/// the thumbnail: this set stands in for it, and an entry evicted here then
/// costs one more 404 rather than showing the fallback.
static void _rememberMissingThumbnail(String url) {
_missingThumbnails
..remove(url)
..add(url);
if (_missingThumbnails.length > _maxMissingThumbnails) {
_missingThumbnails.remove(_missingThumbnails.first);
}
}
@visibleForTesting
static int get debugMissingThumbnailCount => _missingThumbnails.length;
@visibleForTesting
static void debugResetMissingThumbnails() => _missingThumbnails.clear();
bool _fitsThumbnail(BuildContext context, BoxConstraints constraints) {
if (!constraints.hasBoundedWidth || !constraints.hasBoundedHeight) {
return false;
}
final longest =
constraints.biggest.longestSide *
MediaQuery.devicePixelRatioOf(context);
return longest <= thumbnailMaxPhysicalSize;
}
int _physicalWidth(BuildContext context, double logicalWidth) {
return (logicalWidth * MediaQuery.devicePixelRatioOf(context)).ceil();
}
Widget _image(
MediaImageProvider provider,
int? cacheWidth, {
ImageErrorWidgetBuilder? errorBuilder,
}) {
final image = ResizeImage.resizeIfNeeded(cacheWidth, null, provider);
return _AbandonWhenGone(
image: image,
child: Image(
image: image,
fit: fit,
width: width,
height: height,
semanticLabel: semanticLabel,
errorBuilder: errorBuilder ?? this.errorBuilder,
frameBuilder: frameBuilder,
filterQuality: filterQuality,
gaplessPlayback: true,
),
);
}
}
/// Abandons [image]'s download if [child] stops showing it before it loads,
/// for example when a chat image scrolls away. Otherwise the image cache's
/// own listener would keep the download, and its share of a slow link, going
/// until the fetch cap. Another widget still showing the image keeps it.
class _AbandonWhenGone extends HookWidget {
const _AbandonWhenGone({required this.image, required this.child});
final ImageProvider<Object> image;
final Widget child;
@override
Widget build(BuildContext context) {
final config = createLocalImageConfiguration(context);
// Resolve only once the child is gone, and only a still-pending load:
// resolving earlier would start the download ahead of the child's
// [ScrollAwareImageProvider], defeating its deferral during a fast fling.
// The microtask runs after this frame, so the child has stopped
// listening and a widget showing the same image elsewhere has started.
useEffect(
() =>
() => scheduleMicrotask(() {
final cache = PaintingBinding.instance.imageCache;
unawaited(
image.obtainKey(config).then((key) {
if (!cache.statusForKey(key).pending) return;
abandonPendingImage(image.resolve(config), image, config);
}),
);
}),
[image],
);
return child;
}
}