feat: import Chinese-localized Buzz source snapshot
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>
This commit is contained in:
2026-08-13 18:34:25 +08:00
parent 61c3fa1df9
commit 9dfa06ffee
3785 changed files with 1085458 additions and 2 deletions
@@ -0,0 +1,303 @@
part of '../message_content.dart';
const _messageMediaCarouselHeight = 220.0;
@immutable
class _MessageGalleryItem {
final String url;
final String semanticLabel;
final double? aspectRatio;
const _MessageGalleryItem({
required this.url,
required this.semanticLabel,
required this.aspectRatio,
});
}
@immutable
class _TrailingImageGallery {
final String content;
final List<_MessageGalleryItem> items;
const _TrailingImageGallery({required this.content, required this.items});
}
class _MessageGalleryPrecache extends HookWidget {
final List<ImageProvider<Object>> providers;
final int focusedIndex;
const _MessageGalleryPrecache({
required this.providers,
required this.focusedIndex,
});
@override
Widget build(BuildContext context) {
final providerSignature = Object.hashAll(providers);
useEffect(() {
var cancelled = false;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (cancelled || !context.mounted) {
return;
}
for (var index = focusedIndex - 2; index <= focusedIndex + 2; index++) {
if (index < 0 || index >= providers.length) {
continue;
}
unawaited(
precacheImage(providers[index], context, onError: (_, _) {}),
);
}
});
return () => cancelled = true;
}, [focusedIndex, providerSignature]);
return const SizedBox.shrink();
}
}
_TrailingImageGallery? _extractTrailingImageGallery(
String content,
Map<String, ImetaEntry> imetaByUrl,
String fallbackLabel,
) {
final lines = content.split('\n');
var cursor = lines.length - 1;
while (cursor >= 0 && lines[cursor].trim().isEmpty) {
cursor -= 1;
}
final items = <_MessageGalleryItem>[];
final imagePattern = RegExp(r'^!\[([^\]]*)\]\((https?://[^)\s]+)\)$');
while (cursor >= 0) {
final match = imagePattern.firstMatch(lines[cursor].trim());
if (match == null) break;
final url = match.group(2)!;
final imeta = imetaByUrl[url];
if (classifyMediaUrl(url, imeta: imeta) == MessageMediaKind.video) {
break;
}
final markdownLabel = match.group(1)?.trim();
items.insert(
0,
_MessageGalleryItem(
url: url,
semanticLabel:
imeta?.alt ??
(markdownLabel?.isNotEmpty == true
? markdownLabel!
: fallbackLabel),
aspectRatio: imeta?.aspectRatio,
),
);
cursor -= 1;
}
if (items.length < 2) return null;
return _TrailingImageGallery(
content: lines.take(cursor + 1).join('\n').trimRight(),
items: items,
);
}
class _MessageImageCarousel extends HookConsumerWidget {
final List<_MessageGalleryItem> items;
final double leadingOverflow;
final double trailingOverflow;
final VoidCallback? onReply;
final MediaViewerMoreAction? onMore;
const _MessageImageCarousel({
super.key,
required this.items,
required this.leadingOverflow,
required this.trailingOverflow,
required this.onReply,
required this.onMore,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final itemSignature = items.map((item) => item.url).join('\u0000');
final heroTags = useMemoized(
() => [for (var index = 0; index < items.length; index++) Object()],
[itemSignature],
);
final controller = usePageController(viewportFraction: 0.9);
final currentIndex = useState(0);
final mediaAuth = ref.watch(mediaGetAuthServiceProvider);
final mediaClient = ref.watch(mediaHttpClientProvider);
return Padding(
padding: const EdgeInsets.only(top: Grid.half),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
context.l10n.imagesCount(items.length),
key: const ValueKey('message-media-carousel-count'),
style: context.textTheme.labelMedium?.copyWith(
color: context.colors.onSurfaceVariant,
fontWeight: FontWeight.w400,
),
),
const SizedBox(height: Grid.half + Grid.quarter),
LayoutBuilder(
builder: (context, constraints) {
final contentWidth = constraints.hasBoundedWidth
? constraints.maxWidth
: _messageMediaMaxWidth(context);
final carouselWidth =
contentWidth + leadingOverflow + trailingOverflow;
final leadingExtent = leadingOverflow;
final isLeftToRight =
Directionality.of(context) == TextDirection.ltr;
final itemTrailingPaddings = [
for (var index = 0; index < items.length; index++)
index == items.length - 1 ? Grid.gutter : Grid.half,
];
final previewDecodeWidths = [
for (var index = 0; index < items.length; index++)
math.max(
1.0,
carouselWidth * controller.viewportFraction -
itemTrailingPaddings[index],
),
];
final devicePixelRatio = MediaQuery.devicePixelRatioOf(context);
final previewProviders = [
for (var index = 0; index < items.length; index++)
ResizeImage.resizeIfNeeded(
(previewDecodeWidths[index] * devicePixelRatio).ceil(),
null,
MediaImageProvider(
url: items[index].url,
auth: mediaAuth,
client: mediaClient,
),
),
];
final viewerItems = [
for (var index = 0; index < items.length; index++)
MediaViewerImage(
url: items[index].url,
heroTag: heroTags[index],
semanticLabel: items[index].semanticLabel,
previewDecodeWidth: previewDecodeWidths[index],
aspectRatio: items[index].aspectRatio,
preloadProvider: previewProviders[index],
),
];
final carousel = SizedBox(
key: const ValueKey('message-media-carousel'),
width: carouselWidth,
height: _messageMediaCarouselHeight,
child: PageView.builder(
controller: controller,
allowImplicitScrolling: true,
// Keep the first image aligned with the message body, but
// allow later pages to paint through the avatar gutter as
// the row scrolls.
clipBehavior: Clip.none,
padEnds: false,
itemCount: items.length,
onPageChanged: (index) => currentIndex.value = index,
itemBuilder: (context, index) {
final item = items[index];
return Padding(
key: ValueKey('message-media-carousel-page:${item.url}'),
padding: EdgeInsetsDirectional.only(
end: itemTrailingPaddings[index],
),
child: Semantics(
button: true,
excludeSemantics: true,
label: context.l10n.openMedia(item.semanticLabel),
child: GestureDetector(
key: ValueKey(
'message-media-carousel-item:${item.url}',
),
onTap: () => openImageViewer(
context,
imageUrl: item.url,
heroTag: heroTags[index],
semanticLabel: item.semanticLabel,
previewDecodeWidth: previewDecodeWidths[index],
aspectRatio: item.aspectRatio,
galleryItems: viewerItems,
initialIndex: index,
onReply: onReply,
onMore: onMore,
),
child: Container(
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
color: context.colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(Radii.md),
border: Border.all(
color: context.colors.outlineVariant,
),
),
child: MediaViewerHero(
tag: heroTags[index],
child: ClipRRect(
borderRadius: BorderRadius.circular(Radii.md),
child: MediaImage(
url: item.url,
decodeWidth: previewDecodeWidths[index],
fit: BoxFit.cover,
semanticLabel: item.semanticLabel,
errorBuilder: (_, _, _) =>
_MediaPreviewFallback(
icon: LucideIcons.imageOff,
label: context.l10n.imageUnavailable,
),
),
),
),
),
),
),
);
},
),
);
final carouselSurface = Stack(
clipBehavior: Clip.none,
children: [
carousel,
_MessageGalleryPrecache(
providers: previewProviders,
focusedIndex: currentIndex.value,
),
],
);
if (leadingExtent <= 0 && trailingOverflow <= 0) {
return carouselSurface;
}
return SizedBox(
width: contentWidth,
height: _messageMediaCarouselHeight,
child: OverflowBox(
alignment: AlignmentDirectional.centerStart,
minWidth: carouselWidth,
maxWidth: carouselWidth,
child: Transform.translate(
offset: Offset(
isLeftToRight ? -leadingExtent : leadingExtent,
0,
),
child: carouselSurface,
),
),
);
},
),
],
),
);
}
}
@@ -0,0 +1,228 @@
part of '../message_content.dart';
/// A decoded, paused video surface used as a posterless message preview.
class LoadedVideoPreviewFrame {
/// Widget backed by the initialized native video texture.
final Widget child;
/// Aspect ratio reported by the video decoder.
final double aspectRatio;
final Future<void> Function() _dispose;
/// Creates a loaded preview frame with its resource cleanup callback.
const LoadedVideoPreviewFrame({
required this.child,
required this.aspectRatio,
required Future<void> Function() dispose,
}) : _dispose = dispose;
/// Releases the native decoder and any downloaded temporary file.
Future<void> dispose() => _dispose();
}
/// Loads a paused first-frame surface for a video URL.
typedef VideoPreviewFrameLoader =
Future<LoadedVideoPreviewFrame?> Function(String url);
/// Loader used when an older video event has no NIP-71 poster URL.
final videoPreviewFrameLoaderProvider = Provider<VideoPreviewFrameLoader>((
ref,
) {
final auth = ref.watch(mediaGetAuthServiceProvider);
return (url) => _loadVideoPreviewFrame(url, headers: auth.headersFor(url));
});
class _MessageVideoPreview extends HookConsumerWidget {
final String url;
final ImetaEntry? imeta;
final VoidCallback? onReply;
const _MessageVideoPreview({
required this.url,
required this.imeta,
required this.onReply,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final rawAspectRatio = imeta?.aspectRatio ?? (16 / 9);
final aspectRatio = rawAspectRatio.clamp(0.75, 1.91);
final posterUrl = imeta?.posterUrl;
final loadPreviewFrame = ref.watch(videoPreviewFrameLoaderProvider);
final previewFrameFuture = useMemoized(
() => posterUrl == null
? loadPreviewFrame(url)
: Future<LoadedVideoPreviewFrame?>.value(),
[loadPreviewFrame, posterUrl, url],
);
useEffect(() {
var disposed = false;
LoadedVideoPreviewFrame? loadedFrame;
unawaited(
previewFrameFuture.then((frame) {
if (disposed) {
if (frame != null) unawaited(frame.dispose());
} else {
loadedFrame = frame;
}
}),
);
return () {
disposed = true;
final frame = loadedFrame;
if (frame != null) unawaited(frame.dispose());
};
}, [previewFrameFuture]);
return Padding(
padding: const EdgeInsets.only(top: Grid.half),
child: GestureDetector(
onTap: () => openVideoViewer(
context,
videoUrl: url,
posterUrl: posterUrl,
onReply: onReply,
),
child: _MessageMediaPreviewFrame(
previewKey: ValueKey('message-media-video-preview:$url'),
backgroundColor: Colors.black,
child: AspectRatio(
aspectRatio: aspectRatio.toDouble(),
child: Stack(
fit: StackFit.expand,
children: [
if (posterUrl != null)
MediaImage(
url: posterUrl,
fit: BoxFit.cover,
errorBuilder: (_, _, _) => _MediaPreviewFallback(
icon: LucideIcons.video,
label: context.l10n.videoPreviewUnavailable,
),
)
else
FutureBuilder<LoadedVideoPreviewFrame?>(
future: previewFrameFuture,
builder: (context, snapshot) {
final frame = snapshot.data;
if (frame == null) {
return _MediaPreviewFallback(
icon: LucideIcons.video,
label: context.l10n.videoAttachment,
);
}
return ColoredBox(
key: ValueKey('message-media-video-first-frame:$url'),
color: Colors.black,
child: Center(
child: AspectRatio(
aspectRatio: frame.aspectRatio,
child: frame.child,
),
),
);
},
),
const ColoredBox(color: Color.fromRGBO(0, 0, 0, 0.28)),
Center(
child: Container(
width: 52,
height: 52,
decoration: const BoxDecoration(
color: Color.fromRGBO(0, 0, 0, 0.6),
shape: BoxShape.circle,
),
child: const Icon(
LucideIcons.play,
color: Colors.white,
size: 24,
),
),
),
Positioned(
left: Grid.xxs,
right: Grid.xxs,
bottom: Grid.xxs,
child: Text(
context.l10n.video,
style: context.textTheme.labelSmall?.copyWith(
color: Colors.white,
fontWeight: FontWeight.w600,
),
),
),
],
),
),
),
),
);
}
}
Future<LoadedVideoPreviewFrame?> _loadVideoPreviewFrame(
String url, {
required Map<String, String> headers,
}) async {
final uri = Uri.tryParse(url);
if (uri == null || !uri.hasScheme) return null;
VideoPlayerController? controller;
Future<void> disposeResources() async {
final currentController = controller;
controller = null;
if (currentController != null) await currentController.dispose();
}
Future<bool> initialize(VideoPlayerController candidate) async {
controller = candidate;
await candidate.initialize();
final duration = candidate.value.duration;
if (duration > const Duration(milliseconds: 100)) {
await candidate.seekTo(const Duration(milliseconds: 100));
}
await candidate.pause();
return candidate.value.isInitialized;
}
try {
final networkController = VideoPlayerController.networkUrl(
uri,
httpHeaders: headers,
videoPlayerOptions: VideoPlayerOptions(mixWithOthers: true),
);
if (!await initialize(networkController)) {
await disposeResources();
return null;
}
} on MissingPluginException {
await disposeResources();
return null;
} on UnimplementedError {
await disposeResources();
return null;
} catch (_) {
// A timeline preview must stay bounded: falling back to a local file here
// would fully download every posterless video encountered while scrolling.
// The full-screen viewer can use its authenticated fallback after a tap.
await disposeResources();
return null;
}
final initializedController = controller;
if (initializedController == null) return null;
var didDispose = false;
return LoadedVideoPreviewFrame(
aspectRatio: initializedController.value.aspectRatio > 0
? initializedController.value.aspectRatio
: 16 / 9,
child: VideoPlayer(initializedController),
dispose: () async {
if (didDispose) return;
didDispose = true;
await disposeResources();
},
);
}