Merge branch 'feature/voice-video-notes' into feature/FullStack

This commit is contained in:
klockky
2026-06-25 17:36:01 +03:00
20 changed files with 3056 additions and 87 deletions
+67 -1
View File
@@ -155,6 +155,71 @@ class FileUploader {
return ctrl.stream;
}
/// Загружает медиа (Ogg/Opus аудио или MP4 видеосообщение) на CDN-URL,
/// полученный из [MessagesModule.requestAudioUploadUrl] /
/// [MessagesModule.requestVideoNoteUploadUrl]. Одиночный POST всего файла
/// (`octet-stream`, `Content-Range` на весь объём, `filename=<число>`).
/// Токен уже известен, поэтому возвращается только признак успеха.
Future<bool> uploadMediaFile(
Uri uri,
File file, {
void Function(int sent, int total)? onProgress,
Duration overallTimeout = const Duration(minutes: 5),
Duration progressThrottle = const Duration(milliseconds: 16),
}) async {
Socket? socket;
try {
final total = await file.length();
if (total <= 0) return false;
final filename =
(DateTime.now().microsecondsSinceEpoch & 0x7FFFFFFF).toString();
socket = await _openSocket(uri);
_writeHeaders(
socket,
uri,
filename,
total,
contentType: 'application/octet-stream',
connection: 'close',
);
final stopwatch = Stopwatch()..start();
var sent = 0;
final body = file.openRead().map((chunk) {
sent += chunk.length;
if (onProgress != null && stopwatch.elapsed >= progressThrottle) {
onProgress(sent, total);
stopwatch.reset();
}
return chunk;
});
await socket.addStream(body);
await socket.flush();
onProgress?.call(total, total);
final response = await _readFullResponse(socket, timeout: overallTimeout);
try {
socket.destroy();
} catch (_) {}
final statusCode = response?.$1 ?? 0;
final respBody = response?.$2 ?? '';
logger.w(
'uploadMediaFile: status=$statusCode total=$total '
'host=${uri.host} body=${respBody.length > 200 ? respBody.substring(0, 200) : respBody}',
);
final hasError =
respBody.contains('error_msg') || respBody.contains('error_code');
return statusCode == 200 && !hasError;
} catch (e) {
logger.w('uploadMediaFile: $e');
try {
socket?.destroy();
} catch (_) {}
return false;
}
}
Future<Socket> _openSocket(Uri uri) async {
final proxySettings = await ProxyConfig.load();
final base = proxySettings.isEnabled
@@ -175,6 +240,7 @@ class FileUploader {
String filename,
int total, {
String contentType = 'application/x-binary; charset=x-user-defined',
String connection = 'keep-alive',
}) {
final path = '${uri.path}${uri.hasQuery ? "?${uri.query}" : ""}';
final headers = StringBuffer()
@@ -182,7 +248,7 @@ class FileUploader {
..write('Host: ${uri.host}\r\n')
..write('Content-Type: $contentType\r\n')
..write('Content-Disposition: attachment; filename=$filename\r\n')
..write('Connection: keep-alive\r\n')
..write('Connection: $connection\r\n')
..write('User-Agent: ${Uri.encodeComponent('OKMessages/26.14.1 (Android 11; TECNO MOBILE LIMITED TECNO LE7n; xxhdpi 480dpi 1080x2208)')}\r\n')
..write('Content-Range: bytes 0-${total - 1}/$total\r\n')
..write('Content-Length: $total\r\n')
+197 -3
View File
@@ -339,6 +339,18 @@ class ReplyInfo {
}
}
class AudioUploadInfo {
final String url;
final int audioId;
final String token;
AudioUploadInfo({
required this.url,
required this.audioId,
required this.token,
});
}
class CachedMessage {
final String id;
final int accountId;
@@ -954,7 +966,10 @@ class MessagesModule {
if (response.isOk) return true;
return false;
} on PacketError catch (e) {
if (e.errorKey != 'attachment.not.ready') rethrow;
if (!(e.errorKey?.contains('not.ready') ?? false)) {
logger.w('msgSend rejected: key=${e.errorKey} msg=${e.message}');
rethrow;
}
if (attempt == maxAttempts - 1) return false;
await Future.delayed(retryDelay);
}
@@ -1006,7 +1021,10 @@ class MessagesModule {
}
return null;
} on PacketError catch (e) {
if (e.errorKey != 'attachment.not.ready') rethrow;
if (!(e.errorKey?.contains('not.ready') ?? false)) {
logger.w('msgSend rejected: key=${e.errorKey} msg=${e.message}');
rethrow;
}
if (attempt == maxAttempts - 1) return null;
await Future.delayed(retryDelay);
}
@@ -1080,7 +1098,183 @@ class MessagesModule {
}
return null;
} on PacketError catch (e) {
if (e.errorKey != 'attachment.not.ready') rethrow;
if (!(e.errorKey?.contains('not.ready') ?? false)) {
logger.w('msgSend rejected: key=${e.errorKey} msg=${e.message}');
rethrow;
}
if (attempt == maxAttempts - 1) return null;
await Future.delayed(retryDelay);
}
}
return null;
}
/// Запрашивает URL для загрузки голосового сообщения (опкод 82).
///
/// Тот же опкод, что и у видео, но `uploaderType: 1, type: 2`. В ответе
/// `videoId` — это идентификатор аудио (`audioId`), а `token` уже выдан и
/// используется в [sendAudioMessage] после загрузки байтов.
Future<AudioUploadInfo?> requestAudioUploadUrl() async {
final response = await _api.sendRequest(Opcode.videoUpload, {
'uploaderType': 1,
'type': 2,
'count': 1,
});
if (!response.isOk) return null;
final data = response.payload;
if (data is! Map) return null;
final infoList = data['info'] as List?;
if (infoList == null || infoList.isEmpty) return null;
final info = infoList.first;
if (info is! Map) return null;
return AudioUploadInfo(
url: info['url'] as String? ?? '',
audioId: info['videoId'] as int? ?? 0,
token: info['token'] as String? ?? '',
);
}
/// Отправляет голосовое сообщение по [token], полученному из
/// [requestAudioUploadUrl], после загрузки Ogg/Opus-байтов на CDN.
///
/// [duration] — длительность в миллисекундах. [wave] — hex-строка амплитуд
/// для дорожки; если пусто, отправляется плоская (нулевая) волна, которую
/// сервер принимает. Сервер может ответить `attachment.not.ready`, пока
/// обрабатывает загрузку — запрос повторяется.
Future<Map<String, dynamic>?> sendAudioMessage(
int chatId,
String token, {
required int duration,
Uint8List? wave,
bool notify = true,
int? scheduledTime,
int maxAttempts = 30,
Duration retryDelay = const Duration(seconds: 1),
}) async {
final message = <String, dynamic>{
'isLive': false,
'detectShare': false,
'elements': <dynamic>[],
'cid': DateTime.now().millisecondsSinceEpoch * -1,
'attaches': [
{
'duration': duration,
'_type': 'AUDIO',
'wave': (wave != null && wave.isNotEmpty) ? wave : Uint8List(80),
'token': token,
},
],
};
if (scheduledTime != null) {
message['delayedAttributes'] = {
'timeToFire': scheduledTime,
'notifySender': true,
};
}
final payload = {'chatId': chatId, 'message': message, 'notify': notify};
for (var attempt = 0; attempt < maxAttempts; attempt++) {
try {
final response = await _api.sendRequest(Opcode.msgSend, payload);
if (!response.isOk) return null;
final data = response.payload;
if (data is Map) {
final msg = data['message'];
if (msg is Map) return Map<String, dynamic>.from(msg);
}
return null;
} on PacketError catch (e) {
if (!(e.errorKey?.contains('not.ready') ?? false)) {
logger.w('msgSend rejected: key=${e.errorKey} msg=${e.message}');
rethrow;
}
if (attempt == maxAttempts - 1) return null;
await Future.delayed(retryDelay);
}
}
return null;
}
/// Запрашивает URL для загрузки видеосообщения-кружка (опкод 82,
/// `uploaderType: 1, type: 1`). Ответ — `vu.oneme.ru/uploadVideo` + token.
Future<VideoUploadInfo?> requestVideoNoteUploadUrl() async {
final response = await _api.sendRequest(Opcode.videoUpload, {
'uploaderType': 1,
'type': 1,
'count': 1,
});
if (!response.isOk) return null;
final data = response.payload;
if (data is! Map) return null;
final infoList = data['info'] as List?;
if (infoList == null || infoList.isEmpty) return null;
final info = infoList.first;
if (info is! Map) return null;
return VideoUploadInfo(
url: info['url'] as String? ?? '',
videoId: info['videoId'] as int? ?? 0,
token: info['token'] as String? ?? '',
);
}
/// Отправляет видеосообщение-кружок (`videoType: 1`) по [token], полученному
/// из [requestVideoNoteUploadUrl], после загрузки MP4-байтов на CDN.
///
/// [duration] — длительность в мс. [wave] — амплитуды аудиодорожки (бинарь,
/// 80 байт; нули допустимы). [thumbhash] — компактный хеш превью (опционально,
/// сервер всё равно отдаёт собственный `previewData`). Повторяет запрос на
/// `attachment.not.ready`, пока CDN обрабатывает загрузку.
Future<Map<String, dynamic>?> sendVideoNoteMessage(
int chatId,
String token, {
required int duration,
Uint8List? wave,
String? thumbhash,
bool notify = true,
int maxAttempts = 30,
Duration retryDelay = const Duration(seconds: 1),
}) async {
final message = <String, dynamic>{
'isLive': false,
'detectShare': false,
'elements': <dynamic>[],
'cid': DateTime.now().millisecondsSinceEpoch * -1,
'attaches': [
{
'duration': duration,
'videoType': 1,
'_type': 'VIDEO',
'wave': (wave != null && wave.isNotEmpty) ? wave : Uint8List(80),
'token': token,
if (thumbhash != null && thumbhash.isNotEmpty) 'thumbhash': thumbhash,
},
],
};
final payload = {'chatId': chatId, 'message': message, 'notify': notify};
for (var attempt = 0; attempt < maxAttempts; attempt++) {
try {
final response = await _api.sendRequest(Opcode.msgSend, payload);
if (!response.isOk) return null;
final data = response.payload;
if (data is Map) {
final msg = data['message'];
if (msg is Map) return Map<String, dynamic>.from(msg);
}
return null;
} on PacketError catch (e) {
if (!(e.errorKey?.contains('not.ready') ?? false)) {
logger.w('msgSend rejected: key=${e.errorKey} msg=${e.message}');
rethrow;
}
if (attempt == maxAttempts - 1) return null;
await Future.delayed(retryDelay);
}
@@ -0,0 +1,59 @@
import 'dart:io';
import 'package:flutter/services.dart';
import '../utils/logger.dart';
/// Нативная запись видео-кружка (Android, Camera2 + MediaRecorder): пишет
/// квадрат 480×480 сразу при съёмке — как официальный клиент. Превью отдаётся
/// через Flutter [Texture] по [textureId]. media3-перекод не используется
/// (серверный валидатор принимает только нативно записанный MP4).
class NativeVideoNoteRecorder {
static const _channel = MethodChannel('ru.komet.app/video_note');
int? textureId;
bool get isAvailable => Platform.isAndroid;
Future<bool> init({bool front = true}) async {
if (!isAvailable) return false;
try {
final res = await _channel.invokeMapMethod<String, dynamic>('init', {
'front': front,
});
textureId = res?['textureId'] as int?;
return textureId != null;
} catch (e) {
logger.w('NativeVideoNoteRecorder.init: $e');
return false;
}
}
Future<bool> start() async {
if (!isAvailable) return false;
try {
await _channel.invokeMethod('start');
return true;
} catch (e) {
logger.w('NativeVideoNoteRecorder.start: $e');
return false;
}
}
Future<String?> stop() async {
if (!isAvailable) return null;
try {
return await _channel.invokeMethod<String>('stop');
} catch (e) {
logger.w('NativeVideoNoteRecorder.stop: $e');
return null;
}
}
Future<void> dispose() async {
if (!isAvailable) return;
try {
await _channel.invokeMethod('dispose');
} catch (_) {}
textureId = null;
}
}
+248
View File
@@ -0,0 +1,248 @@
import 'dart:ffi';
import 'dart:io';
import 'dart:typed_data';
import 'package:opus_dart/opus_dart.dart';
import '../utils/logger.dart';
/// Кодирует PCM в Ogg/Opus через libopus (FFI) на платформах, где у системы нет
/// своего Opus-энкодера (Windows). Сырые Opus-пакеты выдаёт [opus_dart], а
/// Ogg-контейнер (страницы, лейсинг, CRC32, OpusHead/OpusTags) собирается здесь.
///
/// Формат совпадает с тем, что шлёт оригинальный клиент: моно, 48000 Hz,
/// pre-skip 312, vendor «libopus unknown».
class OpusOggEncoder {
static const int _sampleRate = 48000;
static const int _channels = 1;
static const int _preSkip = 312;
static const int _frameSamples = 960; // 20 мс @ 48 кГц
static const int _serial = 0x4b6f6d74; // 'Komt'
static const String _vendor = 'libopus unknown';
static bool _initialized = false;
static bool _available = false;
/// Лениво загружает libopus и инициализирует opus_dart: на Windows —
/// вендоренную `opus.dll` рядом с exe, на Android — через
/// `opus_flutter_android`. Возвращает `false`, если кодек недоступен.
static Future<bool> ensureAvailable() async {
if (_initialized) return _available;
_initialized = true;
try {
// libopus.so на Android бандлится плагином opus_flutter_android,
// opus.dll — вендоренная рядом с exe на Windows.
final String libName;
if (Platform.isWindows) {
libName = 'opus.dll';
} else if (Platform.isAndroid) {
libName = 'libopus.so';
} else {
return false;
}
initOpus(DynamicLibrary.open(libName) as dynamic);
_available = true;
} catch (e) {
logger.w('OpusOggEncoder: libopus недоступна: $e');
_available = false;
}
return _available;
}
/// Парсит WAV (16-bit PCM моно 48 кГц) и кодирует его в Ogg/Opus.
/// Возвращает `null`, если кодек недоступен или WAV не распознан.
static Future<Uint8List?> wavToOggOpus(Uint8List wav) async {
if (!await ensureAvailable()) return null;
final pcm = _pcmFromWav(wav);
if (pcm == null || pcm.isEmpty) return null;
try {
return _encodePcm(pcm);
} catch (e) {
logger.w('OpusOggEncoder: ошибка кодирования: $e');
return null;
}
}
static Uint8List _encodePcm(Int16List pcm) {
final encoder = SimpleOpusEncoder(
sampleRate: _sampleRate,
channels: _channels,
application: Application.audio,
);
final packets = <Uint8List>[];
try {
for (var off = 0; off < pcm.length; off += _frameSamples) {
final end = off + _frameSamples;
final Int16List frame;
if (end <= pcm.length) {
frame = Int16List.sublistView(pcm, off, end);
} else {
frame = Int16List(_frameSamples)..setRange(0, pcm.length - off, pcm, off);
}
packets.add(encoder.encode(input: frame));
}
} finally {
encoder.destroy();
}
return _buildOgg(packets, totalSamples: pcm.length);
}
static Uint8List _buildOgg(List<Uint8List> packets, {required int totalSamples}) {
final out = BytesBuilder();
var seq = 0;
out.add(_page(headerType: 0x02, granulePos: 0, seq: seq++, packets: [_opusHead()]));
out.add(_page(headerType: 0x00, granulePos: 0, seq: seq++, packets: [_opusTags()]));
var pagePackets = <Uint8List>[];
var pageSegments = 0;
var samples = 0;
void flush({required bool last}) {
final granule = last ? totalSamples + _preSkip : samples + _preSkip;
out.add(_page(
headerType: last ? 0x04 : 0x00,
granulePos: granule,
seq: seq++,
packets: pagePackets,
));
pagePackets = <Uint8List>[];
pageSegments = 0;
}
for (var i = 0; i < packets.length; i++) {
final p = packets[i];
final segs = (p.length ~/ 255) + 1;
if (pagePackets.isNotEmpty && pageSegments + segs > 255) {
flush(last: false);
}
pagePackets.add(p);
pageSegments += segs;
samples += _frameSamples;
}
flush(last: true);
return out.toBytes();
}
static Uint8List _opusHead() {
final b = BytesBuilder();
b.add(_ascii('OpusHead'));
final d = ByteData(11);
d.setUint8(0, 1); // version
d.setUint8(1, _channels);
d.setUint16(2, _preSkip, Endian.little);
d.setUint32(4, _sampleRate, Endian.little);
d.setUint16(8, 0, Endian.little); // output gain
d.setUint8(10, 0); // channel mapping family
b.add(d.buffer.asUint8List());
return b.toBytes();
}
static Uint8List _opusTags() {
final vendor = _ascii(_vendor);
final b = BytesBuilder();
b.add(_ascii('OpusTags'));
final len = ByteData(4)..setUint32(0, vendor.length, Endian.little);
b.add(len.buffer.asUint8List());
b.add(vendor);
final count = ByteData(4)..setUint32(0, 0, Endian.little);
b.add(count.buffer.asUint8List());
return b.toBytes();
}
static Uint8List _page({
required int headerType,
required int granulePos,
required int seq,
required List<Uint8List> packets,
}) {
final segs = <int>[];
for (final p in packets) {
var len = p.length;
while (len >= 255) {
segs.add(255);
len -= 255;
}
segs.add(len);
}
final header = Uint8List(27 + segs.length);
final hd = ByteData.sublistView(header);
header.setRange(0, 4, _ascii('OggS'));
hd.setUint8(4, 0); // stream structure version
hd.setUint8(5, headerType);
hd.setUint64(6, granulePos, Endian.little);
hd.setUint32(14, _serial, Endian.little);
hd.setUint32(18, seq, Endian.little);
hd.setUint32(22, 0, Endian.little); // CRC placeholder
hd.setUint8(26, segs.length);
header.setRange(27, 27 + segs.length, segs);
final body = BytesBuilder();
body.add(header);
for (final p in packets) {
body.add(p);
}
final page = body.toBytes();
final crc = _crc32(page);
ByteData.sublistView(page).setUint32(22, crc, Endian.little);
return page;
}
static Uint8List _ascii(String s) => Uint8List.fromList(s.codeUnits);
static final Uint32List _crcTable = _buildCrcTable();
static Uint32List _buildCrcTable() {
final t = Uint32List(256);
for (var i = 0; i < 256; i++) {
var r = (i << 24) & 0xffffffff;
for (var j = 0; j < 8; j++) {
if ((r & 0x80000000) != 0) {
r = ((r << 1) ^ 0x04c11db7) & 0xffffffff;
} else {
r = (r << 1) & 0xffffffff;
}
}
t[i] = r;
}
return t;
}
static int _crc32(Uint8List data) {
var crc = 0;
for (final b in data) {
crc = (((crc << 8) & 0xffffffff) ^ _crcTable[((crc >> 24) & 0xff) ^ b]) &
0xffffffff;
}
return crc & 0xffffffff;
}
static Int16List? _pcmFromWav(Uint8List bytes) {
if (bytes.length < 12) return null;
if (String.fromCharCodes(bytes, 0, 4) != 'RIFF' ||
String.fromCharCodes(bytes, 8, 12) != 'WAVE') {
return null;
}
final bd = ByteData.sublistView(bytes);
var off = 12;
while (off + 8 <= bytes.length) {
final id = String.fromCharCodes(bytes, off, off + 4);
final size = bd.getUint32(off + 4, Endian.little);
final body = off + 8;
if (id == 'data') {
final end = (body + size) <= bytes.length ? body + size : bytes.length;
final n = (end - body) ~/ 2;
final pcm = Int16List(n);
for (var i = 0; i < n; i++) {
pcm[i] = bd.getInt16(body + i * 2, Endian.little);
}
return pcm;
}
off = body + size + (size & 1);
}
return null;
}
}
+31
View File
@@ -0,0 +1,31 @@
import 'dart:io';
import 'package:flutter/services.dart';
import '../utils/logger.dart';
/// Центр-кроп записанного видео в квадрат для видеосообщений-кружков.
/// На Android выполняется нативно (media3 Transformer, без искажений —
/// заполняет квадрат и обрезает лишнее по бокам). На других платформах
/// возвращает `null` (кружки там не записываются).
class VideoNoteCropper {
static const _channel = MethodChannel('ru.komet.app/video');
static Future<String?> cropSquare(String input, {int size = 480}) async {
if (!Platform.isAndroid) return null;
try {
final dot = input.lastIndexOf('.');
final base = dot > 0 ? input.substring(0, dot) : input;
final output = '${base}_sq.mp4';
final res = await _channel.invokeMethod<String>('cropSquare', {
'input': input,
'output': output,
'size': size,
});
return res;
} catch (e) {
logger.w('VideoNoteCropper: $e');
return null;
}
}
}
File diff suppressed because it is too large Load Diff
+397 -55
View File
@@ -1,6 +1,10 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:ogg_opus_player/ogg_opus_player.dart';
import 'package:video_player/video_player.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
@@ -14,6 +18,7 @@ import '../../core/utils/bubble_radius.dart';
import '../../core/utils/format.dart';
import '../../core/utils/haptics.dart';
import '../../core/utils/file_download.dart';
import '../../core/utils/media_cache.dart';
import '../../core/utils/download_progress.dart';
import '../../core/utils/link_opener.dart';
import '../../core/config/app_link_preview.dart';
@@ -165,6 +170,13 @@ class MessageBubble extends StatelessWidget {
return a != null && a.isNotEmpty && a.first is ShareAttachment;
}
bool get _isVideoNote {
final a = message.attachments;
if (a == null || a.isEmpty) return false;
final first = a.first;
return first is VideoAttachment && first.isNote;
}
MessageType get _contentType {
if (_hasShareAttachment) return _computeContentType();
return _contentTypeCache[message] ??= _computeContentType();
@@ -421,7 +433,10 @@ class MessageBubble extends StatelessWidget {
prevMessage?.senderId != message.senderId;
final maxBubbleWidth = MediaQuery.sizeOf(context).width * 0.75;
final bubbleColor = isMe ? cs.primaryContainer : cs.surfaceContainerHighest;
final isVideoNote = _isVideoNote;
final bubbleColor = isVideoNote
? Colors.transparent
: (isMe ? cs.primaryContainer : cs.surfaceContainerHighest);
_BubbleCtx makeCtx() => _BubbleCtx(
context: context,
@@ -507,13 +522,15 @@ class MessageBubble extends StatelessWidget {
constraints: BoxConstraints(maxWidth: maxBubbleWidth),
decoration: BoxDecoration(
color: bubbleColor,
borderRadius: _borderRadiusFor(
AppBubbleShape.current.value,
AppBubbleBehavior.current.value,
shape,
hasPhotoCap,
hasMultiPhotos,
),
borderRadius: isVideoNote
? null
: _borderRadiusFor(
AppBubbleShape.current.value,
AppBubbleBehavior.current.value,
shape,
hasPhotoCap,
hasMultiPhotos,
),
),
padding: padding,
child: child,
@@ -1819,6 +1836,21 @@ class MessageBubble extends StatelessWidget {
}
Widget _buildVideoAttachment(_BubbleCtx ctx, MessageAttachment video) {
if (video is VideoAttachment && video.isNote) {
return Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
_VideoNoteBubble(
attachment: video,
messageId: message.id,
chatId: message.chatId,
cs: ctx.cs,
),
const SizedBox(height: 6),
_buildMeta(ctx),
],
);
}
final hasCaption = message.text != null && message.text!.isNotEmpty;
final thumb = (video as dynamic).thumbnail as String?;
final durationMs = (video as dynamic).duration as int?;
@@ -2545,6 +2577,16 @@ class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> {
String? _transcriptionText;
bool _transcriptionLoading = false;
OggOpusPlayer? _player;
bool _loadingAudio = false;
Timer? _ticker;
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();
@@ -2553,10 +2595,73 @@ class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> {
@override
void dispose() {
_ticker?.cancel();
_player?.state.removeListener(_onPlayerState);
_player?.dispose();
_progress.dispose();
super.dispose();
}
Future<void> _togglePlay() async {
if (_loadingAudio) return;
if (_player != null) {
if (_isPlaying) {
_player!.pause();
} else {
if (widget.duration > 0 &&
_player!.currentPosition >= widget.duration - 0.05) {
_progress.value = 0;
}
_player!.play();
}
return;
}
final url = widget.url;
if (url.isEmpty) return;
setState(() => _loadingAudio = true);
try {
final name = '${widget.audioId ?? widget.messageId}.ogg';
final file = await MediaCache.getOrDownload(name, url);
if (!mounted) return;
if (file == null) {
showCustomNotification(context, 'Не удалось загрузить аудио');
return;
}
final player = OggOpusPlayer(file.path);
_player = player;
player.state.addListener(_onPlayerState);
_ticker = Timer.periodic(
const Duration(milliseconds: 60),
(_) => _onTick(),
);
player.play();
} catch (e) {
if (mounted) showCustomNotification(context, 'Ошибка воспроизведения');
} finally {
if (mounted) setState(() => _loadingAudio = false);
}
}
void _onTick() {
final player = _player;
if (player == null || widget.duration <= 0) return;
final pos = player.currentPosition;
_progress.value = (pos / widget.duration).clamp(0.0, 1.0);
}
void _onPlayerState() {
final state = _player?.state.value;
if (!mounted) return;
final playing = state == PlayerState.playing;
if (playing != _isPlaying) setState(() => _isPlaying = playing);
if (state == PlayerState.ended) {
_progress.value = 1.0;
}
}
Widget _buildStatusIcon() {
final status = widget.status;
IconData icon;
@@ -2621,53 +2726,41 @@ class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> {
: widget.cs.primaryContainer,
shape: BoxShape.circle,
),
child: Icon(
_isPlaying ? Symbols.pause : Symbols.play_arrow,
color: widget.isMe
? widget.cs.onPrimaryContainer
: widget.cs.primary,
size: 18,
),
child: _loadingAudio
? Padding(
padding: const EdgeInsets.all(8),
child: CircularProgressIndicator(
strokeWidth: 2,
color: widget.isMe
? widget.cs.onPrimaryContainer
: widget.cs.primary,
),
)
: Icon(
_isPlaying ? Symbols.pause : Symbols.play_arrow,
color: widget.isMe
? widget.cs.onPrimaryContainer
: widget.cs.primary,
size: 18,
),
),
),
const SizedBox(width: 10),
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
return GestureDetector(
onTapDown: (details) {
_progress.value =
(details.localPosition.dx / constraints.maxWidth)
.clamp(0.0, 1.0);
},
onHorizontalDragUpdate: (details) {
_progress.value =
(details.localPosition.dx / constraints.maxWidth)
.clamp(0.0, 1.0);
},
child: Container(
height: 4,
decoration: BoxDecoration(
color: waveInactiveColor,
borderRadius: BorderRadius.circular(2),
),
child: ValueListenableBuilder<double>(
valueListenable: _progress,
builder: (context, progress, _) =>
FractionallySizedBox(
alignment: Alignment.centerLeft,
widthFactor: progress.clamp(0.0, 1.0),
child: Container(
decoration: BoxDecoration(
color: waveActiveColor,
borderRadius: BorderRadius.circular(2),
),
),
),
),
child: SizedBox(
height: 26,
child: ValueListenableBuilder<double>(
valueListenable: _progress,
builder: (context, progress, _) => CustomPaint(
size: Size.infinite,
painter: _WaveformPainter(
amps: _amps,
progress: progress,
active: waveActiveColor,
inactive: waveInactiveColor,
),
);
},
),
),
),
),
const SizedBox(width: 8),
@@ -2795,11 +2888,6 @@ class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> {
);
}
void _togglePlay() {
setState(() {
_isPlaying = !_isPlaying;
});
}
Future<void> _requestTranscription() async {
if (widget.audioId == null) return;
@@ -2856,3 +2944,257 @@ class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> {
}
}
}
class _WaveformPainter extends CustomPainter {
final List<int> amps;
final double progress;
final Color active;
final Color inactive;
const _WaveformPainter({
required this.amps,
required this.progress,
required this.active,
required this.inactive,
});
@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,
);
}
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,
);
}
}
@override
bool shouldRepaint(_WaveformPainter old) =>
old.progress != progress ||
old.active != active ||
old.inactive != inactive ||
!identical(old.amps, amps);
}
class _VideoNoteBubble extends StatefulWidget {
final VideoAttachment attachment;
final String messageId;
final int chatId;
final ColorScheme cs;
const _VideoNoteBubble({
required this.attachment,
required this.messageId,
required this.chatId,
required this.cs,
});
@override
State<_VideoNoteBubble> createState() => _VideoNoteBubbleState();
}
class _VideoNoteBubbleState extends State<_VideoNoteBubble> {
static const double _size = 210;
VideoPlayerController? _controller;
bool _loading = false;
bool _error = false;
@override
void dispose() {
_controller?.removeListener(_onTick);
_controller?.dispose();
super.dispose();
}
void _onTick() {
if (mounted) setState(() {});
}
static Uint8List? _previewBytes(String? data) {
if (data == null) return null;
const marker = 'base64,';
final idx = data.indexOf(marker);
if (idx < 0) return null;
try {
return base64Decode(data.substring(idx + marker.length));
} catch (_) {
return null;
}
}
Future<void> _toggle() async {
final existing = _controller;
if (existing != null) {
setState(
() => existing.value.isPlaying ? existing.pause() : existing.play(),
);
return;
}
if (_loading) return;
final a = widget.attachment;
final videoId = a.videoId;
final token = a.videoToken;
if (videoId == null || token == null) {
setState(() => _error = true);
return;
}
setState(() => _loading = true);
Haptics.tap();
try {
final cacheName = 'videonote_$videoId.mp4';
var file = await MediaCache.existing(cacheName);
if (file == null) {
final url = await messagesModule.getVideoUrl(
messageId: widget.messageId,
chatId: widget.chatId,
token: token,
videoId: videoId,
);
if (url == null) throw Exception('no_url');
file = await MediaCache.getOrDownload(cacheName, url);
if (file == null) throw Exception('download');
}
if (!mounted) return;
final c = VideoPlayerController.file(file);
_controller = c;
await c.initialize();
if (!mounted) {
c.dispose();
return;
}
await c.setLooping(true);
c.addListener(_onTick);
c.play();
setState(() => _loading = false);
} catch (_) {
if (mounted) {
setState(() {
_loading = false;
_error = true;
});
}
}
}
@override
Widget build(BuildContext context) {
final a = widget.attachment;
final c = _controller;
final ready = c != null && c.value.isInitialized;
final playing = ready && c.value.isPlaying;
final preview = _previewBytes(a.previewData);
double progress = 0;
if (ready && c.value.duration.inMilliseconds > 0) {
progress =
c.value.position.inMilliseconds / c.value.duration.inMilliseconds;
}
return GestureDetector(
onTap: _toggle,
child: SizedBox(
width: _size,
height: _size,
child: Stack(
alignment: Alignment.center,
children: [
ClipOval(
child: SizedBox(
width: _size,
height: _size,
child: ready
? FittedBox(
fit: BoxFit.cover,
clipBehavior: Clip.hardEdge,
child: SizedBox(
width: c.value.size.width,
height: c.value.size.height,
child: VideoPlayer(c),
),
)
: preview != null
? Image.memory(
preview,
fit: BoxFit.cover,
gaplessPlayback: true,
)
: Container(color: widget.cs.surfaceContainerHighest),
),
),
if (ready)
SizedBox(
width: _size - 2,
height: _size - 2,
child: CircularProgressIndicator(
value: progress.clamp(0.0, 1.0),
strokeWidth: 3,
color: widget.cs.primary,
backgroundColor: Colors.white24,
),
),
if (!playing)
Container(
width: 52,
height: 52,
decoration: const BoxDecoration(
color: Colors.black45,
shape: BoxShape.circle,
),
child: _loading
? const Padding(
padding: EdgeInsets.all(14),
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: Icon(
_error ? Symbols.error : Symbols.play_arrow,
color: Colors.white,
size: 30,
),
),
],
),
),
);
}
}
+4
View File
@@ -4,6 +4,7 @@ import 'dart:ui' as ui;
import 'package:dynamic_color/dynamic_color.dart';
import 'package:flutter/material.dart';
import 'package:fvp/fvp.dart' as fvp;
import 'package:flutter/rendering.dart';
import 'package:komet/l10n/app_localizations.dart';
import 'package:m3e_collection/m3e_collection.dart';
@@ -86,6 +87,9 @@ Future<Locale> _loadInitialLocale() async {
void main() async {
WidgetsFlutterBinding.ensureInitialized();
fvp.registerWith(options: {
'platforms': ['windows', 'linux', 'macos'],
});
if (AppInstance.isNamed) {
SharedPreferences.setPrefix('flutter.${AppInstance.id}.');
}
+8
View File
@@ -127,6 +127,11 @@ class VideoAttachment extends MessageAttachment {
final int? duration;
final int? size;
/// 0 — обычное видео, 1 — видеосообщение-кружок.
final int? videoType;
bool get isNote => videoType == 1;
const VideoAttachment({
super.previewData,
super.baseUrl,
@@ -138,6 +143,7 @@ class VideoAttachment extends MessageAttachment {
this.height,
this.duration,
this.size,
this.videoType,
}) : super(type: AttachmentType.video);
factory VideoAttachment.fromMap(Map<String, dynamic> map) {
@@ -151,6 +157,7 @@ class VideoAttachment extends MessageAttachment {
height: map['height'] as int?,
duration: map['duration'] as int?,
size: map['size'] as int?,
videoType: map['videoType'] as int?,
);
}
@@ -166,6 +173,7 @@ class VideoAttachment extends MessageAttachment {
'height': height,
'duration': duration,
'size': size,
'videoType': videoType,
};
}