mirror of
https://github.com/block/buzz.git
synced 2026-10-29 05:21:31 +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>
192 lines
6.1 KiB
Dart
192 lines
6.1 KiB
Dart
import 'package:flutter/material.dart';
|
|
|
|
/// Resolves bar width and spacing so a waveform occupies its full width.
|
|
@visibleForTesting
|
|
({double barWidth, double gap}) voiceNoteWaveformBarLayout({
|
|
required double width,
|
|
required int sampleCount,
|
|
}) {
|
|
if (sampleCount <= 0 || width <= 0) return (barWidth: 0, gap: 0);
|
|
const preferredGap = 2.0;
|
|
final availableBarWidth =
|
|
(width - (preferredGap * (sampleCount - 1))) / sampleCount;
|
|
final barWidth = availableBarWidth.clamp(1.0, 3.0);
|
|
if (sampleCount == 1) return (barWidth: barWidth, gap: 0);
|
|
final gap = ((width - (barWidth * sampleCount)) / (sampleCount - 1)).clamp(
|
|
0.0,
|
|
double.infinity,
|
|
);
|
|
return (barWidth: barWidth, gap: gap);
|
|
}
|
|
|
|
/// Paints voice-note samples and optionally exposes seek semantics.
|
|
class VoiceNoteWaveform extends StatelessWidget {
|
|
const VoiceNoteWaveform({
|
|
super.key,
|
|
required this.samples,
|
|
this.progress = 0,
|
|
this.onSeek,
|
|
this.fadeEdges = false,
|
|
this.height = 32,
|
|
this.minimumBarHeight = 4,
|
|
this.maximumBarHeight,
|
|
this.colorOpacity = 1,
|
|
});
|
|
|
|
final List<double> samples;
|
|
final double progress;
|
|
final ValueChanged<double>? onSeek;
|
|
final bool fadeEdges;
|
|
final double height;
|
|
final double minimumBarHeight;
|
|
final double? maximumBarHeight;
|
|
final double colorOpacity;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
Widget waveform = CustomPaint(
|
|
key: const ValueKey('voice-note-waveform'),
|
|
painter: _VoiceNoteWaveformPainter(
|
|
samples: samples,
|
|
progress: progress.clamp(0.0, 1.0),
|
|
activeColor: colorScheme.primary.withValues(alpha: colorOpacity),
|
|
inactiveColor: colorScheme.onSurfaceVariant.withValues(alpha: 0.46),
|
|
minimumBarHeight: minimumBarHeight,
|
|
maximumBarHeight: maximumBarHeight ?? height,
|
|
),
|
|
size: Size(double.infinity, height),
|
|
);
|
|
if (fadeEdges) {
|
|
waveform = ShaderMask(
|
|
blendMode: BlendMode.dstIn,
|
|
shaderCallback: (bounds) => const LinearGradient(
|
|
colors: [
|
|
Colors.transparent,
|
|
Colors.white,
|
|
Colors.white,
|
|
Colors.transparent,
|
|
],
|
|
stops: [0, 0.1, 0.9, 1],
|
|
).createShader(bounds),
|
|
child: waveform,
|
|
);
|
|
}
|
|
return LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
void seek(double dx) {
|
|
final width = constraints.maxWidth;
|
|
if (onSeek != null && width > 0 && width.isFinite) {
|
|
onSeek!((dx / width).clamp(0.0, 1.0));
|
|
}
|
|
}
|
|
|
|
void adjust(double delta) =>
|
|
onSeek?.call((progress.clamp(0.0, 1.0) + delta).clamp(0.0, 1.0));
|
|
|
|
return Semantics(
|
|
label: 'Voice note waveform',
|
|
slider: onSeek != null,
|
|
value: onSeek == null ? null : '${(progress * 100).round()} percent',
|
|
increasedValue: onSeek == null
|
|
? null
|
|
: '${((progress + 0.1).clamp(0.0, 1.0) * 100).round()} percent',
|
|
decreasedValue: onSeek == null
|
|
? null
|
|
: '${((progress - 0.1).clamp(0.0, 1.0) * 100).round()} percent',
|
|
onIncrease: onSeek == null ? null : () => adjust(0.1),
|
|
onDecrease: onSeek == null ? null : () => adjust(-0.1),
|
|
child: GestureDetector(
|
|
behavior: HitTestBehavior.opaque,
|
|
onTapDown: onSeek == null
|
|
? null
|
|
: (details) => seek(details.localPosition.dx),
|
|
onHorizontalDragStart: onSeek == null
|
|
? null
|
|
: (details) => seek(details.localPosition.dx),
|
|
onHorizontalDragUpdate: onSeek == null
|
|
? null
|
|
: (details) => seek(details.localPosition.dx),
|
|
child: SizedBox(
|
|
height: height,
|
|
width: double.infinity,
|
|
child: waveform,
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
class _VoiceNoteWaveformPainter extends CustomPainter {
|
|
const _VoiceNoteWaveformPainter({
|
|
required this.samples,
|
|
required this.progress,
|
|
required this.activeColor,
|
|
required this.inactiveColor,
|
|
required this.minimumBarHeight,
|
|
required this.maximumBarHeight,
|
|
});
|
|
|
|
final List<double> samples;
|
|
final double progress;
|
|
final Color activeColor;
|
|
final Color inactiveColor;
|
|
final double minimumBarHeight;
|
|
final double maximumBarHeight;
|
|
|
|
@override
|
|
void paint(Canvas canvas, Size size) {
|
|
if (samples.isEmpty || size.width <= 0 || size.height <= 0) return;
|
|
final layout = voiceNoteWaveformBarLayout(
|
|
width: size.width,
|
|
sampleCount: samples.length,
|
|
);
|
|
final resolvedWidth = layout.barWidth;
|
|
final gap = layout.gap;
|
|
const originX = 0.0;
|
|
final activeEdge = size.width * progress;
|
|
final radius = Radius.circular(resolvedWidth / 2);
|
|
|
|
void drawBars(Color color) {
|
|
final paint = Paint()..color = color;
|
|
for (var index = 0; index < samples.length; index++) {
|
|
final maxBarHeight = maximumBarHeight.clamp(
|
|
minimumBarHeight,
|
|
size.height,
|
|
);
|
|
final barHeight =
|
|
(minimumBarHeight +
|
|
(samples[index].clamp(0.0, 1.0) *
|
|
(maxBarHeight - minimumBarHeight)))
|
|
.clamp(minimumBarHeight, maxBarHeight);
|
|
final left = originX + index * (resolvedWidth + gap);
|
|
final rect = Rect.fromLTWH(
|
|
left,
|
|
(size.height - barHeight) / 2,
|
|
resolvedWidth,
|
|
barHeight,
|
|
);
|
|
canvas.drawRRect(RRect.fromRectAndRadius(rect, radius), paint);
|
|
}
|
|
}
|
|
|
|
drawBars(inactiveColor);
|
|
if (activeEdge <= 0) return;
|
|
canvas.save();
|
|
canvas.clipRect(Rect.fromLTWH(0, 0, activeEdge, size.height));
|
|
drawBars(activeColor);
|
|
canvas.restore();
|
|
}
|
|
|
|
@override
|
|
bool shouldRepaint(_VoiceNoteWaveformPainter oldDelegate) =>
|
|
oldDelegate.samples != samples ||
|
|
oldDelegate.progress != progress ||
|
|
oldDelegate.activeColor != activeColor ||
|
|
oldDelegate.inactiveColor != inactiveColor ||
|
|
oldDelegate.minimumBarHeight != minimumBarHeight ||
|
|
oldDelegate.maximumBarHeight != maximumBarHeight;
|
|
}
|