Files
sevenhill f74057ce9b
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
Rebrand application as Qlyra
2026-08-30 13:16:17 +03:00

722 lines
22 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}