Files
buzz/mobile/lib/features/channels/voice_note_waveform.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

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;
}