Files
buzz/mobile/lib/features/channels/message_content/media_carousel.dart
klopez4212 6f5ec4a731 Add mobile voice notes (#7121)
## Summary
- add iOS and Android voice-note recording and preview directly in the
mobile composer
- add waveform playback, scrubbing, speed controls, haptics, and
one-shot playback in chat
- package recordings in a canonical H.264/AAC MP4 envelope on both
platforms so existing relays accept them
- preserve the shared composer interaction and attachment-card treatment
across mobile platforms

Mobile counterpart to #6978.

## Testing
- `just ci`
- `just mobile-check`
- `just mobile-test` (2,026 tests)
- Android debug build compiled, installed, launched, and Voice note
verified in the attachment menu on Pixel 10
- signed iOS device build installed on iPhone

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz>
Signed-off-by: Kenny Lopez <klopez4212@gmail.com>
Signed-off-by: Carl <3c4caeafb646d23867f1c4832e68211d77e2561946171625f75c3ce1a3f2670f@buzz.block.builderlab.xyz>
Co-authored-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz>
Co-authored-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz>
Co-authored-by: Carl <3c4caeafb646d23867f1c4832e68211d77e2561946171625f75c3ce1a3f2670f@buzz.block.builderlab.xyz>
2026-09-03 08:07:04 +00:00

304 lines
11 KiB
Dart

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,
) {
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];
final mediaKind = classifyMediaUrl(url, imeta: imeta);
if (mediaKind != MessageMediaKind.image) {
break;
}
final markdownLabel = match.group(1)?.trim();
items.insert(
0,
_MessageGalleryItem(
url: url,
semanticLabel:
imeta?.alt ??
(markdownLabel?.isNotEmpty == true
? markdownLabel!
: 'Message image'),
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(
'${items.length} images',
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: 'Open ${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: (_, _, _) =>
const _MediaPreviewFallback(
icon: LucideIcons.imageOff,
label: 'Image unavailable',
),
),
),
),
),
),
),
);
},
),
);
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,
),
),
);
},
),
],
),
);
}
}