9dfa06ffee
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Signed-off-by: cls_宁波本机 <908705107@qq.com>
239 lines
7.5 KiB
Dart
239 lines
7.5 KiB
Dart
import 'dart:async';
|
|
import 'dart:ui' as ui;
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
import 'package:http/http.dart' as http;
|
|
|
|
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 = http.Client();
|
|
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) 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;
|
|
|
|
/// Excluded from equality: transport, not identity.
|
|
final http.Client client;
|
|
|
|
const MediaImageProvider({
|
|
required this.url,
|
|
required this.auth,
|
|
required this.client,
|
|
this.scale = 1.0,
|
|
});
|
|
|
|
static const _defaultCooldown = Duration(seconds: 30);
|
|
static const _maxRetryAfter = Duration(minutes: 5);
|
|
static final Map<String, DateTime> _cooldownUntil = {};
|
|
|
|
/// Injectable clock for cooldown tests.
|
|
@visibleForTesting
|
|
static DateTime Function() debugNow = DateTime.now;
|
|
|
|
@visibleForTesting
|
|
static void debugResetCooldowns() => _cooldownUntil.clear();
|
|
|
|
@override
|
|
Future<MediaImageProvider> obtainKey(ImageConfiguration configuration) {
|
|
return SynchronousFuture<MediaImageProvider>(this);
|
|
}
|
|
|
|
@override
|
|
ImageStreamCompleter loadImage(
|
|
MediaImageProvider key,
|
|
ImageDecoderCallback decode,
|
|
) {
|
|
return MultiFrameImageStreamCompleter(
|
|
codec: _loadAsync(key, decode),
|
|
scale: key.scale,
|
|
debugLabel: url,
|
|
);
|
|
}
|
|
|
|
Future<ui.Codec> _loadAsync(
|
|
MediaImageProvider key,
|
|
ImageDecoderCallback decode,
|
|
) async {
|
|
try {
|
|
final until = _cooldownUntil[url];
|
|
if (until != null) {
|
|
if (debugNow().isBefore(until)) {
|
|
throw MediaImageCooldownException(url: url, until: until);
|
|
}
|
|
_cooldownUntil.remove(url);
|
|
}
|
|
|
|
final uri = Uri.parse(url);
|
|
final http.Response response;
|
|
try {
|
|
response = await client.get(uri, headers: auth.headersFor(url));
|
|
} catch (_) {
|
|
_cooldownUntil[url] = debugNow().add(_defaultCooldown);
|
|
rethrow;
|
|
}
|
|
if (response.statusCode != 200) {
|
|
_cooldownUntil[url] = debugNow().add(_cooldownFor(response));
|
|
throw NetworkImageLoadException(
|
|
statusCode: response.statusCode,
|
|
uri: uri,
|
|
);
|
|
}
|
|
final bytes = response.bodyBytes;
|
|
if (bytes.isEmpty) {
|
|
_cooldownUntil[url] = debugNow().add(_defaultCooldown);
|
|
throw NetworkImageLoadException(statusCode: 200, uri: uri);
|
|
}
|
|
final buffer = await ui.ImmutableBuffer.fromUint8List(bytes);
|
|
return decode(buffer);
|
|
} catch (_) {
|
|
// 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;
|
|
}
|
|
}
|
|
|
|
Duration _cooldownFor(http.Response 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;
|
|
}
|
|
|
|
@override
|
|
int get hashCode => Object.hash(url, scale, auth);
|
|
|
|
@override
|
|
String toString() =>
|
|
'MediaImageProvider("$url", scale: ${scale.toStringAsFixed(1)})';
|
|
}
|
|
|
|
/// 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';
|
|
}
|
|
|
|
/// 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).
|
|
class MediaImage extends ConsumerWidget {
|
|
final String url;
|
|
final BoxFit? fit;
|
|
final double? width;
|
|
final double? height;
|
|
final String? semanticLabel;
|
|
final ImageErrorWidgetBuilder? errorBuilder;
|
|
final FilterQuality filterQuality;
|
|
final double? decodeWidth;
|
|
final bool boundDecodeToLayout;
|
|
|
|
const MediaImage({
|
|
super.key,
|
|
required this.url,
|
|
this.fit,
|
|
this.width,
|
|
this.height,
|
|
this.semanticLabel,
|
|
this.errorBuilder,
|
|
this.filterQuality = FilterQuality.medium,
|
|
this.decodeWidth,
|
|
this.boundDecodeToLayout = true,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final provider = MediaImageProvider(
|
|
url: url,
|
|
auth: ref.watch(mediaGetAuthServiceProvider),
|
|
client: ref.watch(mediaHttpClientProvider),
|
|
);
|
|
|
|
if (decodeWidth != null) {
|
|
return _image(provider, _physicalWidth(context, decodeWidth!));
|
|
}
|
|
if (!boundDecodeToLayout) {
|
|
return _image(provider, null);
|
|
}
|
|
return LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final maxWidth = constraints.maxWidth;
|
|
final cacheWidth = maxWidth.isFinite && maxWidth > 0
|
|
? _physicalWidth(context, maxWidth)
|
|
: null;
|
|
return _image(provider, cacheWidth);
|
|
},
|
|
);
|
|
}
|
|
|
|
int _physicalWidth(BuildContext context, double logicalWidth) {
|
|
return (logicalWidth * MediaQuery.devicePixelRatioOf(context)).ceil();
|
|
}
|
|
|
|
Widget _image(MediaImageProvider provider, int? cacheWidth) {
|
|
return Image(
|
|
image: ResizeImage.resizeIfNeeded(cacheWidth, null, provider),
|
|
fit: fit,
|
|
width: width,
|
|
height: height,
|
|
semanticLabel: semanticLabel,
|
|
errorBuilder: errorBuilder,
|
|
filterQuality: filterQuality,
|
|
gaplessPlayback: true,
|
|
);
|
|
}
|
|
}
|