mirror of
https://github.com/block/buzz.git
synced 2026-10-31 06:21:34 +00:00
## 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>
304 lines
11 KiB
Dart
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,
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|