From b9d69ea37b4b224d099e050ca2ed2259294b2925 Mon Sep 17 00:00:00 2001 From: Jganenokk Date: Fri, 7 Aug 2026 00:23:52 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=BD=D0=BE=D1=80=D0=BC=20=D0=BA=D1=80?= =?UTF-8?q?=D1=83=D0=B6=D0=BA=D0=B8=20=D0=B8=20=D0=B3=D1=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Голосовые: индекс opus/ogg со срезами страниц, общий аудиоконтроллер воспроизведения, переписанный ogg-энкодер. Кружки: предзагрузка видео, геометрия кольца прогресса. third_party/kolibri в .gitignore — локальный dev-клон, зависимость берётся с pub.dev. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VMWkmNx3Ns9SEuKeGEiaR4 --- .gitignore | 1 + lib/core/media/ogg_page_writer.dart | 156 +++++ lib/core/media/opus_ogg_encoder.dart | 70 +-- lib/core/media/opus_ogg_index.dart | 362 +++++++++++ lib/core/media/video_note_preloader.dart | 78 +++ lib/core/media/voice_audio_controller.dart | 331 ++++++++++ .../attachment/bubbles/video_bubble.dart | 19 +- .../attachment/bubbles/video_note_bubble.dart | 595 +++++++++++++++--- .../attachment/bubbles/voice_bubble.dart | 358 +++++++---- .../chat_info/shared_content_tabs.dart | 172 +++-- lib/frontend/widgets/message_bubble.dart | 5 +- test/note_ring_geometry_test.dart | 141 +++++ test/opus_ogg_slice_test.dart | 253 ++++++++ test/video_note_layout_test.dart | 126 ++++ 14 files changed, 2274 insertions(+), 393 deletions(-) create mode 100644 lib/core/media/ogg_page_writer.dart create mode 100644 lib/core/media/opus_ogg_index.dart create mode 100644 lib/core/media/video_note_preloader.dart create mode 100644 lib/core/media/voice_audio_controller.dart create mode 100644 test/note_ring_geometry_test.dart create mode 100644 test/opus_ogg_slice_test.dart create mode 100644 test/video_note_layout_test.dart diff --git a/.gitignore b/.gitignore index 7fb1504..d108e96 100644 --- a/.gitignore +++ b/.gitignore @@ -153,3 +153,4 @@ test/live_server_probe_test.dart # Rust core at third_party/kolibri/kolibri-net instead of the published ones pubspec_overrides.yaml .cargo/ +third_party/kolibri/ diff --git a/lib/core/media/ogg_page_writer.dart b/lib/core/media/ogg_page_writer.dart new file mode 100644 index 0000000..624cc8a --- /dev/null +++ b/lib/core/media/ogg_page_writer.dart @@ -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 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 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 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 _crcTables = _buildCrcTables(); + + static List _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 = [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; + } +} diff --git a/lib/core/media/opus_ogg_encoder.dart b/lib/core/media/opus_ogg_encoder.dart index 5104f08..b49a60a 100644 --- a/lib/core/media/opus_ogg_encoder.dart +++ b/lib/core/media/opus_ogg_encoder.dart @@ -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 packets, - }) { - final segs = []; - 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' || diff --git a/lib/core/media/opus_ogg_index.dart b/lib/core/media/opus_ogg_index.dart new file mode 100644 index 0000000..50ce929 --- /dev/null +++ b/lib/core/media/opus_ogg_index.dart @@ -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 packets, + required List 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 _packets; + final List _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 = []; + final pendingParts = []; + 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 = []; + 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 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; +} diff --git a/lib/core/media/video_note_preloader.dart b/lib/core/media/video_note_preloader.dart new file mode 100644 index 0000000..f48fbde --- /dev/null +++ b/lib/core/media/video_note_preloader.dart @@ -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 load( + String cacheName, + Future 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 _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 Function() resolveUrl; + final void Function(double progress)? onProgress; + final bool Function()? cancelled; + final Completer result = Completer(); +} diff --git a/lib/core/media/voice_audio_controller.dart b/lib/core/media/voice_audio_controller.dart new file mode 100644 index 0000000..5c8634a --- /dev/null +++ b/lib/core/media/voice_audio_controller.dart @@ -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 Function() resolveUrl; + + static const double _endEpsilon = 0.05; + + static VoiceAudioController? _active; + static int _sliceCounter = 0; + static Directory? _sliceDir; + + final ValueNotifier playing = ValueNotifier(false); + final ValueNotifier position = ValueNotifier(0); + final ValueNotifier duration; + final ValueNotifier failure = ValueNotifier( + VoiceAudioFailure.none, + ); + + ValueListenable get downloadProgress => + MediaDownloadProgress.notifier(cacheName); + + ValueListenable get downloaded => MediaCache.presence(cacheName); + + bool get scrubbing => _scrubbing; + + File? _file; + OpusOggIndex? _index; + OggOpusPlayer? _player; + File? _slice; + Timer? _ticker; + Future? _loading; + double _sliceOffset = 0; + int _startGeneration = 0; + bool _scrubbing = false; + bool _resumeAfterScrub = false; + bool _finished = false; + bool _disposed = false; + + Future toggle() async { + if (playing.value) { + pause(); + return; + } + await play(); + } + + Future 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 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 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 _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 _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 _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 _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 _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 _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(); + } +} diff --git a/lib/frontend/widgets/attachment/bubbles/video_bubble.dart b/lib/frontend/widgets/attachment/bubbles/video_bubble.dart index 852bb48..c5fa61c 100644 --- a/lib/frontend/widgets/attachment/bubbles/video_bubble.dart +++ b/lib/frontend/widgets/attachment/bubbles/video_bubble.dart @@ -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; diff --git a/lib/frontend/widgets/attachment/bubbles/video_note_bubble.dart b/lib/frontend/widgets/attachment/bubbles/video_note_bubble.dart index e29850b..6d10a8a 100644 --- a/lib/frontend/widgets/attachment/bubbles/video_note_bubble.dart +++ b/lib/frontend/widgets/attachment/bubbles/video_note_bubble.dart @@ -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 createState() => _VideoNoteBubbleState(); } -class _VideoNoteBubbleState extends State { - static const double _size = 210; +class _VideoNoteBubbleState extends State + 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 _ringProgress = ValueNotifier(0); + Uint8List? _preview; VideoPlayerController? _controller; + Future? _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 { } } + Future _preload() async { + final file = await _fetch(priority: false); + if (file == null || !mounted) return; + await _ensureController(file); + } + + Future _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 _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 _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 _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 _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 _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 _ringDragEnd() async { + if (!_scrubbing) return; + setState(() { + _scrubbing = false; + _lastAngle = null; + }); + if (!_resumeAfterScrub) return; + _resumeAfterScrub = false; + await _controller?.play(); + if (mounted) setState(() {}); + } + + Future _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 { ), ); } + + 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( + 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 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); } diff --git a/lib/frontend/widgets/attachment/bubbles/voice_bubble.dart b/lib/frontend/widgets/attachment/bubbles/voice_bubble.dart index f08fd4e..2d3c6b7 100644 --- a/lib/frontend/widgets/attachment/bubbles/voice_bubble.dart +++ b/lib/frontend/widgets/attachment/bubbles/voice_bubble.dart @@ -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 { - bool _isPlaying = false; - final ValueNotifier _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 _amps = _parseWave(widget.waveData); static List _parseWave(String? data) { @@ -74,75 +67,30 @@ class _VoiceMessageBubbleState extends State { 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 _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 { 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 { 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( - 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 { 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 { } } +class _SeekableWaveform extends StatefulWidget { + final VoiceAudioController audio; + final List 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 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); } diff --git a/lib/frontend/widgets/chat_info/shared_content_tabs.dart b/lib/frontend/widgets/chat_info/shared_content_tabs.dart index d5cb0da..d41ad3a 100644 --- a/lib/frontend/widgets/chat_info/shared_content_tabs.dart +++ b/lib/frontend/widgets/chat_info/shared_content_tabs.dart @@ -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 _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 _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( - 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), diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index 82dee10..5f4ea41 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -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 diff --git a/test/note_ring_geometry_test.dart b/test/note_ring_geometry_test.dart new file mode 100644 index 0000000..39c2952 --- /dev/null +++ b/test/note_ring_geometry_test.dart @@ -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)); + }); +} diff --git a/test/opus_ogg_slice_test.dart b/test/opus_ogg_slice_test.dart new file mode 100644 index 0000000..30ce00b --- /dev/null +++ b/test/opus_ogg_slice_test.dart @@ -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 = []; + 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); + }); +} diff --git a/test/video_note_layout_test.dart b/test/video_note_layout_test.dart new file mode 100644 index 0000000..74ff904 --- /dev/null +++ b/test/video_note_layout_test.dart @@ -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 _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 = {}; + + 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); + }); +}