Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

- Feature [#468](https://github.com/SimformSolutionsPvtLtd/audio_waveforms/pull/468) - Add macOS support
- Feature [#91](https://github.com/SimformSolutionsPvtLtd/audio_waveforms/issues/91) - Add `WaveStyle.maxDuration` to bound recording waveform width proportionally
- Feature [#382](https://github.com/SimformSolutionsPvtLtd/audio_waveforms/issues/382) - Smooth recorder waveform scroll with frame-interpolated animation

## 2.0.2

Expand Down
162 changes: 108 additions & 54 deletions lib/src/audio_waveforms.dart
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,33 @@ class AudioWaveforms extends StatefulWidget {
State<AudioWaveforms> createState() => _AudioWaveformsState();
}

class _AudioWaveformsState extends State<AudioWaveforms> {
class _AudioWaveformsState extends State<AudioWaveforms>
with SingleTickerProviderStateMixin {
bool _isScrolled = false;

/// Tracks the total horizontal offset applied when the waveform is shifted backward.
Offset _totalBackDistance = Offset.zero;
/// Duration of the frame-interpolated scroll animation applied on each
/// pushback.
static const Duration _scrollAnimDuration = Duration(milliseconds: 100);

Offset _dragOffset = Offset.zero;

late final AnimationController _scrollAnim;

/// Current (frame-interpolated) horizontal offset the waveform is shifted
/// backward. A bare scalar — the waveform only ever scrolls on the x-axis.
double _renderedBackDx = 0.0;

/// Logical (final) back-distance target; updated immediately on each pushback.
double _logicalBackDx = 0.0;

/// Animated source position at the moment the current animation started.
double _animSourceDx = 0.0;

/// True while a post-frame callback to (re)start [_scrollAnim] is pending.
/// Coalesces multiple pushbacks within one frame into a single animation
/// start and keeps the controller from being mutated during paint.
bool _scrollAnimScheduled = false;

double _initialOffsetPosition = 0.0;
late double _initialPosition;
Duration currentlyRecordedDuration = Duration.zero;
Expand All @@ -64,6 +84,17 @@ class _AudioWaveformsState extends State<AudioWaveforms> {
// For RTL, initial position starts at 0 (waves grow from right edge)
// For LTR, initial position starts at negative half thickness
_initialPosition = _isRtl ? 0.0 : -(_waveStyle.waveThickness / 2);
_scrollAnim = AnimationController(
vsync: this,
duration: _scrollAnimDuration,
)..addListener(() {
// Only update the interpolated offset here. The AnimatedBuilder around
// the CustomPaint repaints the waveform on each tick, so there is no
// need to setState and rebuild the whole subtree (and re-run the
// clipper) every frame.
_renderedBackDx = _animSourceDx +
(_logicalBackDx - _animSourceDx) * _scrollAnim.value;
});
_recorderController.addListener(_recorderControllerListener);
streamSubscription =
_recorderController.onCurrentDuration.listen((duration) {
Expand Down Expand Up @@ -96,6 +127,7 @@ class _AudioWaveformsState extends State<AudioWaveforms> {

@override
void dispose() {
_scrollAnim.dispose();
_recorderController.removeListener(_recorderControllerListener);
streamSubscription.cancel();
super.dispose();
Expand All @@ -119,45 +151,50 @@ class _AudioWaveformsState extends State<AudioWaveforms> {
waveWidth: _waveWidth,
),
child: RepaintBoundary(
child: CustomPaint(
size: _size,
painter: RecorderWavePainter(
labels: _labels,
waveThickness: _waveStyle.waveThickness,
middleLineThickness: _waveStyle.middleLineThickness,
middleLineColor: _waveStyle.middleLineColor,
waveData: _recorderController.waveData,
callPushback: _recorderController.shouldRefresh,
bottomPadding: _waveStyle.bottomPadding ?? _size.height / 2,
spacing: _waveStyle.spacing,
waveCap: _waveStyle.waveCap,
showBottom: _waveStyle.showBottom,
showTop: _waveStyle.showTop,
waveColor: _waveStyle.waveColor,
showMiddleLine: _waveStyle.showMiddleLine,
totalCurrentBackDistance: _totalBackDistance,
dragOffset: _dragOffset,
pushBack: _pushBackWave,
initialPosition: _initialPosition,
extendWaveform: _waveStyle.extendWaveform,
showHourInDuration: _waveStyle.showHourInDuration,
showDurationLabel: _waveStyle.showDurationLabel,
durationLinesColor: _waveStyle.durationLinesColor,
durationStyle: _waveStyle.durationStyle,
durationTextPadding: _waveStyle.durationTextPadding,
durationLinesHeight: _waveStyle.durationLinesHeight,
labelSpacing: _waveStyle.labelSpacing,
gradient: _waveStyle.gradient,
shouldClearLabels: _recorderController.shouldClearLabels,
revertClearLabelCall: _recorderController.revertClearLabelCall,
setCurrentPositionDuration:
_recorderController.setScrolledPositionDuration,
shouldCalculateScrolledPosition:
widget.shouldCalculateScrolledPosition,
scaleFactor: _waveStyle.scaleFactor,
currentlyRecordedDuration: currentlyRecordedDuration,
isRtl: _isRtl,
maxDuration: _waveStyle.maxDuration,
child: AnimatedBuilder(
animation: _scrollAnim,
builder: (context, _) => CustomPaint(
size: _size,
painter: RecorderWavePainter(
labels: _labels,
waveThickness: _waveStyle.waveThickness,
middleLineThickness: _waveStyle.middleLineThickness,
middleLineColor: _waveStyle.middleLineColor,
waveData: _recorderController.waveData,
callPushback: _recorderController.shouldRefresh,
bottomPadding: _waveStyle.bottomPadding ?? _size.height / 2,
spacing: _waveStyle.spacing,
waveCap: _waveStyle.waveCap,
showBottom: _waveStyle.showBottom,
showTop: _waveStyle.showTop,
waveColor: _waveStyle.waveColor,
showMiddleLine: _waveStyle.showMiddleLine,
renderedBackDistance: _renderedBackDx,
logicalBackDistance: _logicalBackDx,
dragOffset: _dragOffset,
pushBack: _pushBackWave,
initialPosition: _initialPosition,
extendWaveform: _waveStyle.extendWaveform,
showHourInDuration: _waveStyle.showHourInDuration,
showDurationLabel: _waveStyle.showDurationLabel,
durationLinesColor: _waveStyle.durationLinesColor,
durationStyle: _waveStyle.durationStyle,
durationTextPadding: _waveStyle.durationTextPadding,
durationLinesHeight: _waveStyle.durationLinesHeight,
labelSpacing: _waveStyle.labelSpacing,
gradient: _waveStyle.gradient,
shouldClearLabels: _recorderController.shouldClearLabels,
revertClearLabelCall:
_recorderController.revertClearLabelCall,
setCurrentPositionDuration:
_recorderController.setScrolledPositionDuration,
shouldCalculateScrolledPosition:
widget.shouldCalculateScrolledPosition,
scaleFactor: _waveStyle.scaleFactor,
currentlyRecordedDuration: currentlyRecordedDuration,
isRtl: _isRtl,
maxDuration: _waveStyle.maxDuration,
),
),
),
),
Expand Down Expand Up @@ -212,10 +249,10 @@ class _AudioWaveformsState extends State<AudioWaveforms> {
///This will also handle refreshing the wave after scrolled
void _pushBackWave() {
if (_isRtl) {
if (!_isScrolled) {
_totalBackDistance =
_totalBackDistance + Offset(_waveStyle.spacing, 0.0);
}
// Note: the frame-interpolated scroll smoothing (_scrollAnim) is applied
// to LTR only. RTL positions bars from the right edge by index
// (see RecorderWavePainter._drawRtlWave) and never reads the back-distance
// offsets, so RTL recording advances one bar per sample on its own.

// For RTL: handle refresh after scrolling
if (_recorderController.shouldRefresh && _isScrolled) {
Expand All @@ -234,19 +271,36 @@ class _AudioWaveformsState extends State<AudioWaveforms> {
_initialPosition =
_waveStyle.spacing * _recorderController.waveData.length -
_size.width / 2;
_totalBackDistance =
_totalBackDistance + Offset(_waveStyle.spacing, 0.0);
_logicalBackDx += _waveStyle.spacing;
_renderedBackDx = _logicalBackDx;
_animSourceDx = _logicalBackDx;
_scrollAnim.stop();
_isScrolled = false;
} else {
_initialPosition = 0.0;
_totalBackDistance =
_totalBackDistance + Offset(_waveStyle.spacing, 0.0);
_logicalBackDx += _waveStyle.spacing;
// Mutating the AnimationController during paint would call setState in
// the paint phase, so defer the (re)start to after the frame; a guard coalesces multiple pushbacks per frame into one animation.
if (!_scrollAnimScheduled) {
_scrollAnimScheduled = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
_scrollAnimScheduled = false;
if (!mounted) return;
_animSourceDx = _renderedBackDx;
_scrollAnim
..reset()
..forward();
});
}
}
}
if (_recorderController.shouldClearLabels) {
_initialOffsetPosition = 0.0;
_totalBackDistance = Offset.zero;
_renderedBackDx = 0.0;
_dragOffset = Offset.zero;
_logicalBackDx = 0.0;
_animSourceDx = 0.0;
_scrollAnim.stop();
}
}

Expand All @@ -267,19 +321,19 @@ class _AudioWaveformsState extends State<AudioWaveforms> {
final delta = details.delta;
final deltaDx = details.delta.dx;
final dragOffset = _dragOffset.dx;
final totalBackDistanceDx = -_totalBackDistance.dx;
final renderedBackDistanceDx = -_renderedBackDx;
final halfWidth = _size.width / 2;
final waveformWidth =
_waveStyle.spacing * _recorderController.waveData.length;

///left to right
if (totalBackDistanceDx + dragOffset + deltaDx < halfWidth &&
if (renderedBackDistanceDx + dragOffset + deltaDx < halfWidth &&
direction > 0) {
setState(() => _dragOffset += delta);
}

///right to left
else if (totalBackDistanceDx + dragOffset + waveformWidth + deltaDx >
else if (renderedBackDistanceDx + dragOffset + waveformWidth + deltaDx >
halfWidth &&
direction < 0) {
setState(() => _dragOffset += delta);
Expand Down
28 changes: 17 additions & 11 deletions lib/src/painters/recorder_wave_painter.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@ import '/src/base/label.dart';
///
///this gives location of first wave from right to left when scrolling
///
///-totalBackDistance.dx + dragOffset.dx + (spacing * i)
///-renderedBackDistance + dragOffset.dx + (spacing * i)
///
///this gives location of first wave from left to right when scrolling
///
///-totalBackDistance.dx + dragOffset.dx
///-renderedBackDistance + dragOffset.dx
class RecorderWavePainter extends CustomPainter {
RecorderWavePainter({
required this.waveData,
Expand All @@ -26,7 +26,8 @@ class RecorderWavePainter extends CustomPainter {
required this.waveCap,
required this.middleLineColor,
required this.middleLineThickness,
required this.totalCurrentBackDistance,
required this.renderedBackDistance,
required this.logicalBackDistance,
required this.dragOffset,
required this.waveThickness,
required this.pushBack,
Expand Down Expand Up @@ -72,8 +73,12 @@ class RecorderWavePainter extends CustomPainter {
final Color middleLineColor;
final double middleLineThickness;

/// This gives total current distance the waves have been pushed back
final Offset totalCurrentBackDistance;
/// Current (frame-interpolated) distance the waves have been pushed back.
final double renderedBackDistance;

/// Logical (non-animated) back distance — used for overflow check to
/// prevent double-triggering pushBack during an in-flight animation.
final double logicalBackDistance;
final Offset dragOffset;
final double waveThickness;
final VoidCallback pushBack;
Expand Down Expand Up @@ -135,8 +140,7 @@ class RecorderWavePainter extends CustomPainter {
// never scrolls, so pushBack is skipped.
if (!_isBounded &&
((spacing * i) + dragOffset.dx + spacing >
size.width / (extendWaveform ? 1 : 2) +
totalCurrentBackDistance.dx) &&
size.width / (extendWaveform ? 1 : 2) + logicalBackDistance) &&
callPushback) {
pushBack();
}
Expand Down Expand Up @@ -180,7 +184,7 @@ class RecorderWavePainter extends CustomPainter {
final labelX = size.width - distanceFromRight + dragOffset.dx;
offset = Offset(labelX, label.offset.dy);
} else {
offset = label.offset - totalCurrentBackDistance + dragOffset;
offset = label.offset - Offset(renderedBackDistance, 0) + dragOffset;
}
final halfWidth = size.width * 0.5;

Expand Down Expand Up @@ -226,7 +230,7 @@ class RecorderWavePainter extends CustomPainter {
// the width equal to currentlyRecordedDuration / maxDuration. No scroll.
dx = _boundedDx(size, i);
} else {
dx = -totalCurrentBackDistance.dx +
dx = -renderedBackDistance +
dragOffset.dx +
(spacing * i) -
initialPosition;
Expand Down Expand Up @@ -306,9 +310,11 @@ class RecorderWavePainter extends CustomPainter {
}

void _setScrolledDuration(Size size) {
// Use the logical (committed) back-distance, not the in-flight animated
// value, so the reported position reflects the true scroll target and does
// not lag/oscillate during the frame-interpolated scroll animation.
setCurrentPositionDuration(
(((-totalCurrentBackDistance.dx + dragOffset.dx - (size.width / 2)) /
spacing) *
(((-logicalBackDistance + dragOffset.dx - (size.width / 2)) / spacing) *
1000)
.abs()
.toInt(),
Expand Down