mirror of
https://github.com/block/buzz.git
synced 2026-11-12 20:31:12 +00:00
## Summary Videos previously rendered outside photo stacks and opened only the Flutter video viewer. Message bubbles now group trailing videos and photos in their original order, with poster thumbnails or a visible play placeholder. On iOS 18+, the native zoom gallery (enabled by default) opens both standalone videos and mixed stacks with AVKit playback controls, and closing restores the selected stack item. Flutter downloads only the selected video using fresh media authentication, streams it to a bounded temporary file, and cancels/deletes it when selection changes or the viewer closes. UIKit releases the old player before that file is removed. Failed downloads and playback expose Retry. Video URLs never enter the image decoder, and photo-only actions are hidden on video pages. The existing Flutter viewers remain the fallback. Native zoom defaults on; `--dart-define=BUZZ_NATIVE_PHOTO_ZOOM=false` remains an explicit build-time opt-out. Unsupported platforms/formats, unavailable native presentation, and bridge failures use the Flutter fallback. Native video pages use Close or accessibility escape to return with zoom; the photo-specific downward drag recognizer is disabled there to avoid competing with AVKit controls. ### Related issue Stacked on #9314 (PNG/HEIC photo formats); merge that first. No matching open video-stack/zoom PR found. ### Testing - Default-on follow-up: presentation tests also cover unavailable, null, and throwing bridge responses. - `just mobile-check mobile-test`: analysis clean, 4,601 mobile tests passed, plus push-configuration and secp256k1 package tests. - Full native-enabled mobile suite: 4,626 passed, 68 skipped; rerun passed at `dc752dccaa664cb56a19cbe517a266d1407a1100`. - Mixed-stack widget journey covers order, video opening, returning to selection, and the photo-only fallback gallery. - Native bridge tests cover video snapshot presentation, selected-only dispatch, stale sessions and keeping video URLs out of image decoding. - Real HTTP download journey covers authenticated requests, Retry, byte limits, cancellation and temporary-file cleanup. - Native simulator fixture decodes a real MP4 and verifies selected-only loading, player release on paging and teardown. Removing playback cleanup makes the regression fail; restoring it passes. - No physical-device installation or end-to-end zoom recording captured. ### Widget previews Actual Flutter renders with sample video URLs and no poster metadata; these show the stack layout, not device playback. | Received, light | Sent, dark | | --- | --- | |  |  | ### Human verification pending On a normal build on iOS 18+ (no extra flag required), open a message containing photos and videos, page between them, play a video, and close. Verify playback controls work, only the selected video plays, and closing returns to the selected stack item. --------- Signed-off-by: kenny lopez <klopez4212@gmail.com> Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
95 lines
2.8 KiB
Dart
95 lines
2.8 KiB
Dart
import 'package:flutter/foundation.dart';
|
|
|
|
import 'message_media.dart';
|
|
import 'message_content/link_normalizer.dart';
|
|
import 'message_content/markdown_escapes.dart';
|
|
|
|
/// Attachment metadata shared by carousel and stack rendering.
|
|
@immutable
|
|
class MessageGalleryItem {
|
|
final String url;
|
|
final String semanticLabel;
|
|
final double? aspectRatio;
|
|
|
|
/// Declared media type, used when selecting the native gallery.
|
|
final String? mimeType;
|
|
|
|
/// Whether this item opens video playback rather than an image decoder.
|
|
final bool isVideo;
|
|
|
|
/// Optional video poster; absent posters use a static video placeholder.
|
|
final String? posterUrl;
|
|
|
|
const MessageGalleryItem({
|
|
required this.url,
|
|
required this.semanticLabel,
|
|
required this.aspectRatio,
|
|
this.mimeType,
|
|
this.isVideo = false,
|
|
this.posterUrl,
|
|
});
|
|
}
|
|
|
|
/// A trailing run of media embeds and its preceding message text.
|
|
@immutable
|
|
class TrailingImageGallery {
|
|
final String content;
|
|
final List<MessageGalleryItem> items;
|
|
|
|
const TrailingImageGallery({required this.content, required this.items});
|
|
}
|
|
|
|
/// Groups trailing images, optionally including videos for message stacks.
|
|
TrailingImageGallery? extractTrailingImageGallery(
|
|
String content,
|
|
Map<String, ImetaEntry> imetaByUrl, {
|
|
bool includeVideos = false,
|
|
}) {
|
|
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;
|
|
// CommonMark unescapes link destinations, as it does labels below.
|
|
final url = unshieldMarkdownEscapes(shieldMarkdownEscapes(match.group(2)!));
|
|
final imeta = imetaByUrl[url];
|
|
final mediaKind = classifyMediaUrl(url, imeta: imeta);
|
|
if (mediaKind != MessageMediaKind.image &&
|
|
!(includeVideos && mediaKind == MessageMediaKind.video)) {
|
|
break;
|
|
}
|
|
final markdownLabel = decodeMarkdownLabelSyntax(
|
|
unshieldMarkdownEscapes(shieldMarkdownEscapes(match.group(1)!)),
|
|
).trim();
|
|
items.add(
|
|
MessageGalleryItem(
|
|
url: url,
|
|
semanticLabel:
|
|
imeta?.alt ??
|
|
(markdownLabel.isNotEmpty
|
|
? markdownLabel
|
|
: mediaKind == MessageMediaKind.video
|
|
? 'Video'
|
|
: 'Message image'),
|
|
aspectRatio: imeta?.aspectRatio,
|
|
mimeType: imeta?.mimeType,
|
|
isVideo: mediaKind == MessageMediaKind.video,
|
|
posterUrl: imeta?.posterUrl,
|
|
),
|
|
);
|
|
cursor -= 1;
|
|
}
|
|
|
|
if (items.length < 2) return null;
|
|
return TrailingImageGallery(
|
|
content: lines.take(cursor + 1).join('\n').trimRight(),
|
|
items: items.reversed.toList(),
|
|
);
|
|
}
|