Merge branch 'feature/voice-video-notes' into feature/FullStack
This commit is contained in:
@@ -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')
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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,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}.');
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user