Build Android (FCM) / build-android-fcm (push) Canceled after 0s
Build Android / build-android (push) Canceled after 0s
Build iOS / build-ios (push) Canceled after 0s
Build Linux / build-linux (push) Canceled after 0s
Build macOS / build-macos (push) Canceled after 0s
Build Windows / build-windows (push) Canceled after 0s
Release (main) / android (oneme) (push) Canceled after 0s
Release (main) / android (qlyra) (push) Canceled after 0s
Release (main) / windows (push) Canceled after 0s
Release (main) / linux (push) Canceled after 0s
Release (main) / macos (push) Canceled after 0s
Release (main) / ios (push) Canceled after 0s
Release (main) / release (push) Canceled after 0s
722 lines
22 KiB
Dart
722 lines
22 KiB
Dart
import 'package:flutter/foundation.dart';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:material_symbols_icons/symbols.dart';
|
||
import 'package:qlyra/main.dart';
|
||
|
||
import '../../../../backend/modules/messages.dart';
|
||
import '../../../../core/config/app_colors.dart';
|
||
import '../../../../core/config/qlyra_settings.dart';
|
||
import '../../../../core/media/media_playback.dart';
|
||
import '../../../../core/media/voice_audio_controller.dart';
|
||
import '../../../../core/utils/format.dart';
|
||
import '../../../../core/utils/logger.dart';
|
||
import '../../custom_notification.dart';
|
||
import '../../small_spinner.dart';
|
||
|
||
class VoiceMessageBubble extends StatefulWidget {
|
||
final int duration;
|
||
final String url;
|
||
final Color textColor;
|
||
final bool isMe;
|
||
final bool deleted;
|
||
final String? status;
|
||
final ValueListenable<int>? otherReadTime;
|
||
final int time;
|
||
final ColorScheme cs;
|
||
final String? waveData;
|
||
final int chatId;
|
||
final String messageId;
|
||
final int? sourceChatId;
|
||
final String? sourceMessageId;
|
||
final int senderId;
|
||
final int? audioId;
|
||
final String? preloadedText;
|
||
final ValueListenable<List<double>>? uploadProgress;
|
||
|
||
const VoiceMessageBubble({
|
||
super.key,
|
||
required this.duration,
|
||
required this.url,
|
||
required this.textColor,
|
||
required this.isMe,
|
||
this.deleted = false,
|
||
this.status,
|
||
this.otherReadTime,
|
||
required this.time,
|
||
required this.cs,
|
||
this.waveData,
|
||
required this.chatId,
|
||
required this.messageId,
|
||
this.sourceChatId,
|
||
this.sourceMessageId,
|
||
required this.senderId,
|
||
this.audioId,
|
||
this.preloadedText,
|
||
this.uploadProgress,
|
||
});
|
||
|
||
@override
|
||
State<VoiceMessageBubble> createState() => _VoiceMessageBubbleState();
|
||
}
|
||
|
||
const double _transcriptionMaxHeight = 132;
|
||
|
||
class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
||
bool _transcriptionVisible = false;
|
||
String? _transcriptionText;
|
||
bool _transcriptionLoading = false;
|
||
|
||
late final VoiceAudioController _audio;
|
||
late final List<int> _amps = _parseWave(widget.waveData);
|
||
|
||
static List<int> _parseWave(String? data) {
|
||
if (data == null || data.isEmpty) return const [];
|
||
return data.codeUnits;
|
||
}
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_transcriptionText = widget.preloadedText;
|
||
_audio = MediaPlayback.instance.acquireVoice(
|
||
cacheName: _cacheName,
|
||
resolveUrl: () async => widget.url,
|
||
fallbackDuration: Duration(seconds: widget.duration),
|
||
);
|
||
_audio.failure.addListener(_onFailure);
|
||
TranscriptionCache.listen(_sourceMessageId, _onTranscriptionPush);
|
||
_adoptCachedTranscription();
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
TranscriptionCache.unlisten(_sourceMessageId, _onTranscriptionPush);
|
||
_audio.failure.removeListener(_onFailure);
|
||
MediaPlayback.instance.releaseVoice(_audio);
|
||
super.dispose();
|
||
}
|
||
|
||
void _adoptCachedTranscription() {
|
||
final cached = TranscriptionCache.get(_sourceMessageId);
|
||
if (cached == null || cached.status != 1) return;
|
||
_transcriptionText = cached.text ?? 'не удалось распознать текст';
|
||
_transcriptionVisible = TranscriptionCache.isExpanded(_sourceMessageId);
|
||
}
|
||
|
||
void _onTranscriptionPush() {
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_transcriptionLoading = false;
|
||
_adoptCachedTranscription();
|
||
});
|
||
}
|
||
|
||
void _showTranscription(String text) {
|
||
_transcriptionText = text;
|
||
_transcriptionVisible = true;
|
||
TranscriptionCache.setExpanded(_sourceMessageId, true);
|
||
}
|
||
|
||
String get _cacheName => '${widget.audioId ?? _sourceMessageId}.ogg';
|
||
|
||
int get _sourceChatId => widget.sourceChatId ?? widget.chatId;
|
||
|
||
String get _sourceMessageId => widget.sourceMessageId ?? widget.messageId;
|
||
|
||
void _claimPlayback() {
|
||
MediaPlayback.instance.activateVoice(
|
||
VoiceTrack(
|
||
cacheName: _cacheName,
|
||
chatId: widget.chatId,
|
||
messageId: widget.messageId,
|
||
senderId: widget.senderId,
|
||
isMe: widget.isMe,
|
||
time: widget.time,
|
||
audio: _audio,
|
||
),
|
||
);
|
||
}
|
||
|
||
bool get _uploading => widget.uploadProgress != null;
|
||
|
||
void _toggle() {
|
||
if (_uploading) return;
|
||
_claimPlayback();
|
||
_audio.toggle();
|
||
}
|
||
|
||
void _onFailure() {
|
||
if (!mounted) return;
|
||
switch (_audio.failure.value) {
|
||
case VoiceAudioFailure.none:
|
||
return;
|
||
case VoiceAudioFailure.download:
|
||
showCustomNotification(context, 'Не удалось загрузить аудио');
|
||
case VoiceAudioFailure.playback:
|
||
showCustomNotification(context, 'Ошибка воспроизведения');
|
||
}
|
||
}
|
||
|
||
Widget _buildStatusIcon() {
|
||
final rt = widget.otherReadTime;
|
||
if (rt == null) return _statusIconFor(widget.status);
|
||
return ValueListenableBuilder<int>(
|
||
valueListenable: rt,
|
||
builder: (context, readTime, _) =>
|
||
_statusIconFor(_upgradedStatus(readTime)),
|
||
);
|
||
}
|
||
|
||
String? _upgradedStatus(int readTime) {
|
||
final base = widget.status;
|
||
if ((base == null || base == 'sent') &&
|
||
readTime > 0 &&
|
||
readTime >= widget.time) {
|
||
return 'read';
|
||
}
|
||
return base;
|
||
}
|
||
|
||
Widget _statusIconFor(String? status) {
|
||
IconData icon;
|
||
Color color;
|
||
|
||
if (status == null || status == 'sent') {
|
||
icon = Symbols.check;
|
||
color = Colors.white54;
|
||
} else {
|
||
switch (status) {
|
||
case 'sending':
|
||
case 'pending':
|
||
icon = Symbols.schedule;
|
||
color = widget.cs.onPrimaryContainer.withValues(alpha: 0.55);
|
||
case 'sent':
|
||
icon = Symbols.check;
|
||
color = widget.cs.onPrimaryContainer.withValues(alpha: 0.55);
|
||
case 'delivered':
|
||
icon = Symbols.done_all;
|
||
color = widget.cs.onPrimaryContainer.withValues(alpha: 0.55);
|
||
case 'read':
|
||
icon = Symbols.done_all;
|
||
color = kReadReceiptBlue;
|
||
case 'error':
|
||
icon = Symbols.error;
|
||
color = Colors.redAccent;
|
||
default:
|
||
icon = Symbols.check;
|
||
color = widget.cs.onPrimaryContainer.withValues(alpha: 0.55);
|
||
}
|
||
}
|
||
|
||
return Icon(icon, size: 14, color: color);
|
||
}
|
||
|
||
Color get _accent =>
|
||
widget.isMe ? widget.cs.onPrimaryContainer : widget.cs.primary;
|
||
|
||
Widget _buildPlayButton() {
|
||
final uploading = widget.uploadProgress;
|
||
return GestureDetector(
|
||
onTap: uploading == null ? _toggle : null,
|
||
child: Container(
|
||
width: 32,
|
||
height: 32,
|
||
decoration: BoxDecoration(
|
||
color: widget.isMe
|
||
? widget.cs.onPrimaryContainer.withValues(alpha: 0.12)
|
||
: widget.cs.primaryContainer,
|
||
shape: BoxShape.circle,
|
||
),
|
||
child: uploading != null
|
||
? _buildUploadIndicator(uploading)
|
||
: AnimatedBuilder(
|
||
animation: Listenable.merge([
|
||
_audio.downloaded,
|
||
_audio.downloadProgress,
|
||
_audio.playing,
|
||
]),
|
||
builder: (context, _) {
|
||
final progress = _audio.downloadProgress.value;
|
||
if (progress != null) {
|
||
return Padding(
|
||
padding: const EdgeInsets.all(4),
|
||
child: CircularProgressIndicator(
|
||
strokeWidth: 2,
|
||
value: progress > 0 ? progress : null,
|
||
color: _accent,
|
||
backgroundColor: _accent.withValues(alpha: 0.2),
|
||
),
|
||
);
|
||
}
|
||
final IconData icon;
|
||
if (_audio.playing.value) {
|
||
icon = Symbols.pause;
|
||
} else if (_audio.downloaded.value) {
|
||
icon = Symbols.play_arrow;
|
||
} else {
|
||
icon = Symbols.arrow_downward;
|
||
}
|
||
return AnimatedSwitcher(
|
||
duration: const Duration(milliseconds: 160),
|
||
transitionBuilder: (child, animation) =>
|
||
ScaleTransition(scale: animation, child: child),
|
||
child: Icon(
|
||
icon,
|
||
key: ValueKey(icon),
|
||
color: _accent,
|
||
size: 18,
|
||
),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildUploadIndicator(ValueListenable<List<double>> progress) {
|
||
return Padding(
|
||
padding: const EdgeInsets.all(4),
|
||
child: ValueListenableBuilder<List<double>>(
|
||
valueListenable: progress,
|
||
builder: (context, values, _) {
|
||
final value = values.isEmpty
|
||
? 0.0
|
||
: values.reduce((a, b) => a + b) / values.length;
|
||
return TweenAnimationBuilder<double>(
|
||
tween: Tween<double>(end: value.clamp(0.0, 1.0)),
|
||
duration: const Duration(milliseconds: 220),
|
||
curve: Curves.easeOut,
|
||
builder: (context, shown, _) => CircularProgressIndicator(
|
||
strokeWidth: 2,
|
||
value: shown >= 1.0 ? null : shown,
|
||
color: _accent,
|
||
backgroundColor: _accent.withValues(alpha: 0.2),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildTimeLabel() {
|
||
return AnimatedBuilder(
|
||
animation: Listenable.merge([
|
||
_audio.position,
|
||
_audio.duration,
|
||
_audio.playing,
|
||
]),
|
||
builder: (context, _) {
|
||
final elapsed = _audio.position.value;
|
||
final total = _audio.duration.value;
|
||
final seconds = elapsed > 0 ? elapsed.round() : total.round();
|
||
return Text(
|
||
formatSecondsMmSs(seconds),
|
||
style: TextStyle(
|
||
color: widget.textColor.withValues(alpha: 0.7),
|
||
fontSize: 11,
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final waveInactiveColor = widget.textColor.withValues(alpha: 0.35);
|
||
final waveActiveColor = widget.isMe
|
||
? widget.cs.onPrimaryContainer
|
||
: widget.cs.primary;
|
||
|
||
return SizedBox(
|
||
width: 240,
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
crossAxisAlignment: CrossAxisAlignment.center,
|
||
children: [
|
||
_buildPlayButton(),
|
||
const SizedBox(width: 10),
|
||
Expanded(
|
||
child: _SeekableWaveform(
|
||
onClaim: _claimPlayback,
|
||
onToggle: _toggle,
|
||
audio: _audio,
|
||
amps: _amps,
|
||
active: waveActiveColor,
|
||
inactive: waveInactiveColor,
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
GestureDetector(
|
||
onTap: _requestTranscription,
|
||
child: SizedBox(
|
||
width: 20,
|
||
height: 32,
|
||
child: Center(
|
||
child: _transcriptionLoading
|
||
? SmallSpinner(
|
||
size: 12,
|
||
color: widget.textColor.withValues(alpha: 0.6),
|
||
)
|
||
: Text(
|
||
'Т',
|
||
style: TextStyle(
|
||
color: widget.textColor.withValues(alpha: 0.6),
|
||
fontSize: 13,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 2),
|
||
Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
SizedBox(width: 32, child: Center(child: _buildTimeLabel())),
|
||
const SizedBox(width: 10),
|
||
Expanded(
|
||
child: AnimatedSize(
|
||
duration: const Duration(milliseconds: 200),
|
||
curve: Curves.easeOut,
|
||
alignment: Alignment.topLeft,
|
||
child: _transcriptionVisible
|
||
? ConstrainedBox(
|
||
constraints: const BoxConstraints(
|
||
maxHeight: _transcriptionMaxHeight,
|
||
),
|
||
child: SingleChildScrollView(
|
||
physics: const ClampingScrollPhysics(),
|
||
child: Text(
|
||
_transcriptionText ?? '',
|
||
style: TextStyle(
|
||
color: widget.textColor.withValues(alpha: 0.8),
|
||
fontSize: 12,
|
||
height: 1.3,
|
||
),
|
||
),
|
||
),
|
||
)
|
||
: const SizedBox.shrink(),
|
||
),
|
||
),
|
||
if (!_transcriptionVisible) ...[
|
||
Text(
|
||
formatClock(
|
||
DateTime.fromMillisecondsSinceEpoch(widget.time),
|
||
withSeconds: QlyraSettings.fullTimestamp.value,
|
||
),
|
||
style: TextStyle(
|
||
color: widget.textColor.withValues(alpha: 0.6),
|
||
fontSize: 10,
|
||
),
|
||
),
|
||
if (widget.isMe) ...[
|
||
const SizedBox(width: 2),
|
||
_buildStatusIcon(),
|
||
],
|
||
if (widget.deleted) ...[
|
||
const SizedBox(width: 2),
|
||
Icon(
|
||
Symbols.delete,
|
||
size: 13,
|
||
color: widget.textColor.withValues(alpha: 0.6),
|
||
),
|
||
],
|
||
],
|
||
],
|
||
),
|
||
if (_transcriptionVisible) ...[
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.end,
|
||
children: [
|
||
Text(
|
||
formatClock(
|
||
DateTime.fromMillisecondsSinceEpoch(widget.time),
|
||
withSeconds: QlyraSettings.fullTimestamp.value,
|
||
),
|
||
style: TextStyle(
|
||
color: widget.textColor.withValues(alpha: 0.6),
|
||
fontSize: 10,
|
||
),
|
||
),
|
||
if (widget.isMe) ...[
|
||
const SizedBox(width: 2),
|
||
_buildStatusIcon(),
|
||
],
|
||
if (widget.deleted) ...[
|
||
const SizedBox(width: 2),
|
||
Icon(
|
||
Symbols.delete,
|
||
size: 13,
|
||
color: widget.textColor.withValues(alpha: 0.6),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
],
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Future<void> _requestTranscription() async {
|
||
if (widget.audioId == null) return;
|
||
|
||
if (_transcriptionVisible && _transcriptionText != null) {
|
||
setState(() {
|
||
_transcriptionVisible = false;
|
||
TranscriptionCache.setExpanded(_sourceMessageId, false);
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (TranscriptionCache.has(_sourceMessageId)) {
|
||
final cached = TranscriptionCache.get(_sourceMessageId)!;
|
||
setState(
|
||
() => _showTranscription(cached.text ?? 'не удалось распознать текст'),
|
||
);
|
||
return;
|
||
}
|
||
|
||
setState(() {
|
||
_transcriptionLoading = true;
|
||
});
|
||
|
||
try {
|
||
final result = await messagesModule.requestTranscription(
|
||
_sourceChatId,
|
||
int.tryParse(_sourceMessageId) ?? 0,
|
||
widget.audioId!,
|
||
);
|
||
|
||
TranscriptionCache.put(_sourceMessageId, result);
|
||
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_transcriptionLoading = false;
|
||
if (result.status == 1) {
|
||
_showTranscription(
|
||
(result.text == null || result.text!.isEmpty)
|
||
? 'не удалось распознать текст'
|
||
: result.text!,
|
||
);
|
||
} else if (result.status == 0) {
|
||
_showTranscription('транскрибация...');
|
||
}
|
||
});
|
||
} catch (e) {
|
||
logger.w('VoiceBubble._requestTranscription: $e');
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_transcriptionLoading = false;
|
||
_showTranscription('ошибка транскрибации');
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
class _SeekableWaveform extends StatefulWidget {
|
||
final VoiceAudioController audio;
|
||
final List<int> amps;
|
||
final Color active;
|
||
final Color inactive;
|
||
final VoidCallback onClaim;
|
||
final VoidCallback onToggle;
|
||
|
||
const _SeekableWaveform({
|
||
required this.audio,
|
||
required this.amps,
|
||
required this.active,
|
||
required this.inactive,
|
||
required this.onClaim,
|
||
required this.onToggle,
|
||
});
|
||
|
||
@override
|
||
State<_SeekableWaveform> createState() => _SeekableWaveformState();
|
||
}
|
||
|
||
class _SeekableWaveformState extends State<_SeekableWaveform> {
|
||
static const double _hitHeight = 32;
|
||
static const double _waveHeight = 26;
|
||
|
||
double _width = 0;
|
||
|
||
VoiceAudioController get _audio => widget.audio;
|
||
|
||
void _claimPlayback() => widget.onClaim();
|
||
|
||
void _toggle() => widget.onToggle();
|
||
|
||
double _secondsAt(double dx) {
|
||
final total = _audio.duration.value;
|
||
if (_width <= 0 || total <= 0) return 0;
|
||
return (dx / _width).clamp(0.0, 1.0) * total;
|
||
}
|
||
|
||
void _onTapUp(TapUpDetails details) {
|
||
if (!_audio.downloaded.value) {
|
||
_toggle();
|
||
return;
|
||
}
|
||
_claimPlayback();
|
||
_audio.seekTo(_secondsAt(details.localPosition.dx));
|
||
}
|
||
|
||
void _onDragStart(DragStartDetails details) {
|
||
if (!_audio.downloaded.value) return;
|
||
_claimPlayback();
|
||
_audio.scrubStart();
|
||
_audio.scrubTo(_secondsAt(details.localPosition.dx));
|
||
}
|
||
|
||
void _onDragUpdate(DragUpdateDetails details) {
|
||
if (!_audio.scrubbing) return;
|
||
_audio.scrubTo(_secondsAt(details.localPosition.dx));
|
||
}
|
||
|
||
void _onDragEnd(DragEndDetails details) => _audio.scrubEnd();
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return LayoutBuilder(
|
||
builder: (context, constraints) {
|
||
_width = constraints.maxWidth;
|
||
return GestureDetector(
|
||
behavior: HitTestBehavior.opaque,
|
||
onTapUp: _onTapUp,
|
||
onHorizontalDragStart: _onDragStart,
|
||
onHorizontalDragUpdate: _onDragUpdate,
|
||
onHorizontalDragEnd: _onDragEnd,
|
||
onHorizontalDragCancel: _audio.scrubEnd,
|
||
child: SizedBox(
|
||
height: _hitHeight,
|
||
child: Center(
|
||
child: SizedBox(
|
||
height: _waveHeight,
|
||
child: AnimatedBuilder(
|
||
animation: Listenable.merge([
|
||
_audio.position,
|
||
_audio.duration,
|
||
_audio.playing,
|
||
_audio.downloaded,
|
||
]),
|
||
builder: (context, _) {
|
||
final total = _audio.duration.value;
|
||
final progress = total > 0
|
||
? (_audio.position.value / total).clamp(0.0, 1.0)
|
||
: 0.0;
|
||
return CustomPaint(
|
||
size: Size.infinite,
|
||
painter: _WaveformPainter(
|
||
amps: widget.amps,
|
||
progress: progress,
|
||
active: widget.active,
|
||
inactive: widget.inactive,
|
||
knob: _audio.downloaded.value && progress > 0,
|
||
),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
}
|
||
|
||
class _WaveformPainter extends CustomPainter {
|
||
final List<int> amps;
|
||
final double progress;
|
||
final Color active;
|
||
final Color inactive;
|
||
final bool knob;
|
||
|
||
const _WaveformPainter({
|
||
required this.amps,
|
||
required this.progress,
|
||
required this.active,
|
||
required this.inactive,
|
||
this.knob = false,
|
||
});
|
||
|
||
@override
|
||
void paint(Canvas canvas, Size size) {
|
||
final center = size.height / 2;
|
||
|
||
if (amps.isEmpty) {
|
||
final track = Paint()
|
||
..strokeWidth = 3
|
||
..strokeCap = StrokeCap.round;
|
||
canvas.drawLine(
|
||
Offset(0, center),
|
||
Offset(size.width, center),
|
||
track..color = inactive,
|
||
);
|
||
if (progress > 0) {
|
||
canvas.drawLine(
|
||
Offset(0, center),
|
||
Offset(size.width * progress.clamp(0.0, 1.0), center),
|
||
track..color = active,
|
||
);
|
||
}
|
||
_paintKnob(canvas, size, center);
|
||
return;
|
||
}
|
||
|
||
final n = amps.length;
|
||
var maxAmp = 1;
|
||
for (final a in amps) {
|
||
if (a > maxAmp) maxAmp = a;
|
||
}
|
||
final slot = size.width / n;
|
||
final barW = (slot * 0.55).clamp(1.0, 3.0);
|
||
final paint = Paint();
|
||
|
||
for (var i = 0; i < n; i++) {
|
||
final h = ((amps[i] / maxAmp) * size.height).clamp(2.0, size.height);
|
||
final x = i * slot + (slot - barW) / 2;
|
||
paint.color = ((i + 0.5) / n) <= progress ? active : inactive;
|
||
canvas.drawRRect(
|
||
RRect.fromRectAndRadius(
|
||
Rect.fromLTWH(x, center - h / 2, barW, h),
|
||
Radius.circular(barW / 2),
|
||
),
|
||
paint,
|
||
);
|
||
}
|
||
|
||
_paintKnob(canvas, size, center);
|
||
}
|
||
|
||
void _paintKnob(Canvas canvas, Size size, double center) {
|
||
if (!knob) return;
|
||
final x = (size.width * progress.clamp(0.0, 1.0)).clamp(
|
||
1.5,
|
||
size.width - 1.5,
|
||
);
|
||
canvas.drawRRect(
|
||
RRect.fromRectAndRadius(
|
||
Rect.fromLTWH(x - 1.5, 0, 3, size.height),
|
||
const Radius.circular(1.5),
|
||
),
|
||
Paint()..color = active,
|
||
);
|
||
}
|
||
|
||
@override
|
||
bool shouldRepaint(_WaveformPainter old) =>
|
||
old.progress != progress ||
|
||
old.active != active ||
|
||
old.inactive != inactive ||
|
||
old.knob != knob ||
|
||
!identical(old.amps, amps);
|
||
}
|