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
@@ -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 '../utils/logger.dart';
|
||||
import 'ogg_page_writer.dart';
|
||||
|
||||
/// Кодирует PCM в Ogg/Opus через libopus (FFI) на платформах, где у системы нет
|
||||
/// своего Opus-энкодера (Windows). Сырые Opus-пакеты выдаёт [opus_dart], а
|
||||
@@ -176,71 +177,16 @@ class OpusOggEncoder {
|
||||
required int granulePos,
|
||||
required int seq,
|
||||
required List<Uint8List> packets,
|
||||
}) {
|
||||
final segs = <int>[];
|
||||
for (final p in packets) {
|
||||
var len = p.length;
|
||||
while (len >= 255) {
|
||||
segs.add(255);
|
||||
len -= 255;
|
||||
}
|
||||
segs.add(len);
|
||||
}
|
||||
|
||||
final header = Uint8List(27 + segs.length);
|
||||
final hd = ByteData.sublistView(header);
|
||||
header.setRange(0, 4, _ascii('OggS'));
|
||||
hd.setUint8(4, 0); // stream structure version
|
||||
hd.setUint8(5, headerType);
|
||||
hd.setUint64(6, granulePos, Endian.little);
|
||||
hd.setUint32(14, _serial, Endian.little);
|
||||
hd.setUint32(18, seq, Endian.little);
|
||||
hd.setUint32(22, 0, Endian.little); // CRC placeholder
|
||||
hd.setUint8(26, segs.length);
|
||||
header.setRange(27, 27 + segs.length, segs);
|
||||
|
||||
final body = BytesBuilder();
|
||||
body.add(header);
|
||||
for (final p in packets) {
|
||||
body.add(p);
|
||||
}
|
||||
final page = body.toBytes();
|
||||
|
||||
final crc = _crc32(page);
|
||||
ByteData.sublistView(page).setUint32(22, crc, Endian.little);
|
||||
return page;
|
||||
}
|
||||
}) => OggPageWriter.page(
|
||||
headerType: headerType,
|
||||
granulePos: granulePos,
|
||||
serial: _serial,
|
||||
sequence: seq,
|
||||
packets: packets,
|
||||
);
|
||||
|
||||
static Uint8List _ascii(String s) => Uint8List.fromList(s.codeUnits);
|
||||
|
||||
static final Uint32List _crcTable = _buildCrcTable();
|
||||
|
||||
static Uint32List _buildCrcTable() {
|
||||
final t = Uint32List(256);
|
||||
for (var i = 0; i < 256; i++) {
|
||||
var r = (i << 24) & 0xffffffff;
|
||||
for (var j = 0; j < 8; j++) {
|
||||
if ((r & 0x80000000) != 0) {
|
||||
r = ((r << 1) ^ 0x04c11db7) & 0xffffffff;
|
||||
} else {
|
||||
r = (r << 1) & 0xffffffff;
|
||||
}
|
||||
}
|
||||
t[i] = r;
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
||||
static int _crc32(Uint8List data) {
|
||||
var crc = 0;
|
||||
for (final b in data) {
|
||||
crc =
|
||||
(((crc << 8) & 0xffffffff) ^ _crcTable[((crc >> 24) & 0xff) ^ b]) &
|
||||
0xffffffff;
|
||||
}
|
||||
return crc & 0xffffffff;
|
||||
}
|
||||
|
||||
static Int16List? _pcmFromWav(Uint8List bytes) {
|
||||
if (bytes.length < 12) return null;
|
||||
if (String.fromCharCodes(bytes, 0, 4) != 'RIFF' ||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user