расшифровка гс

This commit is contained in:
Jganenok
2026-05-14 12:34:07 +07:00
parent dc752e8b0d
commit bc545df1e7
6 changed files with 440 additions and 109 deletions
+57
View File
@@ -23,6 +23,34 @@ class ContactCache {
static String? getAvatar(int id) => _avatarCache[id]; static String? getAvatar(int id) => _avatarCache[id];
} }
class TranscriptionResult {
final int status;
final String? text;
final String? messageId;
final int? chatId;
final int? mediaId;
TranscriptionResult({
required this.status,
this.text,
this.messageId,
this.chatId,
this.mediaId,
});
}
class TranscriptionCache {
static final Map<String, TranscriptionResult> _cache = {};
static void put(String messageId, TranscriptionResult result) {
_cache[messageId] = result;
}
static TranscriptionResult? get(String messageId) => _cache[messageId];
static bool has(String messageId) => _cache.containsKey(messageId);
}
class CachedMessage { class CachedMessage {
final String id; final String id;
final int accountId; final int accountId;
@@ -247,6 +275,35 @@ class MessagesModule {
await _api.sendRequest(Opcode.msgSend, payload); await _api.sendRequest(Opcode.msgSend, payload);
} }
Future<TranscriptionResult> requestTranscription(
int chatId,
int messageId,
int mediaId,
) async {
final payload = {
'chatId': chatId,
'messageId': messageId,
'mediaId': mediaId,
};
final response = await _api.sendRequest(Opcode.audioTranscription, payload);
if (!response.isOk) return TranscriptionResult(status: -1);
final data = response.payload;
if (data is! Map) return TranscriptionResult(status: -1);
final transcriptionStatus = data['transcriptionStatus'] as int? ?? -1;
if (transcriptionStatus == 1) {
final text = data['transcription'] as String? ?? '';
if (text.isEmpty) {
return TranscriptionResult(status: 1, text: 'не удалось распознать текст');
}
return TranscriptionResult(status: 1, text: text);
}
return TranscriptionResult(status: transcriptionStatus);
}
Future<Uint8List?> downloadPhoto(String baseUrl, String photoToken) async { Future<Uint8List?> downloadPhoto(String baseUrl, String photoToken) async {
try { try {
final response = await _api.sendRequest(Opcode.fileDownload, { final response = await _api.sendRequest(Opcode.fileDownload, {
+6
View File
@@ -170,6 +170,10 @@ abstract class Opcode {
static const int notifBanners = 292; // Баннеры static const int notifBanners = 292; // Баннеры
static const int notifFolders = 277; // Обновление папок static const int notifFolders = 277; // Обновление папок
// ── Transcription ───────────────────────────────────────────────────
static const int audioTranscription = 202; // Запрос транскрибации аудио
static const int transcriptionResult = 293; // Результат транскрибации (push)
// ── Misc ─────────────────────────────────────────────────────────── // ── Misc ───────────────────────────────────────────────────────────
static const int okToken = 158; // OK-токен static const int okToken = 158; // OK-токен
static const int webAppInitData = 160; // Данные WebApp static const int webAppInitData = 160; // Данные WebApp
@@ -332,6 +336,8 @@ abstract class Opcode {
notifProfile: 'NOTIF_PROFILE', notifProfile: 'NOTIF_PROFILE',
notifBanners: 'NOTIF_BANNERS', notifBanners: 'NOTIF_BANNERS',
notifFolders: 'NOTIF_FOLDERS', notifFolders: 'NOTIF_FOLDERS',
audioTranscription: 'AUDIO_TRANSCRIPTION',
transcriptionResult: 'TRANSCRIPTION_RESULT',
okToken: 'OK_TOKEN', okToken: 'OK_TOKEN',
webAppInitData: 'WEB_APP_INIT_DATA', webAppInitData: 'WEB_APP_INIT_DATA',
complain: 'COMPLAIN', complain: 'COMPLAIN',
+5 -1
View File
@@ -53,8 +53,12 @@ class PacketDispatcher {
if (packet.cmd == CmdType.ok || if (packet.cmd == CmdType.ok ||
packet.cmd == CmdType.error || packet.cmd == CmdType.error ||
packet.cmd == CmdType.notFound) { packet.cmd == CmdType.notFound) {
final payloadStr = packet.payload.toString();
final displayPayload = packet.opcode == Opcode.login && payloadStr.length > 50
? '${payloadStr.substring(0, 50)}...'
: payloadStr;
logger.i( logger.i(
'<= {ver: ${packet.api}, cmd: ${packet.cmd}, seq: ${packet.seq}, opcode: ${packet.opcode}, payload: ${packet.payload}}', '<= {ver: ${packet.api}, cmd: ${packet.cmd}, seq: ${packet.seq}, opcode: ${packet.opcode}, payload: $displayPayload}',
); );
final completer = _pendingRequests.remove(packet.seq); final completer = _pendingRequests.remove(packet.seq);
@@ -10,7 +10,7 @@ void showCustomNotificationOnOverlay(OverlayState overlay, String message) {
builder: (context) => CustomNotification(message: message), builder: (context) => CustomNotification(message: message),
); );
overlay.insert(entry); overlay.insert(entry);
Future.delayed(const Duration(milliseconds: 1900), () { Future.delayed(const Duration(milliseconds: 2600), () {
entry.remove(); entry.remove();
}); });
} }
@@ -38,7 +38,7 @@ class _CustomNotificationState extends State<CustomNotification>
); );
_opacity = Tween<double>(begin: 0.0, end: 1.0).animate(_controller); _opacity = Tween<double>(begin: 0.0, end: 1.0).animate(_controller);
_controller.forward(); _controller.forward();
Future.delayed(const Duration(milliseconds: 1600), () { Future.delayed(const Duration(milliseconds: 2300), () {
if (mounted) _controller.reverse(); if (mounted) _controller.reverse();
}); });
} }
+354 -103
View File
@@ -112,19 +112,22 @@ class MessageBubble extends StatelessWidget {
return MessageType.text; return MessageType.text;
} }
// скругление уже смешариков т.е сообщений, те которые isme ? .. Это наши, после : это чужие
BorderRadius get _borderRadius { BorderRadius get _borderRadius {
final topRadius = Radius.circular(bubbleBorderRadius); final topRadius = Radius.circular(bubbleBorderRadius);
final bottomRadius = Radius.circular(bubbleBorderRadius);
final smallRadius = const Radius.circular(4); final smallRadius = const Radius.circular(4);
final cornerTL = isMe ? smallRadius : topRadius;
final cornerTR = isMe ? topRadius : smallRadius;
final cornerBL = isMe ? smallRadius : topRadius;
final cornerBR = isMe ? topRadius : smallRadius;
if (_hasPhotoWithCaption && if (_hasPhotoWithCaption &&
(shape == BubbleShape.singleTop || (shape == BubbleShape.singleTop ||
shape == BubbleShape.singleMiddle || shape == BubbleShape.singleMiddle ||
shape == BubbleShape.singleBottom)) { shape == BubbleShape.singleBottom)) {
return BorderRadius.only( return BorderRadius.only(
topLeft: topRadius, topLeft: topRadius,
topRight: isMe ? topRadius : topRadius, topRight: topRadius,
bottomLeft: smallRadius, bottomLeft: smallRadius,
bottomRight: smallRadius, bottomRight: smallRadius,
); );
@@ -136,16 +139,16 @@ class MessageBubble extends StatelessWidget {
return BorderRadius.only( return BorderRadius.only(
topLeft: smallRadius, topLeft: smallRadius,
topRight: smallRadius, topRight: smallRadius,
bottomLeft: isMe ? smallRadius : smallRadius, bottomLeft: smallRadius,
bottomRight: isMe ? smallRadius : bottomRadius, bottomRight: isMe ? smallRadius : topRadius,
); );
} }
switch (shape) { switch (shape) {
case BubbleShape.singleTop: case BubbleShape.singleTop:
return BorderRadius.only( return BorderRadius.only(
topLeft: isMe ? topRadius : smallRadius, topLeft: cornerTL,
topRight: isMe ? smallRadius : topRadius, topRight: cornerTR,
bottomLeft: smallRadius, bottomLeft: smallRadius,
bottomRight: smallRadius, bottomRight: smallRadius,
); );
@@ -153,22 +156,22 @@ class MessageBubble extends StatelessWidget {
return BorderRadius.only( return BorderRadius.only(
topLeft: smallRadius, topLeft: smallRadius,
topRight: smallRadius, topRight: smallRadius,
bottomLeft: isMe ? topRadius : smallRadius, bottomLeft: cornerBL,
bottomRight: isMe ? smallRadius : topRadius, bottomRight: cornerBR,
); );
case BubbleShape.singleMiddle: case BubbleShape.singleMiddle:
return BorderRadius.only( return BorderRadius.only(
topLeft: topRadius, topLeft: cornerTL,
topRight: topRadius, topRight: cornerTR,
bottomLeft: isMe ? topRadius : smallRadius, bottomLeft: cornerBL,
bottomRight: isMe ? smallRadius : topRadius, bottomRight: cornerBR,
); );
case BubbleShape.groupedMiddle: case BubbleShape.groupedMiddle:
return BorderRadius.only( return BorderRadius.only(
topLeft: isMe ? topRadius : smallRadius, topLeft: cornerTL,
topRight: isMe ? smallRadius : smallRadius, topRight: smallRadius,
bottomLeft: isMe ? topRadius : smallRadius, bottomLeft: cornerBL,
bottomRight: isMe ? smallRadius : smallRadius, bottomRight: smallRadius,
); );
} }
} }
@@ -273,13 +276,13 @@ class MessageBubble extends StatelessWidget {
case MessageType.voice: case MessageType.voice:
switch (shape) { switch (shape) {
case BubbleShape.groupedMiddle: case BubbleShape.groupedMiddle:
return const EdgeInsets.symmetric(horizontal: 14, vertical: 6); return const EdgeInsets.symmetric(horizontal: 14, vertical: 4);
case BubbleShape.singleTop: case BubbleShape.singleTop:
return const EdgeInsets.symmetric(horizontal: 14, vertical: 10); return const EdgeInsets.symmetric(horizontal: 14, vertical: 6);
case BubbleShape.singleBottom: case BubbleShape.singleBottom:
return const EdgeInsets.symmetric(horizontal: 14, vertical: 10); return const EdgeInsets.symmetric(horizontal: 14, vertical: 6);
case BubbleShape.singleMiddle: case BubbleShape.singleMiddle:
return const EdgeInsets.symmetric(horizontal: 14, vertical: 10); return const EdgeInsets.symmetric(horizontal: 14, vertical: 4);
} }
} }
return const EdgeInsets.symmetric(horizontal: 14, vertical: 10); return const EdgeInsets.symmetric(horizontal: 14, vertical: 10);
@@ -1550,23 +1553,47 @@ class MessageBubble extends StatelessWidget {
final textColor = isMe final textColor = isMe
? Colors.white ? Colors.white
: (isDark ? cs.onSurface : const Color(0xFF1C1C1E)); : (isDark ? cs.onSurface : const Color(0xFF1C1C1E));
final payload = message.payload;
final voice = payload?['voice'] as Map<String, dynamic>?;
final duration = voice?['duration'] as int? ?? 0;
final url = voice?['url']?.toString() ?? '';
return Column( int duration = 0;
crossAxisAlignment: CrossAxisAlignment.end, String url = '';
children: [ String? waveData;
_VoiceMessageBubble( int? audioId;
duration: duration,
url: url, final attaches = message.attachments;
textColor: textColor, if (attaches != null && attaches.isNotEmpty) {
isMe: isMe, for (final a in attaches) {
), if (a is AudioAttachment) {
const SizedBox(height: 6), duration = ((a.duration ?? 0) / 1000).round();
_buildMeta(context), url = a.fileUrl ?? a.baseUrl ?? '';
], waveData = a.waveform;
audioId = a.audioId;
break;
}
}
}
if (duration == 0 && url.isEmpty) {
final payload = message.payload;
final voice = payload?['voice'] as Map<String, dynamic>?;
duration = ((voice?['duration'] as int? ?? 0) / 1000).round();
url = voice?['url']?.toString() ?? '';
}
final cachedTranscription = TranscriptionCache.get(message.id);
return _VoiceMessageBubble(
duration: duration,
url: url,
textColor: textColor,
isMe: isMe,
status: message.status,
time: message.time,
cs: cs,
waveData: waveData,
chatId: message.chatId,
messageId: message.id,
audioId: audioId,
preloadedText: cachedTranscription?.text,
); );
} }
@@ -1666,12 +1693,28 @@ class _VoiceMessageBubble extends StatefulWidget {
final String url; final String url;
final Color textColor; final Color textColor;
final bool isMe; final bool isMe;
final String? status;
final int time;
final ColorScheme cs;
final String? waveData;
final int chatId;
final String messageId;
final int? audioId;
final String? preloadedText;
const _VoiceMessageBubble({ const _VoiceMessageBubble({
required this.duration, required this.duration,
required this.url, required this.url,
required this.textColor, required this.textColor,
required this.isMe, required this.isMe,
this.status,
required this.time,
required this.cs,
this.waveData,
required this.chatId,
required this.messageId,
this.audioId,
this.preloadedText,
}); });
@override @override
@@ -1681,97 +1724,305 @@ class _VoiceMessageBubble extends StatefulWidget {
class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> { class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> {
bool _isPlaying = false; bool _isPlaying = false;
double _progress = 0.0; double _progress = 0.0;
bool _transcriptionVisible = false;
String? _transcriptionText;
bool _transcriptionLoading = false;
@override @override
Widget build(BuildContext context) { void initState() {
final cs = Theme.of(context).colorScheme; super.initState();
final isDark = cs.brightness == Brightness.dark; if (widget.preloadedText != null) {
_transcriptionText = widget.preloadedText;
_transcriptionVisible = true;
}
}
return Container( String _formatDuration(int seconds) {
width: 220, final min = seconds ~/ 60;
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), final sec = seconds % 60;
child: Row( return '$min:${sec.toString().padLeft(2, '0')}';
}
String _formatTime(int timestamp) {
final dt = DateTime.fromMillisecondsSinceEpoch(timestamp);
final hour = dt.hour.toString().padLeft(2, '0');
final minute = dt.minute.toString().padLeft(2, '0');
return '$hour:$minute';
}
Widget _buildStatusIcon() {
final status = widget.status;
IconData icon;
Color color;
if (status == null || status == 'sending' || status == 'pending') {
icon = Symbols.check;
color = Colors.white54;
} else {
switch (status) {
case 'sent':
icon = Symbols.check;
color = Colors.white54;
case 'delivered':
icon = Symbols.done_all;
color = Colors.white54;
case 'read':
icon = Symbols.done_all;
color = const Color(0xFF34C759);
case 'error':
icon = Symbols.error;
color = Colors.redAccent;
default:
icon = Symbols.check;
color = Colors.white54;
}
}
return Icon(icon, size: 14, color: color);
}
Widget build(BuildContext context) {
final isDark = widget.cs.brightness == Brightness.dark;
final waveInactiveColor = widget.isMe
? Colors.white.withValues(alpha: 0.35)
: (isDark
? widget.cs.surfaceContainerHighest
: const Color(0xFFD1D1D6));
final waveActiveColor = widget.isMe
? Colors.white.withValues(alpha: 0.7)
: widget.cs.primary;
return SizedBox(
width: 240,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
GestureDetector( Row(
onTap: _togglePlay, crossAxisAlignment: CrossAxisAlignment.center,
child: Container( children: [
width: 36, GestureDetector(
height: 36, onTap: _togglePlay,
decoration: BoxDecoration( child: Container(
color: widget.isMe width: 32,
? Colors.white.withValues(alpha: 0.2) height: 32,
: cs.primaryContainer, decoration: BoxDecoration(
shape: BoxShape.circle, color: widget.isMe
? Colors.white.withValues(alpha: 0.2)
: widget.cs.primaryContainer,
shape: BoxShape.circle,
),
child: Icon(
_isPlaying ? Symbols.pause : Symbols.play_arrow,
color: widget.isMe ? Colors.white : widget.cs.primary,
size: 18,
),
),
), ),
child: Icon( const SizedBox(width: 10),
_isPlaying ? Symbols.pause : Symbols.play_arrow, Expanded(
color: widget.isMe ? Colors.white : cs.primary, child: LayoutBuilder(
size: 20, builder: (context, constraints) {
), return GestureDetector(
), onTapDown: (details) {
), setState(() {
const SizedBox(width: 10), _progress = (details.localPosition.dx /
Expanded( constraints.maxWidth)
child: Column( .clamp(0.0, 1.0);
crossAxisAlignment: CrossAxisAlignment.start, });
children: [ },
Stack( onHorizontalDragUpdate: (details) {
children: [ setState(() {
Container( _progress = (details.localPosition.dx /
height: 24, constraints.maxWidth)
decoration: BoxDecoration( .clamp(0.0, 1.0);
color: widget.isMe });
? Colors.white.withValues(alpha: 0.2) },
: (isDark
? cs.surfaceContainerHighest
: const Color(0xFFD1D1D6)),
borderRadius: BorderRadius.circular(2),
),
),
FractionallySizedBox(
widthFactor: _progress.clamp(0.0, 1.0),
child: Container( child: Container(
height: 24, height: 4,
decoration: BoxDecoration( decoration: BoxDecoration(
color: widget.isMe color: waveInactiveColor,
? Colors.white.withValues(alpha: 0.5)
: cs.primary,
borderRadius: BorderRadius.circular(2), borderRadius: BorderRadius.circular(2),
), ),
), child: FractionallySizedBox(
), alignment: Alignment.centerLeft,
SizedBox( widthFactor: _progress.clamp(0.0, 1.0),
height: 24, child: Container(
child: Center( decoration: BoxDecoration(
child: Text( color: waveActiveColor,
_formatDuration(widget.duration), borderRadius: BorderRadius.circular(2),
style: TextStyle( ),
color: widget.textColor.withValues(alpha: 0.8),
fontSize: 12,
fontWeight: FontWeight.w500,
), ),
), ),
), ),
), );
], },
), ),
),
const SizedBox(width: 8),
GestureDetector(
onTap: _requestTranscription,
child: SizedBox(
width: 20,
height: 32,
child: Center(
child: _transcriptionLoading
? SizedBox(
width: 12,
height: 12,
child: CircularProgressIndicator(
strokeWidth: 1.5,
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: [
Text(
_formatDuration(widget.duration),
style: TextStyle(
color: widget.textColor.withValues(alpha: 0.7),
fontSize: 11,
),
),
const SizedBox(width: 8),
Expanded(
child: AnimatedSize(
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
alignment: Alignment.topLeft,
child: _transcriptionVisible
? Text(
_transcriptionText ?? '',
style: TextStyle(
color: widget.textColor.withValues(alpha: 0.8),
fontSize: 12,
height: 1.3,
),
maxLines: 10,
overflow: TextOverflow.ellipsis,
)
: const SizedBox.shrink(),
),
),
if (!_transcriptionVisible) ...[
Text(
_formatTime(widget.time),
style: TextStyle(
color: widget.textColor.withValues(alpha: 0.6),
fontSize: 10,
),
),
if (widget.isMe) ...[
const SizedBox(width: 2),
_buildStatusIcon(),
],
],
],
),
if (_transcriptionVisible) ...[
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Text(
_formatTime(widget.time),
style: TextStyle(
color: widget.textColor.withValues(alpha: 0.6),
fontSize: 10,
),
),
if (widget.isMe) ...[
const SizedBox(width: 2),
_buildStatusIcon(),
],
], ],
), ),
), ],
], ],
), ),
); );
} }
Widget _buildProgressBar(Color inactive, Color active) {
return Container(
height: 4,
decoration: BoxDecoration(
color: inactive,
borderRadius: BorderRadius.circular(2),
),
);
}
void _togglePlay() { void _togglePlay() {
setState(() { setState(() {
_isPlaying = !_isPlaying; _isPlaying = !_isPlaying;
}); });
} }
String _formatDuration(int seconds) { Future<void> _requestTranscription() async {
final min = seconds ~/ 60; if (widget.audioId == null) return;
final sec = seconds % 60;
return '$min:${sec.toString().padLeft(2, '0')}'; if (_transcriptionVisible && _transcriptionText != null) {
setState(() {
_transcriptionVisible = false;
});
return;
}
if (TranscriptionCache.has(widget.messageId)) {
final cached = TranscriptionCache.get(widget.messageId)!;
setState(() {
_transcriptionText = cached.text ?? 'не удалось распознать текст';
_transcriptionVisible = true;
});
return;
}
setState(() {
_transcriptionLoading = true;
});
try {
final result = await messagesModule.requestTranscription(
widget.chatId,
int.tryParse(widget.messageId) ?? 0,
widget.audioId!,
);
TranscriptionCache.put(widget.messageId, result);
setState(() {
_transcriptionLoading = false;
if (result.status == 1) {
_transcriptionText = (result.text == null || result.text!.isEmpty)
? 'не удалось распознать текст'
: result.text;
_transcriptionVisible = true;
} else if (result.status == 0) {
_transcriptionText = 'транскрибация...';
_transcriptionVisible = true;
}
});
} catch (e) {
setState(() {
_transcriptionLoading = false;
_transcriptionText = 'ошибка транскрибации';
_transcriptionVisible = true;
});
}
} }
} }
+16 -3
View File
@@ -198,14 +198,27 @@ class AudioAttachment extends MessageAttachment {
} catch (_) {} } catch (_) {}
} }
String? waveStr;
final waveRaw = map['wave'];
if (waveRaw is String) {
waveStr = waveRaw;
} else if (waveRaw is List) {
try {
final bytes = List<int>.from(waveRaw);
final base64 = String.fromCharCodes(bytes);
waveStr = 'data:image/webp;base64,$base64';
} catch (_) {}
}
return AudioAttachment( return AudioAttachment(
previewData: previewStr, previewData: previewStr,
baseUrl: map['baseUrl'] as String?, baseUrl: map['baseUrl']?.toString(),
fileUrl: map['url']?.toString(),
audioId: map['audioId'] as int?, audioId: map['audioId'] as int?,
audioToken: map['audioToken'] as String?, audioToken: map['token']?.toString(),
duration: map['duration'] as int?, duration: map['duration'] as int?,
size: map['size'] as int?, size: map['size'] as int?,
waveform: map['waveform'] as String?, waveform: waveStr,
); );
} }