feat(media): голосовые сообщения и видеокружки

- голосовые: запись/отправка/воспроизведение, Ogg/Opus (libopus FFI на Windows/Android), волна, анимации, lock + slide-to-cancel
- видеокружки: нативная квадратная запись 480x480 (Camera2 + GL), аудио mono 48k, снятие video edit-list для прохождения серверного валидатора
- file_uploader: единый uploadMediaFile (аудио+видео), чтение полного ответа CDN
- проигрывание видео и кружков в message_bubble
This commit is contained in:
klockky
2026-06-25 12:53:11 +03:00
parent c4afc94a7d
commit 37e3763384
20 changed files with 3021 additions and 79 deletions
+65 -1
View File
@@ -155,6 +155,69 @@ 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}',
);
return statusCode == 200;
} 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 +238,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 +246,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);
}