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
+141
View File
@@ -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));
});
}
+253
View File
@@ -0,0 +1,253 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:komet/core/media/ogg_page_writer.dart';
import 'package:komet/core/media/opus_ogg_index.dart';
const int _sampleRate = 48000;
const int _packetSamples = 960;
const int _preSkip = 312;
const int _serial = 0x4b6f6d74;
const int _packetsPerPage = 20;
Uint8List _le16(int value) =>
Uint8List(2)..buffer.asByteData().setUint16(0, value, Endian.little);
Uint8List _le32(int value) =>
Uint8List(4)..buffer.asByteData().setUint32(0, value, Endian.little);
Uint8List _opusHead() {
final out = BytesBuilder()
..add('OpusHead'.codeUnits)
..addByte(1)
..addByte(1)
..add(_le16(_preSkip))
..add(_le32(_sampleRate))
..add(_le16(0))
..addByte(0);
return out.toBytes();
}
Uint8List _opusTags() {
const vendor = 'komet-test';
final out = BytesBuilder()
..add('OpusTags'.codeUnits)
..add(_le32(vendor.length))
..add(vendor.codeUnits)
..add(_le32(0));
return out.toBytes();
}
Uint8List _audioPacket(int index) {
final out = Uint8List(40);
out[0] = 0x08;
out[1] = index & 0xff;
for (var i = 2; i < out.length; i++) {
out[i] = (index + i) & 0xff;
}
return out;
}
Uint8List _buildStream(int packetCount, {int endTrim = 0}) {
final pages = <Uint8List>[];
var sequence = 0;
pages.add(
OggPageWriter.page(
headerType: OggPageWriter.beginningOfStream,
granulePos: 0,
serial: _serial,
sequence: sequence++,
packets: [_opusHead()],
),
);
pages.add(
OggPageWriter.page(
headerType: 0,
granulePos: 0,
serial: _serial,
sequence: sequence++,
packets: [_opusTags()],
),
);
for (var start = 0; start < packetCount; start += _packetsPerPage) {
final end = (start + _packetsPerPage).clamp(0, packetCount);
final last = end == packetCount;
final packets = [
for (var i = start; i < end; i++) _audioPacket(i),
];
pages.add(
OggPageWriter.page(
headerType: last ? OggPageWriter.endOfStream : 0,
granulePos: end * _packetSamples - (last ? endTrim : 0),
serial: _serial,
sequence: sequence++,
packets: packets,
),
);
}
final builder = BytesBuilder();
for (final page in pages) {
builder.add(page);
}
return builder.toBytes();
}
int _referenceCrc(Uint8List data) {
var crc = 0;
for (final byte in data) {
crc ^= (byte << 24) & 0xffffffff;
for (var bit = 0; bit < 8; bit++) {
if ((crc & 0x80000000) != 0) {
crc = ((crc << 1) ^ 0x04c11db7) & 0xffffffff;
} else {
crc = (crc << 1) & 0xffffffff;
}
}
}
return crc;
}
class _Page {
_Page({required this.headerType, required this.granulePos});
final int headerType;
final int granulePos;
}
List<_Page> _verifyPages(Uint8List bytes) {
final pages = <_Page>[];
var offset = 0;
while (offset + 27 <= bytes.length) {
expect(
String.fromCharCodes(bytes, offset, offset + 4),
'OggS',
reason: 'страница на смещении $offset',
);
final view = ByteData.sublistView(bytes, offset);
final segmentCount = bytes[offset + 26];
final tableStart = offset + 27;
var bodyLength = 0;
for (var i = 0; i < segmentCount; i++) {
bodyLength += bytes[tableStart + i];
}
final pageEnd = tableStart + segmentCount + bodyLength;
expect(pageEnd <= bytes.length, isTrue);
final stored = view.getUint32(22, Endian.little);
final page = Uint8List.fromList(bytes.sublist(offset, pageEnd));
ByteData.sublistView(page).setUint32(22, 0, Endian.little);
expect(_referenceCrc(page), stored, reason: 'CRC страницы $offset');
pages.add(
_Page(
headerType: bytes[offset + 5],
granulePos: view.getInt64(6, Endian.little),
),
);
offset = pageEnd;
}
expect(offset, bytes.length);
return pages;
}
void main() {
test('crc32 совпадает с побитовой реализацией на любой длине', () {
for (var length = 0; length <= 260; length++) {
final data = Uint8List.fromList(
List.generate(length, (i) => (i * 31 + length) & 0xff),
);
expect(
OggPageWriter.crc32(data),
_referenceCrc(data),
reason: 'длина $length',
);
}
});
test('crc32 по диапазону не зависит от окружающих байт', () {
final payload = Uint8List.fromList(
List.generate(1021, (i) => (i * 7) & 0xff),
);
final padded = Uint8List(payload.length + 9)
..fillRange(0, 5, 0xab)
..setRange(5, 5 + payload.length, payload)
..fillRange(5 + payload.length, payload.length + 9, 0xcd);
expect(
OggPageWriter.crc32(padded, 5, 5 + payload.length),
_referenceCrc(payload),
);
});
test('разбирает длительность из TOC-байтов', () {
final index = OpusOggIndex.parse(_buildStream(250))!;
expect(
index.duration,
closeTo((250 * _packetSamples - _preSkip) / _sampleRate, 1e-9),
);
});
test('не разбирает мусор', () {
expect(OpusOggIndex.parse(Uint8List(0)), isNull);
expect(
OpusOggIndex.parse(Uint8List.fromList(List.filled(512, 7))),
isNull,
);
});
test('срез укорачивает поток ровно на запрошенную позицию', () {
final index = OpusOggIndex.parse(_buildStream(500))!;
final total = index.duration;
for (final seconds in [0.02, 0.5, 1.0, 3.3, 7.75]) {
final sliced = index.sliceFrom(seconds);
expect(sliced, isNotNull, reason: 'срез с $seconds с');
final reparsed = OpusOggIndex.parse(sliced!)!;
expect(
reparsed.duration,
closeTo(total - seconds, 1e-6),
reason: 'длительность среза с $seconds с',
);
}
});
test('срез остаётся валидным Ogg с EOS на последней странице', () {
final index = OpusOggIndex.parse(_buildStream(500))!;
final sliced = index.sliceFrom(4.0)!;
final pages = _verifyPages(sliced);
expect(pages.length, greaterThan(2));
expect(pages.first.headerType & OggPageWriter.beginningOfStream, isNot(0));
expect(pages.first.granulePos, 0);
expect(pages[1].granulePos, 0);
expect(pages.last.headerType & OggPageWriter.endOfStream, isNot(0));
var previous = -1;
for (final page in pages) {
expect(page.granulePos, greaterThanOrEqualTo(previous));
previous = page.granulePos;
}
});
test('учитывает обрезку хвоста в финальной granule', () {
const trim = 700;
final index = OpusOggIndex.parse(_buildStream(300, endTrim: trim))!;
expect(
index.duration,
closeTo((300 * _packetSamples - _preSkip - trim) / _sampleRate, 1e-9),
);
final sliced = index.sliceFrom(2.0);
final reparsed = OpusOggIndex.parse(sliced!)!;
expect(reparsed.duration, closeTo(index.duration - 2.0, 1e-6));
});
test('срез за пределами длительности не строится', () {
final index = OpusOggIndex.parse(_buildStream(100))!;
expect(index.sliceFrom(0), isNull);
expect(index.sliceFrom(-1), isNull);
expect(index.sliceFrom(index.duration + 1), isNull);
});
}
+126
View File
@@ -0,0 +1,126 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:komet/backend/modules/messages.dart';
import 'package:komet/frontend/widgets/attachment/bubbles/video_note_bubble.dart';
import 'package:komet/frontend/widgets/message_bubble.dart';
import 'package:komet/l10n/app_localizations.dart';
import 'package:komet/models/attachment.dart';
const int _me = 1;
const int _peer = 7;
const int _longNoteMs = 45000;
CachedMessage _note({required int durationMs}) => CachedMessage(
id: '1',
accountId: _me,
chatId: 2,
senderId: _peer,
time: DateTime(2026, 1, 1, 5, 46).millisecondsSinceEpoch,
status: 'sent',
attachments: [
VideoAttachment(
videoId: 4242,
videoToken: 'synthetic-token',
videoType: 1,
width: 400,
height: 400,
duration: durationMs,
),
],
);
Future<void> _pumpNote(WidgetTester tester, {required double screenWidth}) async {
tester.view.physicalSize = Size(screenWidth * 2, 2400);
tester.view.devicePixelRatio = 2;
addTearDown(tester.view.reset);
await tester.pumpWidget(
MaterialApp(
locale: const Locale('ru'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: Align(
alignment: Alignment.topLeft,
child: MessageBubble(
message: _note(durationMs: _longNoteMs),
isMe: false,
myId: _me,
chatType: 'DIALOG',
),
),
),
),
);
await tester.pump();
}
void main() {
testWidgets('у кружка есть длительность слева на одном Y с временем', (
tester,
) async {
await _pumpNote(tester, screenWidth: 390);
final duration = find.text('0:45');
final clock = find.text('05:46');
expect(duration, findsOneWidget);
expect(clock, findsOneWidget);
final durationBox = tester.getRect(duration);
final clockBox = tester.getRect(clock);
expect(
durationBox.center.dy,
closeTo(clockBox.center.dy, 1.0),
reason: 'длительность и время должны быть на одном Y',
);
expect(
durationBox.right,
lessThan(clockBox.left),
reason: 'длительность должна быть слева от времени',
);
final circle = tester.getRect(find.byType(VideoNoteBubble));
expect(durationBox.left, closeTo(circle.left, 8.0));
expect(clockBox.right, closeTo(circle.right, 12.0));
});
testWidgets('свёрнутый кружок сохраняет базовый размер', (tester) async {
await _pumpNote(tester, screenWidth: 390);
final size = tester.getSize(find.byType(VideoNoteBubble));
expect(size.width, closeTo(210, 0.5));
});
testWidgets('кружку хватает ширины под увеличение', (tester) async {
const expanded = 210 * 1.7;
final reached = <double, double>{};
for (final screenWidth in [360.0, 390.0, 412.0, 800.0]) {
await _pumpNote(tester, screenWidth: screenWidth);
expect(
tester.takeException(),
isNull,
reason: 'переполнение раскладки при ширине $screenWidth',
);
final box =
tester.renderObject(find.byType(VideoNoteBubble)) as RenderBox;
final available = box.constraints.maxWidth;
reached[screenWidth] = (available / 210).clamp(1.0, 1.7);
expect(
available,
lessThanOrEqualTo(screenWidth),
reason: 'кружку дали больше ширины, чем есть на экране',
);
}
expect(reached[390.0], closeTo(1.7, 0.001));
expect(reached[412.0], closeTo(1.7, 0.001));
expect(reached[800.0], closeTo(1.7, 0.001));
expect(reached[360.0], closeTo((360 - 24) / 210, 0.001));
expect(reached[360.0], greaterThan(1.55));
expect(expanded, 357);
});
}