feat: норм кружки и гс
Голосовые: индекс opus/ogg со срезами страниц, общий аудиоконтроллер воспроизведения, переписанный ogg-энкодер. Кружки: предзагрузка видео, геометрия кольца прогресса. third_party/kolibri в .gitignore — локальный dev-клон, зависимость берётся с pub.dev. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VMWkmNx3Ns9SEuKeGEiaR4
This commit is contained in:
co-authored by
Claude Opus 5
parent
79c743e86a
commit
b9d69ea37b
@@ -153,3 +153,4 @@ test/live_server_probe_test.dart
|
|||||||
# Rust core at third_party/kolibri/kolibri-net instead of the published ones
|
# Rust core at third_party/kolibri/kolibri-net instead of the published ones
|
||||||
pubspec_overrides.yaml
|
pubspec_overrides.yaml
|
||||||
.cargo/
|
.cargo/
|
||||||
|
third_party/kolibri/
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
class OggPageWriter {
|
||||||
|
static const int maxSegmentsPerPage = 255;
|
||||||
|
static const int continuedPacket = 0x01;
|
||||||
|
static const int beginningOfStream = 0x02;
|
||||||
|
static const int endOfStream = 0x04;
|
||||||
|
|
||||||
|
static const int headerSize = 27;
|
||||||
|
static const int _granuleOffset = 6;
|
||||||
|
static const int _serialOffset = 14;
|
||||||
|
static const int _sequenceOffset = 18;
|
||||||
|
static const int _crcOffset = 22;
|
||||||
|
static const int _segmentCountOffset = 26;
|
||||||
|
|
||||||
|
static int segmentsFor(int length) => (length ~/ 255) + 1;
|
||||||
|
|
||||||
|
static int lengthFor(List<Uint8List> packets) {
|
||||||
|
var segments = 0;
|
||||||
|
var body = 0;
|
||||||
|
for (final packet in packets) {
|
||||||
|
segments += segmentsFor(packet.length);
|
||||||
|
body += packet.length;
|
||||||
|
}
|
||||||
|
return headerSize + segments + body;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Uint8List page({
|
||||||
|
required int headerType,
|
||||||
|
required int granulePos,
|
||||||
|
required int serial,
|
||||||
|
required int sequence,
|
||||||
|
required List<Uint8List> packets,
|
||||||
|
}) {
|
||||||
|
final out = Uint8List(lengthFor(packets));
|
||||||
|
writeInto(
|
||||||
|
out,
|
||||||
|
0,
|
||||||
|
headerType: headerType,
|
||||||
|
granulePos: granulePos,
|
||||||
|
serial: serial,
|
||||||
|
sequence: sequence,
|
||||||
|
packets: packets,
|
||||||
|
);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int writeInto(
|
||||||
|
Uint8List out,
|
||||||
|
int offset, {
|
||||||
|
required int headerType,
|
||||||
|
required int granulePos,
|
||||||
|
required int serial,
|
||||||
|
required int sequence,
|
||||||
|
required List<Uint8List> packets,
|
||||||
|
}) {
|
||||||
|
final view = ByteData.sublistView(out);
|
||||||
|
out[offset] = 0x4f;
|
||||||
|
out[offset + 1] = 0x67;
|
||||||
|
out[offset + 2] = 0x67;
|
||||||
|
out[offset + 3] = 0x53;
|
||||||
|
view.setUint8(offset + 4, 0);
|
||||||
|
view.setUint8(offset + 5, headerType);
|
||||||
|
view.setInt64(offset + _granuleOffset, granulePos, Endian.little);
|
||||||
|
view.setUint32(offset + _serialOffset, serial, Endian.little);
|
||||||
|
view.setUint32(offset + _sequenceOffset, sequence, Endian.little);
|
||||||
|
view.setUint32(offset + _crcOffset, 0, Endian.little);
|
||||||
|
|
||||||
|
var table = offset + headerSize;
|
||||||
|
for (final packet in packets) {
|
||||||
|
var remaining = packet.length;
|
||||||
|
while (remaining >= 255) {
|
||||||
|
out[table++] = 255;
|
||||||
|
remaining -= 255;
|
||||||
|
}
|
||||||
|
out[table++] = remaining;
|
||||||
|
}
|
||||||
|
view.setUint8(offset + _segmentCountOffset, table - offset - headerSize);
|
||||||
|
|
||||||
|
var cursor = table;
|
||||||
|
for (final packet in packets) {
|
||||||
|
out.setRange(cursor, cursor + packet.length, packet);
|
||||||
|
cursor += packet.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
view.setUint32(
|
||||||
|
offset + _crcOffset,
|
||||||
|
crc32(out, offset, cursor),
|
||||||
|
Endian.little,
|
||||||
|
);
|
||||||
|
return cursor;
|
||||||
|
}
|
||||||
|
|
||||||
|
static final List<Uint32List> _crcTables = _buildCrcTables();
|
||||||
|
|
||||||
|
static List<Uint32List> _buildCrcTables() {
|
||||||
|
final base = 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
base[i] = r;
|
||||||
|
}
|
||||||
|
|
||||||
|
final tables = <Uint32List>[base];
|
||||||
|
for (var slice = 1; slice < 4; slice++) {
|
||||||
|
final previous = tables[slice - 1];
|
||||||
|
final next = Uint32List(256);
|
||||||
|
for (var i = 0; i < 256; i++) {
|
||||||
|
next[i] =
|
||||||
|
(((previous[i] << 8) & 0xffffffff) ^
|
||||||
|
base[(previous[i] >> 24) & 0xff]) &
|
||||||
|
0xffffffff;
|
||||||
|
}
|
||||||
|
tables.add(next);
|
||||||
|
}
|
||||||
|
return tables;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int crc32(Uint8List data, [int start = 0, int? end]) {
|
||||||
|
final stop = end ?? data.length;
|
||||||
|
final t0 = _crcTables[0];
|
||||||
|
final t1 = _crcTables[1];
|
||||||
|
final t2 = _crcTables[2];
|
||||||
|
final t3 = _crcTables[3];
|
||||||
|
|
||||||
|
var crc = 0;
|
||||||
|
var i = start;
|
||||||
|
final wordEnd = stop - ((stop - start) & 3);
|
||||||
|
while (i < wordEnd) {
|
||||||
|
crc ^=
|
||||||
|
(data[i] << 24) |
|
||||||
|
(data[i + 1] << 16) |
|
||||||
|
(data[i + 2] << 8) |
|
||||||
|
data[i + 3];
|
||||||
|
crc =
|
||||||
|
t3[(crc >> 24) & 0xff] ^
|
||||||
|
t2[(crc >> 16) & 0xff] ^
|
||||||
|
t1[(crc >> 8) & 0xff] ^
|
||||||
|
t0[crc & 0xff];
|
||||||
|
i += 4;
|
||||||
|
}
|
||||||
|
while (i < stop) {
|
||||||
|
crc =
|
||||||
|
(((crc << 8) & 0xffffffff) ^ t0[((crc >> 24) & 0xff) ^ data[i]]) &
|
||||||
|
0xffffffff;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
return crc & 0xffffffff;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import 'dart:typed_data';
|
|||||||
import 'package:opus_dart/opus_dart.dart';
|
import 'package:opus_dart/opus_dart.dart';
|
||||||
|
|
||||||
import '../utils/logger.dart';
|
import '../utils/logger.dart';
|
||||||
|
import 'ogg_page_writer.dart';
|
||||||
|
|
||||||
/// Кодирует PCM в Ogg/Opus через libopus (FFI) на платформах, где у системы нет
|
/// Кодирует PCM в Ogg/Opus через libopus (FFI) на платформах, где у системы нет
|
||||||
/// своего Opus-энкодера (Windows). Сырые Opus-пакеты выдаёт [opus_dart], а
|
/// своего Opus-энкодера (Windows). Сырые Opus-пакеты выдаёт [opus_dart], а
|
||||||
@@ -176,71 +177,16 @@ class OpusOggEncoder {
|
|||||||
required int granulePos,
|
required int granulePos,
|
||||||
required int seq,
|
required int seq,
|
||||||
required List<Uint8List> packets,
|
required List<Uint8List> packets,
|
||||||
}) {
|
}) => OggPageWriter.page(
|
||||||
final segs = <int>[];
|
headerType: headerType,
|
||||||
for (final p in packets) {
|
granulePos: granulePos,
|
||||||
var len = p.length;
|
serial: _serial,
|
||||||
while (len >= 255) {
|
sequence: seq,
|
||||||
segs.add(255);
|
packets: packets,
|
||||||
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 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) {
|
static Int16List? _pcmFromWav(Uint8List bytes) {
|
||||||
if (bytes.length < 12) return null;
|
if (bytes.length < 12) return null;
|
||||||
if (String.fromCharCodes(bytes, 0, 4) != 'RIFF' ||
|
if (String.fromCharCodes(bytes, 0, 4) != 'RIFF' ||
|
||||||
|
|||||||
@@ -0,0 +1,362 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'ogg_page_writer.dart';
|
||||||
|
|
||||||
|
class OpusOggIndex {
|
||||||
|
static const int sampleRate = 48000;
|
||||||
|
|
||||||
|
static const int _prerollSamples = 3840;
|
||||||
|
static const int _maxPreSkip = 65535;
|
||||||
|
static const int _maxPacketSamples = 5760;
|
||||||
|
static const int _preSkipOffset = 10;
|
||||||
|
static const int _opusHeadMinLength = 19;
|
||||||
|
static const int _pageHeaderSize = 27;
|
||||||
|
|
||||||
|
OpusOggIndex._({
|
||||||
|
required Uint8List head,
|
||||||
|
required Uint8List tags,
|
||||||
|
required List<Uint8List> packets,
|
||||||
|
required List<int> packetStarts,
|
||||||
|
required int preSkip,
|
||||||
|
required int serial,
|
||||||
|
required int endGranule,
|
||||||
|
}) : _head = head,
|
||||||
|
_tags = tags,
|
||||||
|
_packets = packets,
|
||||||
|
_packetStarts = packetStarts,
|
||||||
|
_preSkip = preSkip,
|
||||||
|
_serial = serial,
|
||||||
|
_endGranule = endGranule;
|
||||||
|
|
||||||
|
final Uint8List _head;
|
||||||
|
final Uint8List _tags;
|
||||||
|
final List<Uint8List> _packets;
|
||||||
|
final List<int> _packetStarts;
|
||||||
|
final int _preSkip;
|
||||||
|
final int _serial;
|
||||||
|
final int _endGranule;
|
||||||
|
|
||||||
|
double get duration {
|
||||||
|
final playable = _endGranule - _preSkip;
|
||||||
|
return playable <= 0 ? 0 : playable / sampleRate;
|
||||||
|
}
|
||||||
|
|
||||||
|
static OpusOggIndex? parse(Uint8List bytes) {
|
||||||
|
Uint8List? head;
|
||||||
|
Uint8List? tags;
|
||||||
|
int? serial;
|
||||||
|
var lastGranule = 0;
|
||||||
|
final packets = <Uint8List>[];
|
||||||
|
final pendingParts = <Uint8List>[];
|
||||||
|
var pendingStart = -1;
|
||||||
|
var pendingLength = 0;
|
||||||
|
var pendingContiguous = true;
|
||||||
|
var offset = 0;
|
||||||
|
|
||||||
|
while (offset + _pageHeaderSize <= bytes.length) {
|
||||||
|
if (!_hasCapture(bytes, offset)) {
|
||||||
|
final resync = _findCapture(bytes, offset + 1);
|
||||||
|
if (resync < 0) break;
|
||||||
|
offset = resync;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
final view = ByteData.sublistView(bytes, offset);
|
||||||
|
final headerType = bytes[offset + 5];
|
||||||
|
final pageSerial = view.getUint32(14, Endian.little);
|
||||||
|
final segmentCount = bytes[offset + 26];
|
||||||
|
final tableStart = offset + _pageHeaderSize;
|
||||||
|
final bodyStart = tableStart + segmentCount;
|
||||||
|
if (bodyStart > bytes.length) break;
|
||||||
|
|
||||||
|
var bodyLength = 0;
|
||||||
|
for (var i = 0; i < segmentCount; i++) {
|
||||||
|
bodyLength += bytes[tableStart + i];
|
||||||
|
}
|
||||||
|
final bodyEnd = bodyStart + bodyLength;
|
||||||
|
if (bodyEnd > bytes.length) break;
|
||||||
|
|
||||||
|
serial ??= pageSerial;
|
||||||
|
if (pageSerial != serial) {
|
||||||
|
offset = bodyEnd;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
final granule = view.getInt64(6, Endian.little);
|
||||||
|
if (granule > lastGranule) lastGranule = granule;
|
||||||
|
|
||||||
|
if ((headerType & OggPageWriter.continuedPacket) == 0) {
|
||||||
|
pendingParts.clear();
|
||||||
|
pendingStart = -1;
|
||||||
|
pendingLength = 0;
|
||||||
|
pendingContiguous = true;
|
||||||
|
} else if (pendingLength > 0 && pendingContiguous) {
|
||||||
|
pendingParts.add(
|
||||||
|
Uint8List.sublistView(bytes, pendingStart, pendingStart + pendingLength),
|
||||||
|
);
|
||||||
|
pendingContiguous = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var cursor = bodyStart;
|
||||||
|
for (var i = 0; i < segmentCount; i++) {
|
||||||
|
final length = bytes[tableStart + i];
|
||||||
|
if (length > 0) {
|
||||||
|
if (pendingContiguous) {
|
||||||
|
if (pendingLength == 0) pendingStart = cursor;
|
||||||
|
} else {
|
||||||
|
pendingParts.add(
|
||||||
|
Uint8List.sublistView(bytes, cursor, cursor + length),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
pendingLength += length;
|
||||||
|
}
|
||||||
|
cursor += length;
|
||||||
|
if (length == 255) continue;
|
||||||
|
|
||||||
|
final Uint8List packet;
|
||||||
|
if (pendingContiguous) {
|
||||||
|
packet = pendingLength == 0
|
||||||
|
? _empty
|
||||||
|
: Uint8List.sublistView(
|
||||||
|
bytes,
|
||||||
|
pendingStart,
|
||||||
|
pendingStart + pendingLength,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
packet = _join(pendingParts);
|
||||||
|
}
|
||||||
|
pendingParts.clear();
|
||||||
|
pendingStart = -1;
|
||||||
|
pendingLength = 0;
|
||||||
|
pendingContiguous = true;
|
||||||
|
if (packet.isEmpty) continue;
|
||||||
|
if (head == null) {
|
||||||
|
if (!_startsWith(packet, 'OpusHead')) return null;
|
||||||
|
head = packet;
|
||||||
|
} else if (tags == null) {
|
||||||
|
tags = packet;
|
||||||
|
} else {
|
||||||
|
packets.add(packet);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
offset = bodyEnd;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (head == null || tags == null || packets.isEmpty || serial == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (head.length < _opusHeadMinLength) return null;
|
||||||
|
|
||||||
|
final starts = <int>[];
|
||||||
|
var total = 0;
|
||||||
|
for (final packet in packets) {
|
||||||
|
final samples = _packetDuration(packet);
|
||||||
|
if (samples <= 0) return null;
|
||||||
|
starts.add(total);
|
||||||
|
total += samples;
|
||||||
|
}
|
||||||
|
|
||||||
|
final preSkip = ByteData.sublistView(
|
||||||
|
head,
|
||||||
|
).getUint16(_preSkipOffset, Endian.little);
|
||||||
|
if (preSkip >= total) return null;
|
||||||
|
|
||||||
|
final endGranule = lastGranule > preSkip && lastGranule <= total
|
||||||
|
? lastGranule
|
||||||
|
: total;
|
||||||
|
|
||||||
|
return OpusOggIndex._(
|
||||||
|
head: head,
|
||||||
|
tags: tags,
|
||||||
|
packets: packets,
|
||||||
|
packetStarts: starts,
|
||||||
|
preSkip: preSkip,
|
||||||
|
serial: serial,
|
||||||
|
endGranule: endGranule,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Uint8List? sliceFrom(double seconds) {
|
||||||
|
if (seconds <= 0) return null;
|
||||||
|
final target = (seconds * sampleRate).round() + _preSkip;
|
||||||
|
if (target >= _endGranule) return null;
|
||||||
|
|
||||||
|
final floor = target - _prerollSamples;
|
||||||
|
var first = 0;
|
||||||
|
for (var i = 0; i < _packetStarts.length; i++) {
|
||||||
|
if (_packetStarts[i] > floor) break;
|
||||||
|
first = i;
|
||||||
|
}
|
||||||
|
|
||||||
|
final base = _packetStarts[first];
|
||||||
|
final preSkip = target - base;
|
||||||
|
if (preSkip < 0 || preSkip > _maxPreSkip) return null;
|
||||||
|
|
||||||
|
final head = _headWithPreSkip(preSkip);
|
||||||
|
final plans = <_PagePlan>[];
|
||||||
|
var pageStart = first;
|
||||||
|
var pageSegments = 0;
|
||||||
|
var pageBytes = 0;
|
||||||
|
|
||||||
|
for (var i = first; i < _packets.length; i++) {
|
||||||
|
final packet = _packets[i];
|
||||||
|
final segments = OggPageWriter.segmentsFor(packet.length);
|
||||||
|
if (segments > OggPageWriter.maxSegmentsPerPage) return null;
|
||||||
|
if (i > pageStart &&
|
||||||
|
pageSegments + segments > OggPageWriter.maxSegmentsPerPage) {
|
||||||
|
plans.add(
|
||||||
|
_PagePlan(
|
||||||
|
start: pageStart,
|
||||||
|
end: i,
|
||||||
|
granulePos: _packetStarts[i] - base,
|
||||||
|
bytes: OggPageWriter.headerSize + pageSegments + pageBytes,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
pageStart = i;
|
||||||
|
pageSegments = 0;
|
||||||
|
pageBytes = 0;
|
||||||
|
}
|
||||||
|
pageSegments += segments;
|
||||||
|
pageBytes += packet.length;
|
||||||
|
}
|
||||||
|
plans.add(
|
||||||
|
_PagePlan(
|
||||||
|
start: pageStart,
|
||||||
|
end: _packets.length,
|
||||||
|
granulePos: _endGranule - base,
|
||||||
|
bytes: OggPageWriter.headerSize + pageSegments + pageBytes,
|
||||||
|
last: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
var total =
|
||||||
|
OggPageWriter.lengthFor([head]) + OggPageWriter.lengthFor([_tags]);
|
||||||
|
for (final plan in plans) {
|
||||||
|
total += plan.bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
final out = Uint8List(total);
|
||||||
|
var sequence = 0;
|
||||||
|
var offset = OggPageWriter.writeInto(
|
||||||
|
out,
|
||||||
|
0,
|
||||||
|
headerType: OggPageWriter.beginningOfStream,
|
||||||
|
granulePos: 0,
|
||||||
|
serial: _serial,
|
||||||
|
sequence: sequence++,
|
||||||
|
packets: [head],
|
||||||
|
);
|
||||||
|
offset = OggPageWriter.writeInto(
|
||||||
|
out,
|
||||||
|
offset,
|
||||||
|
headerType: 0,
|
||||||
|
granulePos: 0,
|
||||||
|
serial: _serial,
|
||||||
|
sequence: sequence++,
|
||||||
|
packets: [_tags],
|
||||||
|
);
|
||||||
|
for (final plan in plans) {
|
||||||
|
offset = OggPageWriter.writeInto(
|
||||||
|
out,
|
||||||
|
offset,
|
||||||
|
headerType: plan.last ? OggPageWriter.endOfStream : 0,
|
||||||
|
granulePos: plan.granulePos,
|
||||||
|
serial: _serial,
|
||||||
|
sequence: sequence++,
|
||||||
|
packets: _packets.sublist(plan.start, plan.end),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
Uint8List _headWithPreSkip(int preSkip) {
|
||||||
|
final head = Uint8List.fromList(_head);
|
||||||
|
ByteData.sublistView(
|
||||||
|
head,
|
||||||
|
).setUint16(_preSkipOffset, preSkip, Endian.little);
|
||||||
|
return head;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool _hasCapture(Uint8List bytes, int offset) =>
|
||||||
|
bytes[offset] == 0x4f &&
|
||||||
|
bytes[offset + 1] == 0x67 &&
|
||||||
|
bytes[offset + 2] == 0x67 &&
|
||||||
|
bytes[offset + 3] == 0x53;
|
||||||
|
|
||||||
|
static int _findCapture(Uint8List bytes, int from) {
|
||||||
|
for (var i = from; i + 4 <= bytes.length; i++) {
|
||||||
|
if (_hasCapture(bytes, i)) return i;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
static final Uint8List _empty = Uint8List(0);
|
||||||
|
|
||||||
|
static Uint8List _join(List<Uint8List> parts) {
|
||||||
|
if (parts.isEmpty) return _empty;
|
||||||
|
if (parts.length == 1) return parts.first;
|
||||||
|
var length = 0;
|
||||||
|
for (final part in parts) {
|
||||||
|
length += part.length;
|
||||||
|
}
|
||||||
|
final out = Uint8List(length);
|
||||||
|
var offset = 0;
|
||||||
|
for (final part in parts) {
|
||||||
|
out.setRange(offset, offset + part.length, part);
|
||||||
|
offset += part.length;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool _startsWith(Uint8List bytes, String magic) {
|
||||||
|
if (bytes.length < magic.length) return false;
|
||||||
|
for (var i = 0; i < magic.length; i++) {
|
||||||
|
if (bytes[i] != magic.codeUnitAt(i)) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int _packetDuration(Uint8List packet) {
|
||||||
|
if (packet.isEmpty) return 0;
|
||||||
|
final toc = packet[0];
|
||||||
|
final frameSamples = _frameSamples(toc >> 3);
|
||||||
|
final int frames;
|
||||||
|
switch (toc & 0x03) {
|
||||||
|
case 0:
|
||||||
|
frames = 1;
|
||||||
|
case 1:
|
||||||
|
case 2:
|
||||||
|
frames = 2;
|
||||||
|
default:
|
||||||
|
if (packet.length < 2) return 0;
|
||||||
|
frames = packet[1] & 0x3f;
|
||||||
|
}
|
||||||
|
if (frames <= 0) return 0;
|
||||||
|
final total = frameSamples * frames;
|
||||||
|
return total > _maxPacketSamples ? 0 : total;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int _frameSamples(int config) {
|
||||||
|
const silkOrHybrid = [480, 960, 1920, 2880];
|
||||||
|
const celt = [120, 240, 480, 960];
|
||||||
|
if (config < 12) return silkOrHybrid[config & 0x03];
|
||||||
|
if (config < 16) return (config & 0x01) == 0 ? 480 : 960;
|
||||||
|
return celt[config & 0x03];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PagePlan {
|
||||||
|
const _PagePlan({
|
||||||
|
required this.start,
|
||||||
|
required this.end,
|
||||||
|
required this.granulePos,
|
||||||
|
required this.bytes,
|
||||||
|
this.last = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int start;
|
||||||
|
final int end;
|
||||||
|
final int granulePos;
|
||||||
|
final int bytes;
|
||||||
|
final bool last;
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:collection';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import '../utils/media_cache.dart';
|
||||||
|
|
||||||
|
class VideoNotePreloader {
|
||||||
|
static const int autoLoadMaxMs = 30000;
|
||||||
|
static const int _maxConcurrent = 2;
|
||||||
|
|
||||||
|
static int _running = 0;
|
||||||
|
static final Queue<_PreloadJob> _queue = Queue();
|
||||||
|
|
||||||
|
static bool autoLoads(int? durationMs) =>
|
||||||
|
durationMs != null && durationMs > 0 && durationMs <= autoLoadMaxMs;
|
||||||
|
|
||||||
|
static Future<File?> load(
|
||||||
|
String cacheName,
|
||||||
|
Future<String?> Function() resolveUrl, {
|
||||||
|
bool priority = false,
|
||||||
|
void Function(double progress)? onProgress,
|
||||||
|
bool Function()? cancelled,
|
||||||
|
}) async {
|
||||||
|
final cached = await MediaCache.existing(cacheName);
|
||||||
|
if (cached != null) return cached;
|
||||||
|
|
||||||
|
final job = _PreloadJob(cacheName, resolveUrl, onProgress, cancelled);
|
||||||
|
if (priority) {
|
||||||
|
_queue.addFirst(job);
|
||||||
|
} else {
|
||||||
|
_queue.addLast(job);
|
||||||
|
}
|
||||||
|
_pump();
|
||||||
|
return job.result.future;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void _pump() {
|
||||||
|
while (_running < _maxConcurrent && _queue.isNotEmpty) {
|
||||||
|
final job = _queue.removeFirst();
|
||||||
|
_running++;
|
||||||
|
_run(job).whenComplete(() {
|
||||||
|
_running--;
|
||||||
|
_pump();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> _run(_PreloadJob job) async {
|
||||||
|
if (job.cancelled?.call() ?? false) {
|
||||||
|
job.result.complete(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
File? file;
|
||||||
|
try {
|
||||||
|
final url = await job.resolveUrl();
|
||||||
|
if (url != null && url.isNotEmpty) {
|
||||||
|
file = await MediaCache.getOrDownload(
|
||||||
|
job.cacheName,
|
||||||
|
url,
|
||||||
|
onProgress: job.onProgress,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
file = null;
|
||||||
|
}
|
||||||
|
if (!job.result.isCompleted) job.result.complete(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PreloadJob {
|
||||||
|
_PreloadJob(this.cacheName, this.resolveUrl, this.onProgress, this.cancelled);
|
||||||
|
|
||||||
|
final String cacheName;
|
||||||
|
final Future<String?> Function() resolveUrl;
|
||||||
|
final void Function(double progress)? onProgress;
|
||||||
|
final bool Function()? cancelled;
|
||||||
|
final Completer<File?> result = Completer<File?>();
|
||||||
|
}
|
||||||
@@ -0,0 +1,331 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:ogg_opus_player/ogg_opus_player.dart';
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
|
import '../utils/download_progress.dart';
|
||||||
|
import '../utils/logger.dart';
|
||||||
|
import '../utils/media_cache.dart';
|
||||||
|
import 'opus_ogg_index.dart';
|
||||||
|
|
||||||
|
enum VoiceAudioFailure { none, download, playback }
|
||||||
|
|
||||||
|
class VoiceAudioController {
|
||||||
|
VoiceAudioController({
|
||||||
|
required this.cacheName,
|
||||||
|
required this.resolveUrl,
|
||||||
|
required Duration fallbackDuration,
|
||||||
|
}) : duration = ValueNotifier(
|
||||||
|
fallbackDuration.inMicroseconds / Duration.microsecondsPerSecond,
|
||||||
|
);
|
||||||
|
|
||||||
|
final String cacheName;
|
||||||
|
final Future<String?> Function() resolveUrl;
|
||||||
|
|
||||||
|
static const double _endEpsilon = 0.05;
|
||||||
|
|
||||||
|
static VoiceAudioController? _active;
|
||||||
|
static int _sliceCounter = 0;
|
||||||
|
static Directory? _sliceDir;
|
||||||
|
|
||||||
|
final ValueNotifier<bool> playing = ValueNotifier(false);
|
||||||
|
final ValueNotifier<double> position = ValueNotifier(0);
|
||||||
|
final ValueNotifier<double> duration;
|
||||||
|
final ValueNotifier<VoiceAudioFailure> failure = ValueNotifier(
|
||||||
|
VoiceAudioFailure.none,
|
||||||
|
);
|
||||||
|
|
||||||
|
ValueListenable<double?> get downloadProgress =>
|
||||||
|
MediaDownloadProgress.notifier(cacheName);
|
||||||
|
|
||||||
|
ValueListenable<bool> get downloaded => MediaCache.presence(cacheName);
|
||||||
|
|
||||||
|
bool get scrubbing => _scrubbing;
|
||||||
|
|
||||||
|
File? _file;
|
||||||
|
OpusOggIndex? _index;
|
||||||
|
OggOpusPlayer? _player;
|
||||||
|
File? _slice;
|
||||||
|
Timer? _ticker;
|
||||||
|
Future<void>? _loading;
|
||||||
|
double _sliceOffset = 0;
|
||||||
|
int _startGeneration = 0;
|
||||||
|
bool _scrubbing = false;
|
||||||
|
bool _resumeAfterScrub = false;
|
||||||
|
bool _finished = false;
|
||||||
|
bool _disposed = false;
|
||||||
|
|
||||||
|
Future<void> toggle() async {
|
||||||
|
if (playing.value) {
|
||||||
|
pause();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await play();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> play() async {
|
||||||
|
if (_disposed) return;
|
||||||
|
if (!await _ensureLoaded()) return;
|
||||||
|
if (_disposed) return;
|
||||||
|
|
||||||
|
if (_active != this) {
|
||||||
|
_active?.pause();
|
||||||
|
_active = this;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_finished) {
|
||||||
|
_finished = false;
|
||||||
|
_disposePlayer();
|
||||||
|
position.value = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
final player = _player;
|
||||||
|
if (player != null) {
|
||||||
|
player.play();
|
||||||
|
playing.value = true;
|
||||||
|
_startTicker();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final total = duration.value;
|
||||||
|
final from = total > 0 && position.value >= total - _endEpsilon
|
||||||
|
? 0.0
|
||||||
|
: position.value;
|
||||||
|
await _startAt(from);
|
||||||
|
}
|
||||||
|
|
||||||
|
void pause() {
|
||||||
|
_startGeneration++;
|
||||||
|
_player?.pause();
|
||||||
|
playing.value = false;
|
||||||
|
_stopTicker();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> seekTo(double seconds) async {
|
||||||
|
scrubStart();
|
||||||
|
scrubTo(seconds);
|
||||||
|
await scrubEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
void scrubStart() {
|
||||||
|
if (_scrubbing) return;
|
||||||
|
_scrubbing = true;
|
||||||
|
_resumeAfterScrub = playing.value;
|
||||||
|
_startGeneration++;
|
||||||
|
if (playing.value) pause();
|
||||||
|
}
|
||||||
|
|
||||||
|
void scrubTo(double seconds) {
|
||||||
|
final total = duration.value;
|
||||||
|
position.value = total <= 0 ? 0 : seconds.clamp(0.0, total);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> scrubEnd() async {
|
||||||
|
if (!_scrubbing) return;
|
||||||
|
_scrubbing = false;
|
||||||
|
final resume = _resumeAfterScrub;
|
||||||
|
_resumeAfterScrub = false;
|
||||||
|
_finished = false;
|
||||||
|
|
||||||
|
if (_file == null) {
|
||||||
|
if (resume) await play();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_disposePlayer();
|
||||||
|
if (resume) await _startAt(position.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _ensureLoaded() async {
|
||||||
|
if (_file != null) return true;
|
||||||
|
final running = _loading;
|
||||||
|
if (running != null) {
|
||||||
|
await running;
|
||||||
|
return _file != null;
|
||||||
|
}
|
||||||
|
final future = _load();
|
||||||
|
_loading = future;
|
||||||
|
try {
|
||||||
|
await future;
|
||||||
|
} finally {
|
||||||
|
_loading = null;
|
||||||
|
}
|
||||||
|
return _file != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _load() async {
|
||||||
|
failure.value = VoiceAudioFailure.none;
|
||||||
|
try {
|
||||||
|
var file = await MediaCache.existing(cacheName);
|
||||||
|
if (file == null) {
|
||||||
|
MediaDownloadProgress.set(cacheName, 0);
|
||||||
|
try {
|
||||||
|
final url = await resolveUrl();
|
||||||
|
if (url != null && url.isNotEmpty) {
|
||||||
|
file = await MediaCache.getOrDownload(
|
||||||
|
cacheName,
|
||||||
|
url,
|
||||||
|
onProgress: (value) =>
|
||||||
|
MediaDownloadProgress.set(cacheName, value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
MediaDownloadProgress.set(cacheName, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (_disposed) return;
|
||||||
|
if (file == null) {
|
||||||
|
failure.value = VoiceAudioFailure.download;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_file = file;
|
||||||
|
await _buildIndex(file);
|
||||||
|
} catch (e) {
|
||||||
|
logger.w('VoiceAudioController._load($cacheName): $e');
|
||||||
|
if (!_disposed) failure.value = VoiceAudioFailure.download;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _buildIndex(File file) async {
|
||||||
|
try {
|
||||||
|
final bytes = await file.readAsBytes();
|
||||||
|
if (_disposed) return;
|
||||||
|
final index = OpusOggIndex.parse(bytes);
|
||||||
|
if (index == null) return;
|
||||||
|
_index = index;
|
||||||
|
if (index.duration > 0) duration.value = index.duration;
|
||||||
|
} catch (e) {
|
||||||
|
logger.w('VoiceAudioController: индекс не построен ($cacheName): $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _startAt(double seconds) async {
|
||||||
|
final file = _file;
|
||||||
|
if (file == null) return;
|
||||||
|
final generation = ++_startGeneration;
|
||||||
|
_disposePlayer();
|
||||||
|
|
||||||
|
final total = duration.value;
|
||||||
|
if (total > 0 && seconds >= total - _endEpsilon) {
|
||||||
|
_finished = true;
|
||||||
|
playing.value = false;
|
||||||
|
position.value = total;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var path = file.path;
|
||||||
|
var offset = 0.0;
|
||||||
|
final index = _index;
|
||||||
|
if (seconds > 0 && index != null) {
|
||||||
|
final bytes = index.sliceFrom(seconds);
|
||||||
|
if (bytes != null) {
|
||||||
|
final slice = await _writeSlice(bytes);
|
||||||
|
if (_disposed || generation != _startGeneration) return;
|
||||||
|
if (slice != null) {
|
||||||
|
path = slice.path;
|
||||||
|
offset = seconds;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_sliceOffset = offset;
|
||||||
|
position.value = offset;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final player = OggOpusPlayer(path);
|
||||||
|
_player = player;
|
||||||
|
player.state.addListener(_onPlayerState);
|
||||||
|
player.play();
|
||||||
|
playing.value = true;
|
||||||
|
_startTicker();
|
||||||
|
} catch (e) {
|
||||||
|
logger.w('VoiceAudioController._startAt($cacheName): $e');
|
||||||
|
failure.value = VoiceAudioFailure.playback;
|
||||||
|
playing.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<File?> _writeSlice(Uint8List bytes) async {
|
||||||
|
try {
|
||||||
|
final dir = _sliceDir ??= await getTemporaryDirectory();
|
||||||
|
final next = File(p.join(dir.path, 'voice_slice_${_sliceCounter++}.ogg'));
|
||||||
|
await next.writeAsBytes(bytes);
|
||||||
|
final previous = _slice;
|
||||||
|
_slice = next;
|
||||||
|
await _deleteQuietly(previous);
|
||||||
|
return next;
|
||||||
|
} catch (e) {
|
||||||
|
logger.w('VoiceAudioController._writeSlice($cacheName): $e');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onPlayerState() {
|
||||||
|
final state = _player?.state.value;
|
||||||
|
if (state == null || _disposed) return;
|
||||||
|
if (state == PlayerState.ended) {
|
||||||
|
_finished = true;
|
||||||
|
playing.value = false;
|
||||||
|
position.value = duration.value;
|
||||||
|
_stopTicker();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (state == PlayerState.error) {
|
||||||
|
failure.value = VoiceAudioFailure.playback;
|
||||||
|
playing.value = false;
|
||||||
|
_stopTicker();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _startTicker() {
|
||||||
|
_ticker ??= Timer.periodic(
|
||||||
|
const Duration(milliseconds: 50),
|
||||||
|
(_) => _onTick(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _stopTicker() {
|
||||||
|
_ticker?.cancel();
|
||||||
|
_ticker = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onTick() {
|
||||||
|
final player = _player;
|
||||||
|
if (player == null || _scrubbing || _finished) return;
|
||||||
|
final total = duration.value;
|
||||||
|
final value = _sliceOffset + player.currentPosition;
|
||||||
|
position.value = total > 0 ? value.clamp(0.0, total) : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _disposePlayer() {
|
||||||
|
final player = _player;
|
||||||
|
_player = null;
|
||||||
|
_stopTicker();
|
||||||
|
if (player == null) return;
|
||||||
|
player.state.removeListener(_onPlayerState);
|
||||||
|
player.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> _deleteQuietly(File? file) async {
|
||||||
|
if (file == null) return;
|
||||||
|
try {
|
||||||
|
if (await file.exists()) await file.delete();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
void dispose() {
|
||||||
|
_disposed = true;
|
||||||
|
_disposePlayer();
|
||||||
|
if (_active == this) _active = null;
|
||||||
|
final slice = _slice;
|
||||||
|
_slice = null;
|
||||||
|
_deleteQuietly(slice).ignore();
|
||||||
|
playing.dispose();
|
||||||
|
position.dispose();
|
||||||
|
duration.dispose();
|
||||||
|
failure.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,18 +23,13 @@ class VideoBubble extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final message = ctx.message;
|
final message = ctx.message;
|
||||||
if (video.isNote) {
|
if (video.isNote) {
|
||||||
return Column(
|
return VideoNoteBubble(
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
attachment: video,
|
||||||
children: [
|
messageId: message.id,
|
||||||
VideoNoteBubble(
|
chatId: message.chatId,
|
||||||
attachment: video,
|
cs: ctx.cs,
|
||||||
messageId: message.id,
|
textColor: ctx.text,
|
||||||
chatId: message.chatId,
|
meta: ctx.meta(),
|
||||||
cs: ctx.cs,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 6),
|
|
||||||
ctx.meta(),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
final hasCaption = message.text != null && message.text!.isNotEmpty;
|
final hasCaption = message.text != null && message.text!.isNotEmpty;
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:typed_data';
|
import 'dart:io';
|
||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:material_symbols_icons/symbols.dart';
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
import 'package:video_player/video_player.dart';
|
import 'package:video_player/video_player.dart';
|
||||||
import 'package:komet/main.dart';
|
import 'package:komet/main.dart';
|
||||||
|
|
||||||
|
import '../../../../core/media/video_note_preloader.dart';
|
||||||
|
import '../../../../core/utils/format.dart';
|
||||||
import '../../../../core/utils/haptics.dart';
|
import '../../../../core/utils/haptics.dart';
|
||||||
import '../../../../core/utils/logger.dart';
|
import '../../../../core/utils/logger.dart';
|
||||||
import '../../../../core/utils/media_cache.dart';
|
|
||||||
import '../../../../models/attachment.dart';
|
import '../../../../models/attachment.dart';
|
||||||
import '../../small_spinner.dart';
|
import '../../small_spinner.dart';
|
||||||
|
|
||||||
@@ -17,6 +20,8 @@ class VideoNoteBubble extends StatefulWidget {
|
|||||||
final String messageId;
|
final String messageId;
|
||||||
final int chatId;
|
final int chatId;
|
||||||
final ColorScheme cs;
|
final ColorScheme cs;
|
||||||
|
final Color textColor;
|
||||||
|
final Widget meta;
|
||||||
|
|
||||||
const VideoNoteBubble({
|
const VideoNoteBubble({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -24,27 +29,87 @@ class VideoNoteBubble extends StatefulWidget {
|
|||||||
required this.messageId,
|
required this.messageId,
|
||||||
required this.chatId,
|
required this.chatId,
|
||||||
required this.cs,
|
required this.cs,
|
||||||
|
required this.textColor,
|
||||||
|
required this.meta,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<VideoNoteBubble> createState() => _VideoNoteBubbleState();
|
State<VideoNoteBubble> createState() => _VideoNoteBubbleState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _VideoNoteBubbleState extends State<VideoNoteBubble> {
|
class _VideoNoteBubbleState extends State<VideoNoteBubble>
|
||||||
static const double _size = 210;
|
with SingleTickerProviderStateMixin {
|
||||||
|
static const double _baseSize = 210;
|
||||||
|
static const double _expandedScale = 1.7;
|
||||||
|
static const Duration _expandDuration = Duration(milliseconds: 280);
|
||||||
|
static const Duration _swapDuration = Duration(milliseconds: 220);
|
||||||
|
|
||||||
|
static _VideoNoteBubbleState? _playingNote;
|
||||||
|
|
||||||
|
late final AnimationController _expand;
|
||||||
|
final ValueNotifier<double> _ringProgress = ValueNotifier(0);
|
||||||
|
Uint8List? _preview;
|
||||||
VideoPlayerController? _controller;
|
VideoPlayerController? _controller;
|
||||||
|
Future<void>? _initializing;
|
||||||
|
Duration? _pendingSeek;
|
||||||
|
double? _lastAngle;
|
||||||
|
bool _playing = false;
|
||||||
bool _loading = false;
|
bool _loading = false;
|
||||||
bool _error = false;
|
bool _error = false;
|
||||||
|
bool _scrubbing = false;
|
||||||
|
bool _seekInFlight = false;
|
||||||
|
bool _resumeAfterScrub = false;
|
||||||
|
|
||||||
|
int? get _videoId => widget.attachment.videoId;
|
||||||
|
String get _cacheName => 'videonote_$_videoId.mp4';
|
||||||
|
int get _attachmentDurationMs => widget.attachment.duration ?? 0;
|
||||||
|
|
||||||
|
bool get _ready {
|
||||||
|
final controller = _controller;
|
||||||
|
return controller != null && controller.value.isInitialized;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_expand = AnimationController(vsync: this, duration: _expandDuration);
|
||||||
|
_preview = _previewBytes(widget.attachment.previewData);
|
||||||
|
if (VideoNotePreloader.autoLoads(widget.attachment.duration)) _preload();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(VideoNoteBubble old) {
|
||||||
|
super.didUpdateWidget(old);
|
||||||
|
if (old.attachment.previewData != widget.attachment.previewData) {
|
||||||
|
_preview = _previewBytes(widget.attachment.previewData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
if (_playingNote == this) _playingNote = null;
|
||||||
|
_PreviewPool.unregister(this);
|
||||||
|
_expand.dispose();
|
||||||
|
_ringProgress.dispose();
|
||||||
_controller?.removeListener(_onTick);
|
_controller?.removeListener(_onTick);
|
||||||
_controller?.dispose();
|
_controller?.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onTick() {
|
void _onTick() {
|
||||||
if (mounted) setState(() {});
|
final controller = _controller;
|
||||||
|
if (controller == null || !mounted) return;
|
||||||
|
final value = controller.value;
|
||||||
|
|
||||||
|
if (!_scrubbing) {
|
||||||
|
final total = value.duration.inMilliseconds;
|
||||||
|
_ringProgress.value = total > 0
|
||||||
|
? (value.position.inMilliseconds / total).clamp(0.0, 1.0)
|
||||||
|
: 0.0;
|
||||||
|
}
|
||||||
|
if (value.isPlaying != _playing) {
|
||||||
|
setState(() => _playing = value.isPlaying);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static Uint8List? _previewBytes(String? data) {
|
static Uint8List? _previewBytes(String? data) {
|
||||||
@@ -59,119 +124,278 @@ class _VideoNoteBubbleState extends State<VideoNoteBubble> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _preload() async {
|
||||||
|
final file = await _fetch(priority: false);
|
||||||
|
if (file == null || !mounted) return;
|
||||||
|
await _ensureController(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<File?> _fetch({required bool priority}) {
|
||||||
|
final videoId = _videoId;
|
||||||
|
final token = widget.attachment.videoToken;
|
||||||
|
if (videoId == null || token == null) return Future.value(null);
|
||||||
|
return VideoNotePreloader.load(
|
||||||
|
_cacheName,
|
||||||
|
() => messagesModule.getVideoUrl(
|
||||||
|
messageId: widget.messageId,
|
||||||
|
chatId: widget.chatId,
|
||||||
|
token: token,
|
||||||
|
videoId: videoId,
|
||||||
|
),
|
||||||
|
priority: priority,
|
||||||
|
cancelled: priority ? null : () => !mounted,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<VideoPlayerController?> _ensureController(File file) async {
|
||||||
|
if (_controller != null) return _controller;
|
||||||
|
final running = _initializing;
|
||||||
|
if (running != null) {
|
||||||
|
await running;
|
||||||
|
return _controller;
|
||||||
|
}
|
||||||
|
|
||||||
|
final controller = VideoPlayerController.file(file);
|
||||||
|
final future = controller.initialize();
|
||||||
|
_initializing = future;
|
||||||
|
try {
|
||||||
|
await future;
|
||||||
|
} catch (e) {
|
||||||
|
logger.w('VideoNoteBubble: инициализация не удалась: $e');
|
||||||
|
await controller.dispose();
|
||||||
|
_initializing = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
_initializing = null;
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
await controller.dispose();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
_controller = controller;
|
||||||
|
await controller.setLooping(true);
|
||||||
|
await controller.seekTo(Duration.zero);
|
||||||
|
controller.addListener(_onTick);
|
||||||
|
_PreviewPool.register(this);
|
||||||
|
if (mounted) setState(() {});
|
||||||
|
return controller;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _releasePreview() {
|
||||||
|
final controller = _controller;
|
||||||
|
if (controller == null) return;
|
||||||
|
_controller = null;
|
||||||
|
controller.removeListener(_onTick);
|
||||||
|
controller.dispose();
|
||||||
|
if (mounted) setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _toggle() async {
|
Future<void> _toggle() async {
|
||||||
final existing = _controller;
|
if (_ready) {
|
||||||
if (existing != null) {
|
if (_controller!.value.isPlaying) {
|
||||||
setState(
|
await _pause();
|
||||||
() => existing.value.isPlaying ? existing.pause() : existing.play(),
|
} else {
|
||||||
);
|
await _play();
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (_loading) return;
|
if (_loading) return;
|
||||||
|
|
||||||
final a = widget.attachment;
|
setState(() {
|
||||||
final videoId = a.videoId;
|
_loading = true;
|
||||||
final token = a.videoToken;
|
_error = false;
|
||||||
if (videoId == null || token == null) {
|
});
|
||||||
setState(() => _error = true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setState(() => _loading = true);
|
|
||||||
Haptics.tap();
|
Haptics.tap();
|
||||||
|
|
||||||
|
final file = await _fetch(priority: true);
|
||||||
|
if (!mounted) return;
|
||||||
|
final controller = file == null ? null : await _ensureController(file);
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_loading = false;
|
||||||
|
_error = controller == null;
|
||||||
|
});
|
||||||
|
if (controller != null) await _play();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _play() async {
|
||||||
|
final controller = _controller;
|
||||||
|
if (controller == null) return;
|
||||||
|
final other = _playingNote;
|
||||||
|
if (other != null && other != this) await other._pause();
|
||||||
|
_playingNote = this;
|
||||||
|
_PreviewPool.pin(this);
|
||||||
|
await controller.play();
|
||||||
|
_expand.forward();
|
||||||
|
if (mounted) setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pause() async {
|
||||||
|
final controller = _controller;
|
||||||
|
if (controller == null) return;
|
||||||
|
await controller.pause();
|
||||||
|
if (_playingNote == this) _playingNote = null;
|
||||||
|
_PreviewPool.register(this);
|
||||||
|
_expand.reverse();
|
||||||
|
if (mounted) setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _seekToProgress(double progress) {
|
||||||
|
final controller = _controller;
|
||||||
|
if (controller == null || !controller.value.isInitialized) return;
|
||||||
|
final total = controller.value.duration;
|
||||||
|
if (total.inMilliseconds <= 0) return;
|
||||||
|
_pendingSeek = total * progress.clamp(0.0, 1.0);
|
||||||
|
if (!_seekInFlight) _drainSeeks();
|
||||||
|
}
|
||||||
|
|
||||||
|
NoteRingGeometry _geometry(double extent) =>
|
||||||
|
NoteRingGeometry(extent: extent, knobRadius: _scrubbing ? 9 : 7);
|
||||||
|
|
||||||
|
void _ringTap(Offset local, double extent) {
|
||||||
|
final controller = _controller;
|
||||||
|
if (controller == null || !controller.value.isInitialized) return;
|
||||||
|
Haptics.tap();
|
||||||
|
_seekToProgress(_geometry(extent).progressAt(local));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _ringDragStart(Offset local, double extent) async {
|
||||||
|
final controller = _controller;
|
||||||
|
if (controller == null || !controller.value.isInitialized) return;
|
||||||
|
_resumeAfterScrub = controller.value.isPlaying;
|
||||||
|
if (_resumeAfterScrub) await controller.pause();
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
final geometry = _geometry(extent);
|
||||||
|
final target = geometry.progressAt(local);
|
||||||
|
Haptics.tap();
|
||||||
|
_lastAngle = geometry.angleAt(local);
|
||||||
|
_ringProgress.value = target;
|
||||||
|
setState(() => _scrubbing = true);
|
||||||
|
_seekToProgress(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _ringDragUpdate(Offset local, double extent) {
|
||||||
|
final previous = _lastAngle;
|
||||||
|
if (!_scrubbing || previous == null) return;
|
||||||
|
final geometry = _geometry(extent);
|
||||||
|
final angle = geometry.angleAt(local);
|
||||||
|
_lastAngle = angle;
|
||||||
|
_ringProgress.value = geometry.advance(
|
||||||
|
_ringProgress.value,
|
||||||
|
NoteRingGeometry.angleDelta(previous, angle),
|
||||||
|
);
|
||||||
|
_seekToProgress(_ringProgress.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _ringDragEnd() async {
|
||||||
|
if (!_scrubbing) return;
|
||||||
|
setState(() {
|
||||||
|
_scrubbing = false;
|
||||||
|
_lastAngle = null;
|
||||||
|
});
|
||||||
|
if (!_resumeAfterScrub) return;
|
||||||
|
_resumeAfterScrub = false;
|
||||||
|
await _controller?.play();
|
||||||
|
if (mounted) setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _drainSeeks() async {
|
||||||
|
_seekInFlight = true;
|
||||||
try {
|
try {
|
||||||
final cacheName = 'videonote_$videoId.mp4';
|
var target = _pendingSeek;
|
||||||
var file = await MediaCache.existing(cacheName);
|
while (target != null) {
|
||||||
if (file == null) {
|
_pendingSeek = null;
|
||||||
final url = await messagesModule.getVideoUrl(
|
await _controller?.seekTo(target);
|
||||||
messageId: widget.messageId,
|
target = _pendingSeek;
|
||||||
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 (e) {
|
} catch (e) {
|
||||||
logger.w('VideoNoteBubble._toggle: $e');
|
logger.w('VideoNoteBubble._drainSeeks: $e');
|
||||||
if (mounted) {
|
} finally {
|
||||||
setState(() {
|
_seekInFlight = false;
|
||||||
_loading = false;
|
|
||||||
_error = true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final a = widget.attachment;
|
return LayoutBuilder(
|
||||||
final c = _controller;
|
builder: (context, constraints) {
|
||||||
final ready = c != null && c.value.isInitialized;
|
final maxWidth = constraints.maxWidth.isFinite
|
||||||
final playing = ready && c.value.isPlaying;
|
? constraints.maxWidth
|
||||||
final preview = _previewBytes(a.previewData);
|
: _baseSize * _expandedScale;
|
||||||
|
return AnimatedBuilder(
|
||||||
|
animation: _expand,
|
||||||
|
builder: (context, _) {
|
||||||
|
final t = Curves.easeOutCubic.transform(_expand.value);
|
||||||
|
final size = math.min(
|
||||||
|
_baseSize * (1 + (_expandedScale - 1) * t),
|
||||||
|
maxWidth,
|
||||||
|
);
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
_buildCircle(size),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
SizedBox(width: size, child: _buildMetaRow()),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
double progress = 0;
|
Widget _buildCircle(double size) {
|
||||||
if (ready && c.value.duration.inMilliseconds > 0) {
|
final controller = _controller;
|
||||||
progress =
|
final ready = _ready;
|
||||||
c.value.position.inMilliseconds / c.value.duration.inMilliseconds;
|
final playing = ready && _playing && !_scrubbing;
|
||||||
}
|
final preview = _preview;
|
||||||
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: _toggle,
|
onTap: _toggle,
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: _size,
|
width: size,
|
||||||
height: _size,
|
height: size,
|
||||||
child: Stack(
|
child: Stack(
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
children: [
|
children: [
|
||||||
ClipOval(
|
ClipOval(
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: _size,
|
width: size,
|
||||||
height: _size,
|
height: size,
|
||||||
child: ready
|
child: AnimatedSwitcher(
|
||||||
? FittedBox(
|
duration: _swapDuration,
|
||||||
fit: BoxFit.cover,
|
child: ready
|
||||||
clipBehavior: Clip.hardEdge,
|
? SizedBox.expand(
|
||||||
child: SizedBox(
|
key: const ValueKey('note-video'),
|
||||||
width: c.value.size.width,
|
child: FittedBox(
|
||||||
height: c.value.size.height,
|
fit: BoxFit.cover,
|
||||||
child: VideoPlayer(c),
|
clipBehavior: Clip.hardEdge,
|
||||||
|
child: SizedBox(
|
||||||
|
width: controller!.value.size.width,
|
||||||
|
height: controller.value.size.height,
|
||||||
|
child: VideoPlayer(controller),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: preview != null
|
||||||
|
? Image.memory(
|
||||||
|
preview,
|
||||||
|
key: const ValueKey('note-preview'),
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
gaplessPlayback: true,
|
||||||
|
)
|
||||||
|
: Container(
|
||||||
|
key: const ValueKey('note-empty'),
|
||||||
|
color: widget.cs.surfaceContainerHighest,
|
||||||
),
|
),
|
||||||
)
|
|
||||||
: 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 (ready) _buildRing(size),
|
||||||
if (!playing)
|
if (!playing)
|
||||||
Container(
|
Container(
|
||||||
width: 52,
|
width: 52,
|
||||||
@@ -196,4 +420,193 @@ class _VideoNoteBubbleState extends State<VideoNoteBubble> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildRing(double size) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTapUp: (details) => _ringTap(details.localPosition, size),
|
||||||
|
onPanStart: (details) => _ringDragStart(details.localPosition, size),
|
||||||
|
onPanUpdate: (details) => _ringDragUpdate(details.localPosition, size),
|
||||||
|
onPanEnd: (_) => _ringDragEnd(),
|
||||||
|
onPanCancel: _ringDragEnd,
|
||||||
|
child: CustomPaint(
|
||||||
|
size: Size(size, size),
|
||||||
|
painter: _NoteRingPainter(
|
||||||
|
geometry: _geometry(size),
|
||||||
|
progress: _ringProgress,
|
||||||
|
color: widget.cs.primary,
|
||||||
|
trackColor: Colors.white30,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildMetaRow() {
|
||||||
|
final controller = _controller;
|
||||||
|
final ready = _ready;
|
||||||
|
final totalMs = ready
|
||||||
|
? controller!.value.duration.inMilliseconds
|
||||||
|
: _attachmentDurationMs;
|
||||||
|
final showPosition = ready && (_playing || _scrubbing);
|
||||||
|
final style = TextStyle(
|
||||||
|
color: widget.textColor.withValues(alpha: 0.7),
|
||||||
|
fontSize: 11,
|
||||||
|
);
|
||||||
|
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
||||||
|
child: showPosition
|
||||||
|
? ValueListenableBuilder<double>(
|
||||||
|
valueListenable: _ringProgress,
|
||||||
|
builder: (context, progress, _) => Text(
|
||||||
|
formatSecondsMmSs((progress * totalMs) ~/ 1000),
|
||||||
|
style: style,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Text(formatSecondsMmSs((totalMs / 1000).round()), style: style),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
widget.meta,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class NoteRingGeometry {
|
||||||
|
const NoteRingGeometry({required this.extent, required this.knobRadius});
|
||||||
|
|
||||||
|
static const double startAngle = -math.pi / 2;
|
||||||
|
static const double bandTolerance = 12;
|
||||||
|
static const double knobTolerance = 26;
|
||||||
|
static const double stroke = 3;
|
||||||
|
|
||||||
|
final double extent;
|
||||||
|
final double knobRadius;
|
||||||
|
|
||||||
|
double get radius => extent / 2 - knobRadius - 1;
|
||||||
|
|
||||||
|
Offset get center => Offset(extent / 2, extent / 2);
|
||||||
|
|
||||||
|
Offset knobCenter(double progress) {
|
||||||
|
final angle = startAngle + 2 * math.pi * progress.clamp(0.0, 1.0);
|
||||||
|
return center + Offset(math.cos(angle) * radius, math.sin(angle) * radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
double angleAt(Offset local) {
|
||||||
|
final vector = local - center;
|
||||||
|
return math.atan2(vector.dy, vector.dx);
|
||||||
|
}
|
||||||
|
|
||||||
|
double progressAt(Offset local) {
|
||||||
|
var turns = (angleAt(local) - startAngle) / (2 * math.pi) % 1.0;
|
||||||
|
if (turns < 0) turns += 1.0;
|
||||||
|
return turns;
|
||||||
|
}
|
||||||
|
|
||||||
|
static double angleDelta(double from, double to) {
|
||||||
|
var delta = to - from;
|
||||||
|
while (delta > math.pi) {
|
||||||
|
delta -= 2 * math.pi;
|
||||||
|
}
|
||||||
|
while (delta < -math.pi) {
|
||||||
|
delta += 2 * math.pi;
|
||||||
|
}
|
||||||
|
return delta;
|
||||||
|
}
|
||||||
|
|
||||||
|
double advance(double progress, double delta) =>
|
||||||
|
(progress + delta / (2 * math.pi)).clamp(0.0, 1.0);
|
||||||
|
|
||||||
|
bool grabs(Offset position, double progress) {
|
||||||
|
if ((position - knobCenter(progress)).distance <= knobTolerance) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return ((position - center).distance - radius).abs() <= bandTolerance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _NoteRingPainter extends CustomPainter {
|
||||||
|
_NoteRingPainter({
|
||||||
|
required this.geometry,
|
||||||
|
required this.progress,
|
||||||
|
required this.color,
|
||||||
|
required this.trackColor,
|
||||||
|
}) : super(repaint: progress);
|
||||||
|
|
||||||
|
final NoteRingGeometry geometry;
|
||||||
|
final ValueListenable<double> progress;
|
||||||
|
final Color color;
|
||||||
|
final Color trackColor;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void paint(Canvas canvas, Size size) {
|
||||||
|
final value = progress.value;
|
||||||
|
final center = geometry.center;
|
||||||
|
final radius = geometry.radius;
|
||||||
|
|
||||||
|
canvas.drawCircle(
|
||||||
|
center,
|
||||||
|
radius,
|
||||||
|
Paint()
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = NoteRingGeometry.stroke
|
||||||
|
..color = trackColor,
|
||||||
|
);
|
||||||
|
|
||||||
|
final sweep = 2 * math.pi * value.clamp(0.0, 1.0);
|
||||||
|
if (sweep > 0) {
|
||||||
|
canvas.drawArc(
|
||||||
|
Rect.fromCircle(center: center, radius: radius),
|
||||||
|
NoteRingGeometry.startAngle,
|
||||||
|
sweep,
|
||||||
|
false,
|
||||||
|
Paint()
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = NoteRingGeometry.stroke
|
||||||
|
..strokeCap = StrokeCap.round
|
||||||
|
..color = color,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final knob = geometry.knobCenter(value);
|
||||||
|
final knobRadius = geometry.knobRadius;
|
||||||
|
canvas.drawCircle(
|
||||||
|
knob,
|
||||||
|
knobRadius,
|
||||||
|
Paint()
|
||||||
|
..color = Colors.black26
|
||||||
|
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 2),
|
||||||
|
);
|
||||||
|
canvas.drawCircle(knob, knobRadius, Paint()..color = Colors.white);
|
||||||
|
canvas.drawCircle(knob, knobRadius - 2.5, Paint()..color = color);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool hitTest(Offset position) => geometry.grabs(position, progress.value);
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRepaint(_NoteRingPainter old) =>
|
||||||
|
old.geometry.extent != geometry.extent ||
|
||||||
|
old.geometry.knobRadius != geometry.knobRadius ||
|
||||||
|
old.color != color ||
|
||||||
|
old.trackColor != trackColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PreviewPool {
|
||||||
|
static const int _maxIdle = 4;
|
||||||
|
static final List<_VideoNoteBubbleState> _idle = [];
|
||||||
|
|
||||||
|
static void register(_VideoNoteBubbleState state) {
|
||||||
|
_idle
|
||||||
|
..remove(state)
|
||||||
|
..add(state);
|
||||||
|
while (_idle.length > _maxIdle) {
|
||||||
|
_idle.removeAt(0)._releasePreview();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void pin(_VideoNoteBubbleState state) => _idle.remove(state);
|
||||||
|
|
||||||
|
static void unregister(_VideoNoteBubbleState state) => _idle.remove(state);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,14 @@
|
|||||||
import 'dart:async';
|
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:material_symbols_icons/symbols.dart';
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
import 'package:ogg_opus_player/ogg_opus_player.dart';
|
|
||||||
import 'package:komet/main.dart';
|
import 'package:komet/main.dart';
|
||||||
|
|
||||||
import '../../../../backend/modules/messages.dart';
|
import '../../../../backend/modules/messages.dart';
|
||||||
import '../../../../core/config/app_colors.dart';
|
import '../../../../core/config/app_colors.dart';
|
||||||
import '../../../../core/config/komet_settings.dart';
|
import '../../../../core/config/komet_settings.dart';
|
||||||
|
import '../../../../core/media/voice_audio_controller.dart';
|
||||||
import '../../../../core/utils/format.dart';
|
import '../../../../core/utils/format.dart';
|
||||||
import '../../../../core/utils/logger.dart';
|
import '../../../../core/utils/logger.dart';
|
||||||
import '../../../../core/utils/media_cache.dart';
|
|
||||||
import '../../custom_notification.dart';
|
import '../../custom_notification.dart';
|
||||||
import '../../small_spinner.dart';
|
import '../../small_spinner.dart';
|
||||||
|
|
||||||
@@ -54,15 +51,11 @@ class VoiceMessageBubble extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
||||||
bool _isPlaying = false;
|
|
||||||
final ValueNotifier<double> _progress = ValueNotifier(0.0);
|
|
||||||
bool _transcriptionVisible = false;
|
bool _transcriptionVisible = false;
|
||||||
String? _transcriptionText;
|
String? _transcriptionText;
|
||||||
bool _transcriptionLoading = false;
|
bool _transcriptionLoading = false;
|
||||||
|
|
||||||
OggOpusPlayer? _player;
|
late final VoiceAudioController _audio;
|
||||||
bool _loadingAudio = false;
|
|
||||||
Timer? _ticker;
|
|
||||||
late final List<int> _amps = _parseWave(widget.waveData);
|
late final List<int> _amps = _parseWave(widget.waveData);
|
||||||
|
|
||||||
static List<int> _parseWave(String? data) {
|
static List<int> _parseWave(String? data) {
|
||||||
@@ -74,75 +67,30 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_transcriptionText = widget.preloadedText;
|
_transcriptionText = widget.preloadedText;
|
||||||
|
_audio = VoiceAudioController(
|
||||||
|
cacheName: '${widget.audioId ?? widget.messageId}.ogg',
|
||||||
|
resolveUrl: () async => widget.url,
|
||||||
|
fallbackDuration: Duration(seconds: widget.duration),
|
||||||
|
);
|
||||||
|
_audio.failure.addListener(_onFailure);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_ticker?.cancel();
|
_audio.failure.removeListener(_onFailure);
|
||||||
_player?.state.removeListener(_onPlayerState);
|
_audio.dispose();
|
||||||
_player?.dispose();
|
|
||||||
_progress.dispose();
|
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _togglePlay() async {
|
void _onFailure() {
|
||||||
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) {
|
|
||||||
logger.w('VoiceBubble._togglePlay: $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;
|
if (!mounted) return;
|
||||||
final playing = state == PlayerState.playing;
|
switch (_audio.failure.value) {
|
||||||
if (playing != _isPlaying) setState(() => _isPlaying = playing);
|
case VoiceAudioFailure.none:
|
||||||
if (state == PlayerState.ended) {
|
return;
|
||||||
_progress.value = 1.0;
|
case VoiceAudioFailure.download:
|
||||||
|
showCustomNotification(context, 'Не удалось загрузить аудио');
|
||||||
|
case VoiceAudioFailure.playback:
|
||||||
|
showCustomNotification(context, 'Ошибка воспроизведения');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,13 +148,92 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
|||||||
return Icon(icon, size: 14, color: color);
|
return Icon(icon, size: 14, color: color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Color get _accent =>
|
||||||
|
widget.isMe ? widget.cs.onPrimaryContainer : widget.cs.primary;
|
||||||
|
|
||||||
|
Widget _buildPlayButton() {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: _audio.toggle,
|
||||||
|
child: Container(
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: widget.isMe
|
||||||
|
? widget.cs.onPrimaryContainer.withValues(alpha: 0.12)
|
||||||
|
: widget.cs.primaryContainer,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: AnimatedBuilder(
|
||||||
|
animation: Listenable.merge([
|
||||||
|
_audio.downloaded,
|
||||||
|
_audio.downloadProgress,
|
||||||
|
_audio.playing,
|
||||||
|
]),
|
||||||
|
builder: (context, _) {
|
||||||
|
final progress = _audio.downloadProgress.value;
|
||||||
|
if (progress != null) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.all(4),
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
value: progress > 0 ? progress : null,
|
||||||
|
color: _accent,
|
||||||
|
backgroundColor: _accent.withValues(alpha: 0.2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final IconData icon;
|
||||||
|
if (_audio.playing.value) {
|
||||||
|
icon = Symbols.pause;
|
||||||
|
} else if (_audio.downloaded.value) {
|
||||||
|
icon = Symbols.play_arrow;
|
||||||
|
} else {
|
||||||
|
icon = Symbols.arrow_downward;
|
||||||
|
}
|
||||||
|
return AnimatedSwitcher(
|
||||||
|
duration: const Duration(milliseconds: 160),
|
||||||
|
transitionBuilder: (child, animation) =>
|
||||||
|
ScaleTransition(scale: animation, child: child),
|
||||||
|
child: Icon(
|
||||||
|
icon,
|
||||||
|
key: ValueKey(icon),
|
||||||
|
color: _accent,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTimeLabel() {
|
||||||
|
return AnimatedBuilder(
|
||||||
|
animation: Listenable.merge([
|
||||||
|
_audio.position,
|
||||||
|
_audio.duration,
|
||||||
|
_audio.playing,
|
||||||
|
]),
|
||||||
|
builder: (context, _) {
|
||||||
|
final elapsed = _audio.position.value;
|
||||||
|
final total = _audio.duration.value;
|
||||||
|
final seconds = elapsed > 0 ? elapsed.round() : total.round();
|
||||||
|
return Text(
|
||||||
|
formatSecondsMmSs(seconds),
|
||||||
|
style: TextStyle(
|
||||||
|
color: widget.textColor.withValues(alpha: 0.7),
|
||||||
|
fontSize: 11,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final waveInactiveColor = widget.isMe
|
final waveInactiveColor = widget.textColor.withValues(alpha: 0.35);
|
||||||
? widget.cs.onPrimaryContainer.withValues(alpha: 0.35)
|
|
||||||
: widget.cs.surfaceContainerHighest;
|
|
||||||
final waveActiveColor = widget.isMe
|
final waveActiveColor = widget.isMe
|
||||||
? widget.cs.onPrimaryContainer.withValues(alpha: 0.7)
|
? widget.cs.onPrimaryContainer
|
||||||
: widget.cs.primary;
|
: widget.cs.primary;
|
||||||
|
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
@@ -218,52 +245,14 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
|||||||
Row(
|
Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
GestureDetector(
|
_buildPlayButton(),
|
||||||
onTap: _togglePlay,
|
|
||||||
child: Container(
|
|
||||||
width: 32,
|
|
||||||
height: 32,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: widget.isMe
|
|
||||||
? widget.cs.onPrimaryContainer.withValues(alpha: 0.12)
|
|
||||||
: widget.cs.primaryContainer,
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
|
||||||
child: _loadingAudio
|
|
||||||
? Padding(
|
|
||||||
padding: const EdgeInsets.all(8),
|
|
||||||
child: SmallSpinner(
|
|
||||||
size: 36,
|
|
||||||
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),
|
const SizedBox(width: 10),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: SizedBox(
|
child: _SeekableWaveform(
|
||||||
height: 26,
|
audio: _audio,
|
||||||
child: ValueListenableBuilder<double>(
|
amps: _amps,
|
||||||
valueListenable: _progress,
|
active: waveActiveColor,
|
||||||
builder: (context, progress, _) => CustomPaint(
|
inactive: waveInactiveColor,
|
||||||
size: Size.infinite,
|
|
||||||
painter: _WaveformPainter(
|
|
||||||
amps: _amps,
|
|
||||||
progress: progress,
|
|
||||||
active: waveActiveColor,
|
|
||||||
inactive: waveInactiveColor,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
@@ -295,18 +284,7 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
|||||||
Row(
|
Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
SizedBox(
|
SizedBox(width: 32, child: Center(child: _buildTimeLabel())),
|
||||||
width: 32,
|
|
||||||
child: Center(
|
|
||||||
child: Text(
|
|
||||||
formatSecondsMmSs(widget.duration),
|
|
||||||
style: TextStyle(
|
|
||||||
color: widget.textColor.withValues(alpha: 0.7),
|
|
||||||
fontSize: 11,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: AnimatedSize(
|
child: AnimatedSize(
|
||||||
@@ -444,17 +422,121 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _SeekableWaveform extends StatefulWidget {
|
||||||
|
final VoiceAudioController audio;
|
||||||
|
final List<int> amps;
|
||||||
|
final Color active;
|
||||||
|
final Color inactive;
|
||||||
|
|
||||||
|
const _SeekableWaveform({
|
||||||
|
required this.audio,
|
||||||
|
required this.amps,
|
||||||
|
required this.active,
|
||||||
|
required this.inactive,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_SeekableWaveform> createState() => _SeekableWaveformState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SeekableWaveformState extends State<_SeekableWaveform> {
|
||||||
|
static const double _hitHeight = 32;
|
||||||
|
static const double _waveHeight = 26;
|
||||||
|
|
||||||
|
double _width = 0;
|
||||||
|
|
||||||
|
VoiceAudioController get _audio => widget.audio;
|
||||||
|
|
||||||
|
double _secondsAt(double dx) {
|
||||||
|
final total = _audio.duration.value;
|
||||||
|
if (_width <= 0 || total <= 0) return 0;
|
||||||
|
return (dx / _width).clamp(0.0, 1.0) * total;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onTapUp(TapUpDetails details) {
|
||||||
|
if (!_audio.downloaded.value) {
|
||||||
|
_audio.toggle();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_audio.seekTo(_secondsAt(details.localPosition.dx));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onDragStart(DragStartDetails details) {
|
||||||
|
if (!_audio.downloaded.value) return;
|
||||||
|
_audio.scrubStart();
|
||||||
|
_audio.scrubTo(_secondsAt(details.localPosition.dx));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onDragUpdate(DragUpdateDetails details) {
|
||||||
|
if (!_audio.scrubbing) return;
|
||||||
|
_audio.scrubTo(_secondsAt(details.localPosition.dx));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onDragEnd(DragEndDetails details) => _audio.scrubEnd();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
_width = constraints.maxWidth;
|
||||||
|
return GestureDetector(
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onTapUp: _onTapUp,
|
||||||
|
onHorizontalDragStart: _onDragStart,
|
||||||
|
onHorizontalDragUpdate: _onDragUpdate,
|
||||||
|
onHorizontalDragEnd: _onDragEnd,
|
||||||
|
onHorizontalDragCancel: _audio.scrubEnd,
|
||||||
|
child: SizedBox(
|
||||||
|
height: _hitHeight,
|
||||||
|
child: Center(
|
||||||
|
child: SizedBox(
|
||||||
|
height: _waveHeight,
|
||||||
|
child: AnimatedBuilder(
|
||||||
|
animation: Listenable.merge([
|
||||||
|
_audio.position,
|
||||||
|
_audio.duration,
|
||||||
|
_audio.playing,
|
||||||
|
_audio.downloaded,
|
||||||
|
]),
|
||||||
|
builder: (context, _) {
|
||||||
|
final total = _audio.duration.value;
|
||||||
|
final progress = total > 0
|
||||||
|
? (_audio.position.value / total).clamp(0.0, 1.0)
|
||||||
|
: 0.0;
|
||||||
|
return CustomPaint(
|
||||||
|
size: Size.infinite,
|
||||||
|
painter: _WaveformPainter(
|
||||||
|
amps: widget.amps,
|
||||||
|
progress: progress,
|
||||||
|
active: widget.active,
|
||||||
|
inactive: widget.inactive,
|
||||||
|
knob: _audio.downloaded.value && progress > 0,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _WaveformPainter extends CustomPainter {
|
class _WaveformPainter extends CustomPainter {
|
||||||
final List<int> amps;
|
final List<int> amps;
|
||||||
final double progress;
|
final double progress;
|
||||||
final Color active;
|
final Color active;
|
||||||
final Color inactive;
|
final Color inactive;
|
||||||
|
final bool knob;
|
||||||
|
|
||||||
const _WaveformPainter({
|
const _WaveformPainter({
|
||||||
required this.amps,
|
required this.amps,
|
||||||
required this.progress,
|
required this.progress,
|
||||||
required this.active,
|
required this.active,
|
||||||
required this.inactive,
|
required this.inactive,
|
||||||
|
this.knob = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -477,6 +559,7 @@ class _WaveformPainter extends CustomPainter {
|
|||||||
track..color = active,
|
track..color = active,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
_paintKnob(canvas, size, center);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -501,6 +584,20 @@ class _WaveformPainter extends CustomPainter {
|
|||||||
paint,
|
paint,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_paintKnob(canvas, size, center);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _paintKnob(Canvas canvas, Size size, double center) {
|
||||||
|
if (!knob) return;
|
||||||
|
final x = (size.width * progress.clamp(0.0, 1.0)).clamp(1.5, size.width - 1.5);
|
||||||
|
canvas.drawRRect(
|
||||||
|
RRect.fromRectAndRadius(
|
||||||
|
Rect.fromLTWH(x - 1.5, 0, 3, size.height),
|
||||||
|
const Radius.circular(1.5),
|
||||||
|
),
|
||||||
|
Paint()..color = active,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -508,5 +605,6 @@ class _WaveformPainter extends CustomPainter {
|
|||||||
old.progress != progress ||
|
old.progress != progress ||
|
||||||
old.active != active ||
|
old.active != active ||
|
||||||
old.inactive != inactive ||
|
old.inactive != inactive ||
|
||||||
|
old.knob != knob ||
|
||||||
!identical(old.amps, amps);
|
!identical(old.amps, amps);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,19 +5,18 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:komet/main.dart';
|
import 'package:komet/main.dart';
|
||||||
import 'package:material_symbols_icons/symbols.dart';
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
import 'package:ogg_opus_player/ogg_opus_player.dart';
|
|
||||||
|
|
||||||
import '../../../backend/modules/messages.dart'
|
import '../../../backend/modules/messages.dart'
|
||||||
show CachedMessage, ContactCache;
|
show CachedMessage, ContactCache;
|
||||||
import '../../../backend/modules/shared_content.dart';
|
import '../../../backend/modules/shared_content.dart';
|
||||||
import '../../../core/cache/info_cache.dart';
|
import '../../../core/cache/info_cache.dart';
|
||||||
|
import '../../../core/media/voice_audio_controller.dart';
|
||||||
import '../../../core/utils/download_history.dart';
|
import '../../../core/utils/download_history.dart';
|
||||||
import '../../../core/utils/download_progress.dart';
|
import '../../../core/utils/download_progress.dart';
|
||||||
import '../../../core/utils/file_download.dart';
|
import '../../../core/utils/file_download.dart';
|
||||||
import '../../../core/utils/format.dart';
|
import '../../../core/utils/format.dart';
|
||||||
import '../../../core/utils/link_opener.dart';
|
import '../../../core/utils/link_opener.dart';
|
||||||
import '../../../core/utils/logger.dart';
|
import '../../../core/utils/logger.dart';
|
||||||
import '../../../core/utils/media_cache.dart';
|
|
||||||
import '../../../core/utils/media_saver.dart';
|
import '../../../core/utils/media_saver.dart';
|
||||||
import '../../../l10n/app_localizations.dart';
|
import '../../../l10n/app_localizations.dart';
|
||||||
import '../../../models/attachment.dart';
|
import '../../../models/attachment.dart';
|
||||||
@@ -1166,81 +1165,39 @@ class _ProfileVoiceTile extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _ProfileVoiceTileState extends State<_ProfileVoiceTile> {
|
class _ProfileVoiceTileState extends State<_ProfileVoiceTile> {
|
||||||
OggOpusPlayer? _player;
|
late final VoiceAudioController _player;
|
||||||
bool _isPlaying = false;
|
|
||||||
bool _loadingAudio = false;
|
|
||||||
Timer? _ticker;
|
|
||||||
final ValueNotifier<double> _progress = ValueNotifier(0.0);
|
|
||||||
|
|
||||||
AudioAttachment get _audio => widget.item.attachment as AudioAttachment;
|
AudioAttachment get _audio => widget.item.attachment as AudioAttachment;
|
||||||
int get _durationSec => ((_audio.duration ?? 0) / 1000).round();
|
int get _durationSec => ((_audio.duration ?? 0) / 1000).round();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_player = VoiceAudioController(
|
||||||
|
cacheName: '${_audio.audioId ?? widget.item.messageId}.ogg',
|
||||||
|
resolveUrl: () async => _audio.fileUrl ?? _audio.baseUrl ?? '',
|
||||||
|
fallbackDuration: Duration(milliseconds: _audio.duration ?? 0),
|
||||||
|
);
|
||||||
|
_player.failure.addListener(_onFailure);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_ticker?.cancel();
|
_player.failure.removeListener(_onFailure);
|
||||||
_player?.state.removeListener(_onPlayerState);
|
_player.dispose();
|
||||||
_player?.dispose();
|
|
||||||
_progress.dispose();
|
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _togglePlay() async {
|
void _onFailure() {
|
||||||
if (_loadingAudio) return;
|
|
||||||
|
|
||||||
if (_player != null) {
|
|
||||||
if (_isPlaying) {
|
|
||||||
_player!.pause();
|
|
||||||
} else {
|
|
||||||
final dur = _audio.duration ?? 0;
|
|
||||||
if (dur > 0 && _player!.currentPosition * 1000 >= dur - 50) {
|
|
||||||
_progress.value = 0;
|
|
||||||
}
|
|
||||||
_player!.play();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final url = _audio.fileUrl ?? _audio.baseUrl ?? '';
|
|
||||||
if (url.isEmpty) return;
|
|
||||||
|
|
||||||
setState(() => _loadingAudio = true);
|
|
||||||
try {
|
|
||||||
final name = '${_audio.audioId ?? widget.item.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) {
|
|
||||||
logger.w('ProfileVoiceTile._togglePlay: $e');
|
|
||||||
if (mounted) showCustomNotification(context, 'Ошибка воспроизведения');
|
|
||||||
} finally {
|
|
||||||
if (mounted) setState(() => _loadingAudio = false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onTick() {
|
|
||||||
final player = _player;
|
|
||||||
final dur = _audio.duration ?? 0;
|
|
||||||
if (player == null || dur <= 0) return;
|
|
||||||
_progress.value = (player.currentPosition * 1000 / dur).clamp(0.0, 1.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onPlayerState() {
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final state = _player?.state.value;
|
switch (_player.failure.value) {
|
||||||
final playing = state == PlayerState.playing;
|
case VoiceAudioFailure.none:
|
||||||
if (playing != _isPlaying) setState(() => _isPlaying = playing);
|
return;
|
||||||
if (state == PlayerState.ended) _progress.value = 1.0;
|
case VoiceAudioFailure.download:
|
||||||
|
showCustomNotification(context, 'Не удалось загрузить аудио');
|
||||||
|
case VoiceAudioFailure.playback:
|
||||||
|
showCustomNotification(context, 'Ошибка воспроизведения');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -1255,7 +1212,7 @@ class _ProfileVoiceTileState extends State<_ProfileVoiceTile> {
|
|||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: _togglePlay,
|
onTap: _player.toggle,
|
||||||
child: Container(
|
child: Container(
|
||||||
width: 46,
|
width: 46,
|
||||||
height: 46,
|
height: 46,
|
||||||
@@ -1263,37 +1220,58 @@ class _ProfileVoiceTileState extends State<_ProfileVoiceTile> {
|
|||||||
color: cs.primary,
|
color: cs.primary,
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
),
|
),
|
||||||
child: _loadingAudio
|
child: AnimatedBuilder(
|
||||||
? const Padding(
|
animation: Listenable.merge([
|
||||||
padding: EdgeInsets.all(13),
|
_player.downloaded,
|
||||||
child: SmallSpinner(size: 36, color: Colors.white),
|
_player.downloadProgress,
|
||||||
)
|
_player.playing,
|
||||||
: ValueListenableBuilder<double>(
|
_player.position,
|
||||||
valueListenable: _progress,
|
_player.duration,
|
||||||
builder: (context, progress, child) => Stack(
|
]),
|
||||||
alignment: Alignment.center,
|
builder: (context, _) {
|
||||||
children: [
|
final download = _player.downloadProgress.value;
|
||||||
if (progress > 0 && progress < 1)
|
if (download != null) {
|
||||||
SizedBox(
|
return Padding(
|
||||||
width: 46,
|
padding: const EdgeInsets.all(11),
|
||||||
height: 46,
|
child: CircularProgressIndicator(
|
||||||
child: CircularProgressIndicator(
|
strokeWidth: 2,
|
||||||
strokeWidth: 2,
|
value: download > 0 ? download : null,
|
||||||
value: progress,
|
|
||||||
color: cs.onPrimary.withValues(alpha: 0.5),
|
|
||||||
backgroundColor: Colors.transparent,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child!,
|
|
||||||
],
|
|
||||||
),
|
|
||||||
child: Icon(
|
|
||||||
_isPlaying ? Symbols.pause : Symbols.play_arrow,
|
|
||||||
color: cs.onPrimary,
|
color: cs.onPrimary,
|
||||||
size: 24,
|
backgroundColor: cs.onPrimary.withValues(alpha: 0.25),
|
||||||
fill: 1,
|
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
|
}
|
||||||
|
final total = _player.duration.value;
|
||||||
|
final progress = total > 0
|
||||||
|
? (_player.position.value / total).clamp(0.0, 1.0)
|
||||||
|
: 0.0;
|
||||||
|
final IconData icon;
|
||||||
|
if (_player.playing.value) {
|
||||||
|
icon = Symbols.pause;
|
||||||
|
} else if (_player.downloaded.value) {
|
||||||
|
icon = Symbols.play_arrow;
|
||||||
|
} else {
|
||||||
|
icon = Symbols.arrow_downward;
|
||||||
|
}
|
||||||
|
return Stack(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
children: [
|
||||||
|
if (progress > 0 && progress < 1)
|
||||||
|
SizedBox(
|
||||||
|
width: 46,
|
||||||
|
height: 46,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
value: progress,
|
||||||
|
color: cs.onPrimary.withValues(alpha: 0.5),
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Icon(icon, color: cs.onPrimary, size: 24, fill: 1),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
|
|||||||
@@ -869,9 +869,12 @@ class MessageBubble extends StatelessWidget {
|
|||||||
nextMessage?.senderId != message.senderId;
|
nextMessage?.senderId != message.senderId;
|
||||||
final showSenderName = _showsSenderName;
|
final showSenderName = _showsSenderName;
|
||||||
|
|
||||||
final maxBubbleWidth = math.min(MediaQuery.sizeOf(context).width * 0.75, 560.0);
|
|
||||||
final keyboard = _inlineKeyboard;
|
final keyboard = _inlineKeyboard;
|
||||||
final isVideoNote = _isVideoNote;
|
final isVideoNote = _isVideoNote;
|
||||||
|
final screenWidth = MediaQuery.sizeOf(context).width;
|
||||||
|
final maxBubbleWidth = isVideoNote
|
||||||
|
? math.min(screenWidth - 24, 560.0)
|
||||||
|
: math.min(screenWidth * 0.75, 560.0);
|
||||||
final noBubbleBackground = isVideoNote || _isSticker || jumboAnimoji != null;
|
final noBubbleBackground = isVideoNote || _isSticker || jumboAnimoji != null;
|
||||||
final bubbleColor = noBubbleBackground
|
final bubbleColor = noBubbleBackground
|
||||||
? Colors.transparent
|
? Colors.transparent
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import 'dart:math' as math;
|
||||||
|
import 'dart:ui';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:komet/frontend/widgets/attachment/bubbles/video_note_bubble.dart';
|
||||||
|
|
||||||
|
const _extent = 210.0;
|
||||||
|
const _knob = 7.0;
|
||||||
|
const _geometry = NoteRingGeometry(extent: _extent, knobRadius: _knob);
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('ручка идёт по ободу от 12 часов по часовой', () {
|
||||||
|
final center = _geometry.center;
|
||||||
|
final r = _geometry.radius;
|
||||||
|
|
||||||
|
expect(_geometry.knobCenter(0).dx, closeTo(center.dx, 0.001));
|
||||||
|
expect(_geometry.knobCenter(0).dy, closeTo(center.dy - r, 0.001));
|
||||||
|
|
||||||
|
expect(_geometry.knobCenter(0.25).dx, closeTo(center.dx + r, 0.001));
|
||||||
|
expect(_geometry.knobCenter(0.25).dy, closeTo(center.dy, 0.001));
|
||||||
|
|
||||||
|
expect(_geometry.knobCenter(0.5).dy, closeTo(center.dy + r, 0.001));
|
||||||
|
expect(_geometry.knobCenter(0.75).dx, closeTo(center.dx - r, 0.001));
|
||||||
|
|
||||||
|
expect(_geometry.knobCenter(1).dx, closeTo(center.dx, 0.001));
|
||||||
|
expect(_geometry.knobCenter(1).dy, closeTo(center.dy - r, 0.001));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ручка целиком помещается в бокс и не обрезается', () {
|
||||||
|
for (var i = 0; i <= 100; i++) {
|
||||||
|
final knob = _geometry.knobCenter(i / 100);
|
||||||
|
expect(knob.dx - _knob, greaterThanOrEqualTo(0));
|
||||||
|
expect(knob.dy - _knob, greaterThanOrEqualTo(0));
|
||||||
|
expect(knob.dx + _knob, lessThanOrEqualTo(_extent));
|
||||||
|
expect(knob.dy + _knob, lessThanOrEqualTo(_extent));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('центр кружка остаётся под тап, обод и ручка ловят драг', () {
|
||||||
|
expect(
|
||||||
|
_geometry.grabs(_geometry.center, 0),
|
||||||
|
isFalse,
|
||||||
|
reason: 'центр должен переключать воспроизведение, а не мотать',
|
||||||
|
);
|
||||||
|
expect(_geometry.grabs(_geometry.knobCenter(0.4), 0.4), isTrue);
|
||||||
|
expect(
|
||||||
|
_geometry.grabs(_geometry.knobCenter(0.4) + const Offset(0, 18), 0.4),
|
||||||
|
isTrue,
|
||||||
|
reason: 'промах мимо ручки в пределах допуска всё ещё считается',
|
||||||
|
);
|
||||||
|
|
||||||
|
final onBand = _geometry.center + Offset(_geometry.radius, 0);
|
||||||
|
expect(_geometry.grabs(onBand, 0), isTrue);
|
||||||
|
|
||||||
|
final wellInside = _geometry.center + Offset(_geometry.radius / 2, 0);
|
||||||
|
expect(_geometry.grabs(wellInside, 0), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('тап по ободу попадает ровно в свою долю', () {
|
||||||
|
for (final progress in [0.0, 0.1, 0.25, 0.5, 0.75, 0.9, 0.999]) {
|
||||||
|
expect(
|
||||||
|
_geometry.progressAt(_geometry.knobCenter(progress)),
|
||||||
|
closeTo(progress, 0.0001),
|
||||||
|
reason: 'тап по точке $progress',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('тап работает на любом удалении от обода вдоль того же луча', () {
|
||||||
|
final center = _geometry.center;
|
||||||
|
for (final radius in [_geometry.radius - 10, _geometry.radius + 5]) {
|
||||||
|
final point = center + Offset(radius, 0);
|
||||||
|
expect(_geometry.progressAt(point), closeTo(0.25, 0.0001));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('захват абсолютный: драг ведёт ручку ровно под пальцем', () {
|
||||||
|
for (final route in [
|
||||||
|
[0.1, 0.3],
|
||||||
|
[0.4, 0.2],
|
||||||
|
[0.7, 0.95],
|
||||||
|
[0.0, 0.15],
|
||||||
|
]) {
|
||||||
|
final from = _geometry.knobCenter(route[0]);
|
||||||
|
final to = _geometry.knobCenter(route[1]);
|
||||||
|
|
||||||
|
final grabbed = _geometry.progressAt(from);
|
||||||
|
expect(
|
||||||
|
grabbed,
|
||||||
|
closeTo(route[0], 1e-4),
|
||||||
|
reason: 'захват должен встать в точку пальца, а не в текущую позицию',
|
||||||
|
);
|
||||||
|
|
||||||
|
final delta = NoteRingGeometry.angleDelta(
|
||||||
|
_geometry.angleAt(from),
|
||||||
|
_geometry.angleAt(to),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
_geometry.advance(grabbed, delta),
|
||||||
|
closeTo(route[1], 1e-4),
|
||||||
|
reason: 'ручка отстала от пальца на маршруте $route',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('прокрутка мимо конца упирается, а не заворачивается в начало', () {
|
||||||
|
final from = _geometry.knobCenter(0.9);
|
||||||
|
final to = _geometry.knobCenter(0.1);
|
||||||
|
final delta = NoteRingGeometry.angleDelta(
|
||||||
|
_geometry.angleAt(from),
|
||||||
|
_geometry.angleAt(to),
|
||||||
|
);
|
||||||
|
expect(delta, greaterThan(0));
|
||||||
|
expect(_geometry.advance(_geometry.progressAt(from), delta), 1.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('переход через 12 часов не перебрасывает позицию', () {
|
||||||
|
final before = _geometry.angleAt(
|
||||||
|
_geometry.center + const Offset(-4, -90),
|
||||||
|
);
|
||||||
|
final after = _geometry.angleAt(_geometry.center + const Offset(4, -90));
|
||||||
|
final delta = NoteRingGeometry.angleDelta(before, after);
|
||||||
|
|
||||||
|
expect(delta.abs(), lessThan(0.3), reason: 'скачок вместо плавного шага');
|
||||||
|
expect(delta, greaterThan(0));
|
||||||
|
expect(_geometry.advance(0.99, delta), 1.0);
|
||||||
|
expect(_geometry.advance(0.01, -delta), greaterThanOrEqualTo(0.0));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('прогресс зажат в границах при бесконечной прокрутке', () {
|
||||||
|
expect(_geometry.advance(0.5, 100 * math.pi), 1.0);
|
||||||
|
expect(_geometry.advance(0.5, -100 * math.pi), 0.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('увеличенный кружок сохраняет пропорции обода', () {
|
||||||
|
const big = NoteRingGeometry(extent: 357, knobRadius: 9);
|
||||||
|
expect(big.radius, closeTo(357 / 2 - 10, 0.001));
|
||||||
|
expect(big.knobCenter(0).dy, closeTo(big.center.dy - big.radius, 0.001));
|
||||||
|
expect(big.knobCenter(0.5).dy + 9, lessThanOrEqualTo(357));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:komet/core/media/ogg_page_writer.dart';
|
||||||
|
import 'package:komet/core/media/opus_ogg_index.dart';
|
||||||
|
|
||||||
|
const int _sampleRate = 48000;
|
||||||
|
const int _packetSamples = 960;
|
||||||
|
const int _preSkip = 312;
|
||||||
|
const int _serial = 0x4b6f6d74;
|
||||||
|
const int _packetsPerPage = 20;
|
||||||
|
|
||||||
|
Uint8List _le16(int value) =>
|
||||||
|
Uint8List(2)..buffer.asByteData().setUint16(0, value, Endian.little);
|
||||||
|
|
||||||
|
Uint8List _le32(int value) =>
|
||||||
|
Uint8List(4)..buffer.asByteData().setUint32(0, value, Endian.little);
|
||||||
|
|
||||||
|
Uint8List _opusHead() {
|
||||||
|
final out = BytesBuilder()
|
||||||
|
..add('OpusHead'.codeUnits)
|
||||||
|
..addByte(1)
|
||||||
|
..addByte(1)
|
||||||
|
..add(_le16(_preSkip))
|
||||||
|
..add(_le32(_sampleRate))
|
||||||
|
..add(_le16(0))
|
||||||
|
..addByte(0);
|
||||||
|
return out.toBytes();
|
||||||
|
}
|
||||||
|
|
||||||
|
Uint8List _opusTags() {
|
||||||
|
const vendor = 'komet-test';
|
||||||
|
final out = BytesBuilder()
|
||||||
|
..add('OpusTags'.codeUnits)
|
||||||
|
..add(_le32(vendor.length))
|
||||||
|
..add(vendor.codeUnits)
|
||||||
|
..add(_le32(0));
|
||||||
|
return out.toBytes();
|
||||||
|
}
|
||||||
|
|
||||||
|
Uint8List _audioPacket(int index) {
|
||||||
|
final out = Uint8List(40);
|
||||||
|
out[0] = 0x08;
|
||||||
|
out[1] = index & 0xff;
|
||||||
|
for (var i = 2; i < out.length; i++) {
|
||||||
|
out[i] = (index + i) & 0xff;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
Uint8List _buildStream(int packetCount, {int endTrim = 0}) {
|
||||||
|
final pages = <Uint8List>[];
|
||||||
|
var sequence = 0;
|
||||||
|
pages.add(
|
||||||
|
OggPageWriter.page(
|
||||||
|
headerType: OggPageWriter.beginningOfStream,
|
||||||
|
granulePos: 0,
|
||||||
|
serial: _serial,
|
||||||
|
sequence: sequence++,
|
||||||
|
packets: [_opusHead()],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
pages.add(
|
||||||
|
OggPageWriter.page(
|
||||||
|
headerType: 0,
|
||||||
|
granulePos: 0,
|
||||||
|
serial: _serial,
|
||||||
|
sequence: sequence++,
|
||||||
|
packets: [_opusTags()],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (var start = 0; start < packetCount; start += _packetsPerPage) {
|
||||||
|
final end = (start + _packetsPerPage).clamp(0, packetCount);
|
||||||
|
final last = end == packetCount;
|
||||||
|
final packets = [
|
||||||
|
for (var i = start; i < end; i++) _audioPacket(i),
|
||||||
|
];
|
||||||
|
pages.add(
|
||||||
|
OggPageWriter.page(
|
||||||
|
headerType: last ? OggPageWriter.endOfStream : 0,
|
||||||
|
granulePos: end * _packetSamples - (last ? endTrim : 0),
|
||||||
|
serial: _serial,
|
||||||
|
sequence: sequence++,
|
||||||
|
packets: packets,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final builder = BytesBuilder();
|
||||||
|
for (final page in pages) {
|
||||||
|
builder.add(page);
|
||||||
|
}
|
||||||
|
return builder.toBytes();
|
||||||
|
}
|
||||||
|
|
||||||
|
int _referenceCrc(Uint8List data) {
|
||||||
|
var crc = 0;
|
||||||
|
for (final byte in data) {
|
||||||
|
crc ^= (byte << 24) & 0xffffffff;
|
||||||
|
for (var bit = 0; bit < 8; bit++) {
|
||||||
|
if ((crc & 0x80000000) != 0) {
|
||||||
|
crc = ((crc << 1) ^ 0x04c11db7) & 0xffffffff;
|
||||||
|
} else {
|
||||||
|
crc = (crc << 1) & 0xffffffff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return crc;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Page {
|
||||||
|
_Page({required this.headerType, required this.granulePos});
|
||||||
|
|
||||||
|
final int headerType;
|
||||||
|
final int granulePos;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<_Page> _verifyPages(Uint8List bytes) {
|
||||||
|
final pages = <_Page>[];
|
||||||
|
var offset = 0;
|
||||||
|
while (offset + 27 <= bytes.length) {
|
||||||
|
expect(
|
||||||
|
String.fromCharCodes(bytes, offset, offset + 4),
|
||||||
|
'OggS',
|
||||||
|
reason: 'страница на смещении $offset',
|
||||||
|
);
|
||||||
|
final view = ByteData.sublistView(bytes, offset);
|
||||||
|
final segmentCount = bytes[offset + 26];
|
||||||
|
final tableStart = offset + 27;
|
||||||
|
var bodyLength = 0;
|
||||||
|
for (var i = 0; i < segmentCount; i++) {
|
||||||
|
bodyLength += bytes[tableStart + i];
|
||||||
|
}
|
||||||
|
final pageEnd = tableStart + segmentCount + bodyLength;
|
||||||
|
expect(pageEnd <= bytes.length, isTrue);
|
||||||
|
|
||||||
|
final stored = view.getUint32(22, Endian.little);
|
||||||
|
final page = Uint8List.fromList(bytes.sublist(offset, pageEnd));
|
||||||
|
ByteData.sublistView(page).setUint32(22, 0, Endian.little);
|
||||||
|
expect(_referenceCrc(page), stored, reason: 'CRC страницы $offset');
|
||||||
|
|
||||||
|
pages.add(
|
||||||
|
_Page(
|
||||||
|
headerType: bytes[offset + 5],
|
||||||
|
granulePos: view.getInt64(6, Endian.little),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
offset = pageEnd;
|
||||||
|
}
|
||||||
|
expect(offset, bytes.length);
|
||||||
|
return pages;
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('crc32 совпадает с побитовой реализацией на любой длине', () {
|
||||||
|
for (var length = 0; length <= 260; length++) {
|
||||||
|
final data = Uint8List.fromList(
|
||||||
|
List.generate(length, (i) => (i * 31 + length) & 0xff),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
OggPageWriter.crc32(data),
|
||||||
|
_referenceCrc(data),
|
||||||
|
reason: 'длина $length',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('crc32 по диапазону не зависит от окружающих байт', () {
|
||||||
|
final payload = Uint8List.fromList(
|
||||||
|
List.generate(1021, (i) => (i * 7) & 0xff),
|
||||||
|
);
|
||||||
|
final padded = Uint8List(payload.length + 9)
|
||||||
|
..fillRange(0, 5, 0xab)
|
||||||
|
..setRange(5, 5 + payload.length, payload)
|
||||||
|
..fillRange(5 + payload.length, payload.length + 9, 0xcd);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
OggPageWriter.crc32(padded, 5, 5 + payload.length),
|
||||||
|
_referenceCrc(payload),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('разбирает длительность из TOC-байтов', () {
|
||||||
|
final index = OpusOggIndex.parse(_buildStream(250))!;
|
||||||
|
expect(
|
||||||
|
index.duration,
|
||||||
|
closeTo((250 * _packetSamples - _preSkip) / _sampleRate, 1e-9),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('не разбирает мусор', () {
|
||||||
|
expect(OpusOggIndex.parse(Uint8List(0)), isNull);
|
||||||
|
expect(
|
||||||
|
OpusOggIndex.parse(Uint8List.fromList(List.filled(512, 7))),
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('срез укорачивает поток ровно на запрошенную позицию', () {
|
||||||
|
final index = OpusOggIndex.parse(_buildStream(500))!;
|
||||||
|
final total = index.duration;
|
||||||
|
|
||||||
|
for (final seconds in [0.02, 0.5, 1.0, 3.3, 7.75]) {
|
||||||
|
final sliced = index.sliceFrom(seconds);
|
||||||
|
expect(sliced, isNotNull, reason: 'срез с $seconds с');
|
||||||
|
final reparsed = OpusOggIndex.parse(sliced!)!;
|
||||||
|
expect(
|
||||||
|
reparsed.duration,
|
||||||
|
closeTo(total - seconds, 1e-6),
|
||||||
|
reason: 'длительность среза с $seconds с',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('срез остаётся валидным Ogg с EOS на последней странице', () {
|
||||||
|
final index = OpusOggIndex.parse(_buildStream(500))!;
|
||||||
|
final sliced = index.sliceFrom(4.0)!;
|
||||||
|
final pages = _verifyPages(sliced);
|
||||||
|
|
||||||
|
expect(pages.length, greaterThan(2));
|
||||||
|
expect(pages.first.headerType & OggPageWriter.beginningOfStream, isNot(0));
|
||||||
|
expect(pages.first.granulePos, 0);
|
||||||
|
expect(pages[1].granulePos, 0);
|
||||||
|
expect(pages.last.headerType & OggPageWriter.endOfStream, isNot(0));
|
||||||
|
|
||||||
|
var previous = -1;
|
||||||
|
for (final page in pages) {
|
||||||
|
expect(page.granulePos, greaterThanOrEqualTo(previous));
|
||||||
|
previous = page.granulePos;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('учитывает обрезку хвоста в финальной granule', () {
|
||||||
|
const trim = 700;
|
||||||
|
final index = OpusOggIndex.parse(_buildStream(300, endTrim: trim))!;
|
||||||
|
expect(
|
||||||
|
index.duration,
|
||||||
|
closeTo((300 * _packetSamples - _preSkip - trim) / _sampleRate, 1e-9),
|
||||||
|
);
|
||||||
|
|
||||||
|
final sliced = index.sliceFrom(2.0);
|
||||||
|
final reparsed = OpusOggIndex.parse(sliced!)!;
|
||||||
|
expect(reparsed.duration, closeTo(index.duration - 2.0, 1e-6));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('срез за пределами длительности не строится', () {
|
||||||
|
final index = OpusOggIndex.parse(_buildStream(100))!;
|
||||||
|
expect(index.sliceFrom(0), isNull);
|
||||||
|
expect(index.sliceFrom(-1), isNull);
|
||||||
|
expect(index.sliceFrom(index.duration + 1), isNull);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:komet/backend/modules/messages.dart';
|
||||||
|
import 'package:komet/frontend/widgets/attachment/bubbles/video_note_bubble.dart';
|
||||||
|
import 'package:komet/frontend/widgets/message_bubble.dart';
|
||||||
|
import 'package:komet/l10n/app_localizations.dart';
|
||||||
|
import 'package:komet/models/attachment.dart';
|
||||||
|
|
||||||
|
const int _me = 1;
|
||||||
|
const int _peer = 7;
|
||||||
|
const int _longNoteMs = 45000;
|
||||||
|
|
||||||
|
CachedMessage _note({required int durationMs}) => CachedMessage(
|
||||||
|
id: '1',
|
||||||
|
accountId: _me,
|
||||||
|
chatId: 2,
|
||||||
|
senderId: _peer,
|
||||||
|
time: DateTime(2026, 1, 1, 5, 46).millisecondsSinceEpoch,
|
||||||
|
status: 'sent',
|
||||||
|
attachments: [
|
||||||
|
VideoAttachment(
|
||||||
|
videoId: 4242,
|
||||||
|
videoToken: 'synthetic-token',
|
||||||
|
videoType: 1,
|
||||||
|
width: 400,
|
||||||
|
height: 400,
|
||||||
|
duration: durationMs,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
Future<void> _pumpNote(WidgetTester tester, {required double screenWidth}) async {
|
||||||
|
tester.view.physicalSize = Size(screenWidth * 2, 2400);
|
||||||
|
tester.view.devicePixelRatio = 2;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
locale: const Locale('ru'),
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
home: Scaffold(
|
||||||
|
body: Align(
|
||||||
|
alignment: Alignment.topLeft,
|
||||||
|
child: MessageBubble(
|
||||||
|
message: _note(durationMs: _longNoteMs),
|
||||||
|
isMe: false,
|
||||||
|
myId: _me,
|
||||||
|
chatType: 'DIALOG',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('у кружка есть длительность слева на одном Y с временем', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await _pumpNote(tester, screenWidth: 390);
|
||||||
|
|
||||||
|
final duration = find.text('0:45');
|
||||||
|
final clock = find.text('05:46');
|
||||||
|
expect(duration, findsOneWidget);
|
||||||
|
expect(clock, findsOneWidget);
|
||||||
|
|
||||||
|
final durationBox = tester.getRect(duration);
|
||||||
|
final clockBox = tester.getRect(clock);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
durationBox.center.dy,
|
||||||
|
closeTo(clockBox.center.dy, 1.0),
|
||||||
|
reason: 'длительность и время должны быть на одном Y',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
durationBox.right,
|
||||||
|
lessThan(clockBox.left),
|
||||||
|
reason: 'длительность должна быть слева от времени',
|
||||||
|
);
|
||||||
|
|
||||||
|
final circle = tester.getRect(find.byType(VideoNoteBubble));
|
||||||
|
expect(durationBox.left, closeTo(circle.left, 8.0));
|
||||||
|
expect(clockBox.right, closeTo(circle.right, 12.0));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('свёрнутый кружок сохраняет базовый размер', (tester) async {
|
||||||
|
await _pumpNote(tester, screenWidth: 390);
|
||||||
|
|
||||||
|
final size = tester.getSize(find.byType(VideoNoteBubble));
|
||||||
|
expect(size.width, closeTo(210, 0.5));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('кружку хватает ширины под увеличение', (tester) async {
|
||||||
|
const expanded = 210 * 1.7;
|
||||||
|
final reached = <double, double>{};
|
||||||
|
|
||||||
|
for (final screenWidth in [360.0, 390.0, 412.0, 800.0]) {
|
||||||
|
await _pumpNote(tester, screenWidth: screenWidth);
|
||||||
|
expect(
|
||||||
|
tester.takeException(),
|
||||||
|
isNull,
|
||||||
|
reason: 'переполнение раскладки при ширине $screenWidth',
|
||||||
|
);
|
||||||
|
|
||||||
|
final box =
|
||||||
|
tester.renderObject(find.byType(VideoNoteBubble)) as RenderBox;
|
||||||
|
final available = box.constraints.maxWidth;
|
||||||
|
reached[screenWidth] = (available / 210).clamp(1.0, 1.7);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
available,
|
||||||
|
lessThanOrEqualTo(screenWidth),
|
||||||
|
reason: 'кружку дали больше ширины, чем есть на экране',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(reached[390.0], closeTo(1.7, 0.001));
|
||||||
|
expect(reached[412.0], closeTo(1.7, 0.001));
|
||||||
|
expect(reached[800.0], closeTo(1.7, 0.001));
|
||||||
|
expect(reached[360.0], closeTo((360 - 24) / 210, 0.001));
|
||||||
|
expect(reached[360.0], greaterThan(1.55));
|
||||||
|
expect(expanded, 357);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user