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:
Jganenokk
2026-08-07 00:23:52 +07:00
co-authored by Claude Opus 5
parent 79c743e86a
commit b9d69ea37b
14 changed files with 2274 additions and 393 deletions
+156
View File
@@ -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;
}
}
+8 -62
View File
@@ -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' ||
+362
View File
@@ -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;
}
+78
View File
@@ -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?>();
}
+331
View 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) {
final message = ctx.message;
if (video.isNote) {
return Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
VideoNoteBubble(
attachment: video,
messageId: message.id,
chatId: message.chatId,
cs: ctx.cs,
),
const SizedBox(height: 6),
ctx.meta(),
],
return VideoNoteBubble(
attachment: video,
messageId: message.id,
chatId: message.chatId,
cs: ctx.cs,
textColor: ctx.text,
meta: ctx.meta(),
);
}
final hasCaption = message.text != null && message.text!.isNotEmpty;
@@ -1,14 +1,17 @@
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:material_symbols_icons/symbols.dart';
import 'package:video_player/video_player.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/logger.dart';
import '../../../../core/utils/media_cache.dart';
import '../../../../models/attachment.dart';
import '../../small_spinner.dart';
@@ -17,6 +20,8 @@ class VideoNoteBubble extends StatefulWidget {
final String messageId;
final int chatId;
final ColorScheme cs;
final Color textColor;
final Widget meta;
const VideoNoteBubble({
super.key,
@@ -24,27 +29,87 @@ class VideoNoteBubble extends StatefulWidget {
required this.messageId,
required this.chatId,
required this.cs,
required this.textColor,
required this.meta,
});
@override
State<VideoNoteBubble> createState() => _VideoNoteBubbleState();
}
class _VideoNoteBubbleState extends State<VideoNoteBubble> {
static const double _size = 210;
class _VideoNoteBubbleState extends State<VideoNoteBubble>
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;
Future<void>? _initializing;
Duration? _pendingSeek;
double? _lastAngle;
bool _playing = false;
bool _loading = 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
void dispose() {
if (_playingNote == this) _playingNote = null;
_PreviewPool.unregister(this);
_expand.dispose();
_ringProgress.dispose();
_controller?.removeListener(_onTick);
_controller?.dispose();
super.dispose();
}
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) {
@@ -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 {
final existing = _controller;
if (existing != null) {
setState(
() => existing.value.isPlaying ? existing.pause() : existing.play(),
);
if (_ready) {
if (_controller!.value.isPlaying) {
await _pause();
} else {
await _play();
}
return;
}
if (_loading) return;
final a = widget.attachment;
final videoId = a.videoId;
final token = a.videoToken;
if (videoId == null || token == null) {
setState(() => _error = true);
return;
}
setState(() => _loading = true);
setState(() {
_loading = true;
_error = false;
});
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 {
final cacheName = 'videonote_$videoId.mp4';
var file = await MediaCache.existing(cacheName);
if (file == null) {
final url = await messagesModule.getVideoUrl(
messageId: widget.messageId,
chatId: widget.chatId,
token: token,
videoId: videoId,
);
if (url == null) throw Exception('no_url');
file = await MediaCache.getOrDownload(cacheName, url);
if (file == null) throw Exception('download');
var target = _pendingSeek;
while (target != null) {
_pendingSeek = null;
await _controller?.seekTo(target);
target = _pendingSeek;
}
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) {
logger.w('VideoNoteBubble._toggle: $e');
if (mounted) {
setState(() {
_loading = false;
_error = true;
});
}
logger.w('VideoNoteBubble._drainSeeks: $e');
} finally {
_seekInFlight = false;
}
}
@override
Widget build(BuildContext context) {
final a = widget.attachment;
final c = _controller;
final ready = c != null && c.value.isInitialized;
final playing = ready && c.value.isPlaying;
final preview = _previewBytes(a.previewData);
return LayoutBuilder(
builder: (context, constraints) {
final maxWidth = constraints.maxWidth.isFinite
? constraints.maxWidth
: _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;
if (ready && c.value.duration.inMilliseconds > 0) {
progress =
c.value.position.inMilliseconds / c.value.duration.inMilliseconds;
}
Widget _buildCircle(double size) {
final controller = _controller;
final ready = _ready;
final playing = ready && _playing && !_scrubbing;
final preview = _preview;
return GestureDetector(
onTap: _toggle,
child: SizedBox(
width: _size,
height: _size,
width: size,
height: size,
child: Stack(
alignment: Alignment.center,
children: [
ClipOval(
child: SizedBox(
width: _size,
height: _size,
child: ready
? FittedBox(
fit: BoxFit.cover,
clipBehavior: Clip.hardEdge,
child: SizedBox(
width: c.value.size.width,
height: c.value.size.height,
child: VideoPlayer(c),
width: size,
height: size,
child: AnimatedSwitcher(
duration: _swapDuration,
child: ready
? SizedBox.expand(
key: const ValueKey('note-video'),
child: FittedBox(
fit: BoxFit.cover,
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)
Container(
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/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:ogg_opus_player/ogg_opus_player.dart';
import 'package:komet/main.dart';
import '../../../../backend/modules/messages.dart';
import '../../../../core/config/app_colors.dart';
import '../../../../core/config/komet_settings.dart';
import '../../../../core/media/voice_audio_controller.dart';
import '../../../../core/utils/format.dart';
import '../../../../core/utils/logger.dart';
import '../../../../core/utils/media_cache.dart';
import '../../custom_notification.dart';
import '../../small_spinner.dart';
@@ -54,15 +51,11 @@ class VoiceMessageBubble extends StatefulWidget {
}
class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
bool _isPlaying = false;
final ValueNotifier<double> _progress = ValueNotifier(0.0);
bool _transcriptionVisible = false;
String? _transcriptionText;
bool _transcriptionLoading = false;
OggOpusPlayer? _player;
bool _loadingAudio = false;
Timer? _ticker;
late final VoiceAudioController _audio;
late final List<int> _amps = _parseWave(widget.waveData);
static List<int> _parseWave(String? data) {
@@ -74,75 +67,30 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
void initState() {
super.initState();
_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
void dispose() {
_ticker?.cancel();
_player?.state.removeListener(_onPlayerState);
_player?.dispose();
_progress.dispose();
_audio.failure.removeListener(_onFailure);
_audio.dispose();
super.dispose();
}
Future<void> _togglePlay() async {
if (_loadingAudio) return;
if (_player != null) {
if (_isPlaying) {
_player!.pause();
} else {
if (widget.duration > 0 &&
_player!.currentPosition >= widget.duration - 0.05) {
_progress.value = 0;
}
_player!.play();
}
return;
}
final url = widget.url;
if (url.isEmpty) return;
setState(() => _loadingAudio = true);
try {
final name = '${widget.audioId ?? widget.messageId}.ogg';
final file = await MediaCache.getOrDownload(name, url);
if (!mounted) return;
if (file == null) {
showCustomNotification(context, 'Не удалось загрузить аудио');
return;
}
final player = OggOpusPlayer(file.path);
_player = player;
player.state.addListener(_onPlayerState);
_ticker = Timer.periodic(
const Duration(milliseconds: 60),
(_) => _onTick(),
);
player.play();
} catch (e) {
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;
void _onFailure() {
if (!mounted) return;
final playing = state == PlayerState.playing;
if (playing != _isPlaying) setState(() => _isPlaying = playing);
if (state == PlayerState.ended) {
_progress.value = 1.0;
switch (_audio.failure.value) {
case VoiceAudioFailure.none:
return;
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);
}
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
Widget build(BuildContext context) {
final waveInactiveColor = widget.isMe
? widget.cs.onPrimaryContainer.withValues(alpha: 0.35)
: widget.cs.surfaceContainerHighest;
final waveInactiveColor = widget.textColor.withValues(alpha: 0.35);
final waveActiveColor = widget.isMe
? widget.cs.onPrimaryContainer.withValues(alpha: 0.7)
? widget.cs.onPrimaryContainer
: widget.cs.primary;
return SizedBox(
@@ -218,52 +245,14 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
GestureDetector(
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,
),
),
),
_buildPlayButton(),
const SizedBox(width: 10),
Expanded(
child: SizedBox(
height: 26,
child: ValueListenableBuilder<double>(
valueListenable: _progress,
builder: (context, progress, _) => CustomPaint(
size: Size.infinite,
painter: _WaveformPainter(
amps: _amps,
progress: progress,
active: waveActiveColor,
inactive: waveInactiveColor,
),
),
),
child: _SeekableWaveform(
audio: _audio,
amps: _amps,
active: waveActiveColor,
inactive: waveInactiveColor,
),
),
const SizedBox(width: 8),
@@ -295,18 +284,7 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 32,
child: Center(
child: Text(
formatSecondsMmSs(widget.duration),
style: TextStyle(
color: widget.textColor.withValues(alpha: 0.7),
fontSize: 11,
),
),
),
),
SizedBox(width: 32, child: Center(child: _buildTimeLabel())),
const SizedBox(width: 10),
Expanded(
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 {
final List<int> amps;
final double progress;
final Color active;
final Color inactive;
final bool knob;
const _WaveformPainter({
required this.amps,
required this.progress,
required this.active,
required this.inactive,
this.knob = false,
});
@override
@@ -477,6 +559,7 @@ class _WaveformPainter extends CustomPainter {
track..color = active,
);
}
_paintKnob(canvas, size, center);
return;
}
@@ -501,6 +584,20 @@ class _WaveformPainter extends CustomPainter {
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
@@ -508,5 +605,6 @@ class _WaveformPainter extends CustomPainter {
old.progress != progress ||
old.active != active ||
old.inactive != inactive ||
old.knob != knob ||
!identical(old.amps, amps);
}
@@ -5,19 +5,18 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:komet/main.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:ogg_opus_player/ogg_opus_player.dart';
import '../../../backend/modules/messages.dart'
show CachedMessage, ContactCache;
import '../../../backend/modules/shared_content.dart';
import '../../../core/cache/info_cache.dart';
import '../../../core/media/voice_audio_controller.dart';
import '../../../core/utils/download_history.dart';
import '../../../core/utils/download_progress.dart';
import '../../../core/utils/file_download.dart';
import '../../../core/utils/format.dart';
import '../../../core/utils/link_opener.dart';
import '../../../core/utils/logger.dart';
import '../../../core/utils/media_cache.dart';
import '../../../core/utils/media_saver.dart';
import '../../../l10n/app_localizations.dart';
import '../../../models/attachment.dart';
@@ -1166,81 +1165,39 @@ class _ProfileVoiceTile extends StatefulWidget {
}
class _ProfileVoiceTileState extends State<_ProfileVoiceTile> {
OggOpusPlayer? _player;
bool _isPlaying = false;
bool _loadingAudio = false;
Timer? _ticker;
final ValueNotifier<double> _progress = ValueNotifier(0.0);
late final VoiceAudioController _player;
AudioAttachment get _audio => widget.item.attachment as AudioAttachment;
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
void dispose() {
_ticker?.cancel();
_player?.state.removeListener(_onPlayerState);
_player?.dispose();
_progress.dispose();
_player.failure.removeListener(_onFailure);
_player.dispose();
super.dispose();
}
Future<void> _togglePlay() async {
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() {
void _onFailure() {
if (!mounted) return;
final state = _player?.state.value;
final playing = state == PlayerState.playing;
if (playing != _isPlaying) setState(() => _isPlaying = playing);
if (state == PlayerState.ended) _progress.value = 1.0;
switch (_player.failure.value) {
case VoiceAudioFailure.none:
return;
case VoiceAudioFailure.download:
showCustomNotification(context, 'Не удалось загрузить аудио');
case VoiceAudioFailure.playback:
showCustomNotification(context, 'Ошибка воспроизведения');
}
}
@override
@@ -1255,7 +1212,7 @@ class _ProfileVoiceTileState extends State<_ProfileVoiceTile> {
child: Row(
children: [
GestureDetector(
onTap: _togglePlay,
onTap: _player.toggle,
child: Container(
width: 46,
height: 46,
@@ -1263,37 +1220,58 @@ class _ProfileVoiceTileState extends State<_ProfileVoiceTile> {
color: cs.primary,
shape: BoxShape.circle,
),
child: _loadingAudio
? const Padding(
padding: EdgeInsets.all(13),
child: SmallSpinner(size: 36, color: Colors.white),
)
: ValueListenableBuilder<double>(
valueListenable: _progress,
builder: (context, progress, child) => 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,
),
),
child!,
],
),
child: Icon(
_isPlaying ? Symbols.pause : Symbols.play_arrow,
child: AnimatedBuilder(
animation: Listenable.merge([
_player.downloaded,
_player.downloadProgress,
_player.playing,
_player.position,
_player.duration,
]),
builder: (context, _) {
final download = _player.downloadProgress.value;
if (download != null) {
return Padding(
padding: const EdgeInsets.all(11),
child: CircularProgressIndicator(
strokeWidth: 2,
value: download > 0 ? download : null,
color: cs.onPrimary,
size: 24,
fill: 1,
backgroundColor: cs.onPrimary.withValues(alpha: 0.25),
),
),
);
}
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),
+4 -1
View File
@@ -869,9 +869,12 @@ class MessageBubble extends StatelessWidget {
nextMessage?.senderId != message.senderId;
final showSenderName = _showsSenderName;
final maxBubbleWidth = math.min(MediaQuery.sizeOf(context).width * 0.75, 560.0);
final keyboard = _inlineKeyboard;
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 bubbleColor = noBubbleBackground
? Colors.transparent