feat: норм кружки и гс
Голосовые: индекс opus/ogg со срезами страниц, общий аудиоконтроллер воспроизведения, переписанный ogg-энкодер. Кружки: предзагрузка видео, геометрия кольца прогресса. third_party/kolibri в .gitignore — локальный dev-клон, зависимость берётся с pub.dev. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VMWkmNx3Ns9SEuKeGEiaR4
This commit is contained in:
co-authored by
Claude Opus 5
parent
79c743e86a
commit
b9d69ea37b
@@ -0,0 +1,141 @@
|
||||
import 'dart:math' as math;
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:komet/frontend/widgets/attachment/bubbles/video_note_bubble.dart';
|
||||
|
||||
const _extent = 210.0;
|
||||
const _knob = 7.0;
|
||||
const _geometry = NoteRingGeometry(extent: _extent, knobRadius: _knob);
|
||||
|
||||
void main() {
|
||||
test('ручка идёт по ободу от 12 часов по часовой', () {
|
||||
final center = _geometry.center;
|
||||
final r = _geometry.radius;
|
||||
|
||||
expect(_geometry.knobCenter(0).dx, closeTo(center.dx, 0.001));
|
||||
expect(_geometry.knobCenter(0).dy, closeTo(center.dy - r, 0.001));
|
||||
|
||||
expect(_geometry.knobCenter(0.25).dx, closeTo(center.dx + r, 0.001));
|
||||
expect(_geometry.knobCenter(0.25).dy, closeTo(center.dy, 0.001));
|
||||
|
||||
expect(_geometry.knobCenter(0.5).dy, closeTo(center.dy + r, 0.001));
|
||||
expect(_geometry.knobCenter(0.75).dx, closeTo(center.dx - r, 0.001));
|
||||
|
||||
expect(_geometry.knobCenter(1).dx, closeTo(center.dx, 0.001));
|
||||
expect(_geometry.knobCenter(1).dy, closeTo(center.dy - r, 0.001));
|
||||
});
|
||||
|
||||
test('ручка целиком помещается в бокс и не обрезается', () {
|
||||
for (var i = 0; i <= 100; i++) {
|
||||
final knob = _geometry.knobCenter(i / 100);
|
||||
expect(knob.dx - _knob, greaterThanOrEqualTo(0));
|
||||
expect(knob.dy - _knob, greaterThanOrEqualTo(0));
|
||||
expect(knob.dx + _knob, lessThanOrEqualTo(_extent));
|
||||
expect(knob.dy + _knob, lessThanOrEqualTo(_extent));
|
||||
}
|
||||
});
|
||||
|
||||
test('центр кружка остаётся под тап, обод и ручка ловят драг', () {
|
||||
expect(
|
||||
_geometry.grabs(_geometry.center, 0),
|
||||
isFalse,
|
||||
reason: 'центр должен переключать воспроизведение, а не мотать',
|
||||
);
|
||||
expect(_geometry.grabs(_geometry.knobCenter(0.4), 0.4), isTrue);
|
||||
expect(
|
||||
_geometry.grabs(_geometry.knobCenter(0.4) + const Offset(0, 18), 0.4),
|
||||
isTrue,
|
||||
reason: 'промах мимо ручки в пределах допуска всё ещё считается',
|
||||
);
|
||||
|
||||
final onBand = _geometry.center + Offset(_geometry.radius, 0);
|
||||
expect(_geometry.grabs(onBand, 0), isTrue);
|
||||
|
||||
final wellInside = _geometry.center + Offset(_geometry.radius / 2, 0);
|
||||
expect(_geometry.grabs(wellInside, 0), isFalse);
|
||||
});
|
||||
|
||||
test('тап по ободу попадает ровно в свою долю', () {
|
||||
for (final progress in [0.0, 0.1, 0.25, 0.5, 0.75, 0.9, 0.999]) {
|
||||
expect(
|
||||
_geometry.progressAt(_geometry.knobCenter(progress)),
|
||||
closeTo(progress, 0.0001),
|
||||
reason: 'тап по точке $progress',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('тап работает на любом удалении от обода вдоль того же луча', () {
|
||||
final center = _geometry.center;
|
||||
for (final radius in [_geometry.radius - 10, _geometry.radius + 5]) {
|
||||
final point = center + Offset(radius, 0);
|
||||
expect(_geometry.progressAt(point), closeTo(0.25, 0.0001));
|
||||
}
|
||||
});
|
||||
|
||||
test('захват абсолютный: драг ведёт ручку ровно под пальцем', () {
|
||||
for (final route in [
|
||||
[0.1, 0.3],
|
||||
[0.4, 0.2],
|
||||
[0.7, 0.95],
|
||||
[0.0, 0.15],
|
||||
]) {
|
||||
final from = _geometry.knobCenter(route[0]);
|
||||
final to = _geometry.knobCenter(route[1]);
|
||||
|
||||
final grabbed = _geometry.progressAt(from);
|
||||
expect(
|
||||
grabbed,
|
||||
closeTo(route[0], 1e-4),
|
||||
reason: 'захват должен встать в точку пальца, а не в текущую позицию',
|
||||
);
|
||||
|
||||
final delta = NoteRingGeometry.angleDelta(
|
||||
_geometry.angleAt(from),
|
||||
_geometry.angleAt(to),
|
||||
);
|
||||
expect(
|
||||
_geometry.advance(grabbed, delta),
|
||||
closeTo(route[1], 1e-4),
|
||||
reason: 'ручка отстала от пальца на маршруте $route',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('прокрутка мимо конца упирается, а не заворачивается в начало', () {
|
||||
final from = _geometry.knobCenter(0.9);
|
||||
final to = _geometry.knobCenter(0.1);
|
||||
final delta = NoteRingGeometry.angleDelta(
|
||||
_geometry.angleAt(from),
|
||||
_geometry.angleAt(to),
|
||||
);
|
||||
expect(delta, greaterThan(0));
|
||||
expect(_geometry.advance(_geometry.progressAt(from), delta), 1.0);
|
||||
});
|
||||
|
||||
test('переход через 12 часов не перебрасывает позицию', () {
|
||||
final before = _geometry.angleAt(
|
||||
_geometry.center + const Offset(-4, -90),
|
||||
);
|
||||
final after = _geometry.angleAt(_geometry.center + const Offset(4, -90));
|
||||
final delta = NoteRingGeometry.angleDelta(before, after);
|
||||
|
||||
expect(delta.abs(), lessThan(0.3), reason: 'скачок вместо плавного шага');
|
||||
expect(delta, greaterThan(0));
|
||||
expect(_geometry.advance(0.99, delta), 1.0);
|
||||
expect(_geometry.advance(0.01, -delta), greaterThanOrEqualTo(0.0));
|
||||
});
|
||||
|
||||
test('прогресс зажат в границах при бесконечной прокрутке', () {
|
||||
expect(_geometry.advance(0.5, 100 * math.pi), 1.0);
|
||||
expect(_geometry.advance(0.5, -100 * math.pi), 0.0);
|
||||
});
|
||||
|
||||
test('увеличенный кружок сохраняет пропорции обода', () {
|
||||
const big = NoteRingGeometry(extent: 357, knobRadius: 9);
|
||||
expect(big.radius, closeTo(357 / 2 - 10, 0.001));
|
||||
expect(big.knobCenter(0).dy, closeTo(big.center.dy - big.radius, 0.001));
|
||||
expect(big.knobCenter(0.5).dy + 9, lessThanOrEqualTo(357));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:komet/core/media/ogg_page_writer.dart';
|
||||
import 'package:komet/core/media/opus_ogg_index.dart';
|
||||
|
||||
const int _sampleRate = 48000;
|
||||
const int _packetSamples = 960;
|
||||
const int _preSkip = 312;
|
||||
const int _serial = 0x4b6f6d74;
|
||||
const int _packetsPerPage = 20;
|
||||
|
||||
Uint8List _le16(int value) =>
|
||||
Uint8List(2)..buffer.asByteData().setUint16(0, value, Endian.little);
|
||||
|
||||
Uint8List _le32(int value) =>
|
||||
Uint8List(4)..buffer.asByteData().setUint32(0, value, Endian.little);
|
||||
|
||||
Uint8List _opusHead() {
|
||||
final out = BytesBuilder()
|
||||
..add('OpusHead'.codeUnits)
|
||||
..addByte(1)
|
||||
..addByte(1)
|
||||
..add(_le16(_preSkip))
|
||||
..add(_le32(_sampleRate))
|
||||
..add(_le16(0))
|
||||
..addByte(0);
|
||||
return out.toBytes();
|
||||
}
|
||||
|
||||
Uint8List _opusTags() {
|
||||
const vendor = 'komet-test';
|
||||
final out = BytesBuilder()
|
||||
..add('OpusTags'.codeUnits)
|
||||
..add(_le32(vendor.length))
|
||||
..add(vendor.codeUnits)
|
||||
..add(_le32(0));
|
||||
return out.toBytes();
|
||||
}
|
||||
|
||||
Uint8List _audioPacket(int index) {
|
||||
final out = Uint8List(40);
|
||||
out[0] = 0x08;
|
||||
out[1] = index & 0xff;
|
||||
for (var i = 2; i < out.length; i++) {
|
||||
out[i] = (index + i) & 0xff;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
Uint8List _buildStream(int packetCount, {int endTrim = 0}) {
|
||||
final pages = <Uint8List>[];
|
||||
var sequence = 0;
|
||||
pages.add(
|
||||
OggPageWriter.page(
|
||||
headerType: OggPageWriter.beginningOfStream,
|
||||
granulePos: 0,
|
||||
serial: _serial,
|
||||
sequence: sequence++,
|
||||
packets: [_opusHead()],
|
||||
),
|
||||
);
|
||||
pages.add(
|
||||
OggPageWriter.page(
|
||||
headerType: 0,
|
||||
granulePos: 0,
|
||||
serial: _serial,
|
||||
sequence: sequence++,
|
||||
packets: [_opusTags()],
|
||||
),
|
||||
);
|
||||
|
||||
for (var start = 0; start < packetCount; start += _packetsPerPage) {
|
||||
final end = (start + _packetsPerPage).clamp(0, packetCount);
|
||||
final last = end == packetCount;
|
||||
final packets = [
|
||||
for (var i = start; i < end; i++) _audioPacket(i),
|
||||
];
|
||||
pages.add(
|
||||
OggPageWriter.page(
|
||||
headerType: last ? OggPageWriter.endOfStream : 0,
|
||||
granulePos: end * _packetSamples - (last ? endTrim : 0),
|
||||
serial: _serial,
|
||||
sequence: sequence++,
|
||||
packets: packets,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final builder = BytesBuilder();
|
||||
for (final page in pages) {
|
||||
builder.add(page);
|
||||
}
|
||||
return builder.toBytes();
|
||||
}
|
||||
|
||||
int _referenceCrc(Uint8List data) {
|
||||
var crc = 0;
|
||||
for (final byte in data) {
|
||||
crc ^= (byte << 24) & 0xffffffff;
|
||||
for (var bit = 0; bit < 8; bit++) {
|
||||
if ((crc & 0x80000000) != 0) {
|
||||
crc = ((crc << 1) ^ 0x04c11db7) & 0xffffffff;
|
||||
} else {
|
||||
crc = (crc << 1) & 0xffffffff;
|
||||
}
|
||||
}
|
||||
}
|
||||
return crc;
|
||||
}
|
||||
|
||||
class _Page {
|
||||
_Page({required this.headerType, required this.granulePos});
|
||||
|
||||
final int headerType;
|
||||
final int granulePos;
|
||||
}
|
||||
|
||||
List<_Page> _verifyPages(Uint8List bytes) {
|
||||
final pages = <_Page>[];
|
||||
var offset = 0;
|
||||
while (offset + 27 <= bytes.length) {
|
||||
expect(
|
||||
String.fromCharCodes(bytes, offset, offset + 4),
|
||||
'OggS',
|
||||
reason: 'страница на смещении $offset',
|
||||
);
|
||||
final view = ByteData.sublistView(bytes, offset);
|
||||
final segmentCount = bytes[offset + 26];
|
||||
final tableStart = offset + 27;
|
||||
var bodyLength = 0;
|
||||
for (var i = 0; i < segmentCount; i++) {
|
||||
bodyLength += bytes[tableStart + i];
|
||||
}
|
||||
final pageEnd = tableStart + segmentCount + bodyLength;
|
||||
expect(pageEnd <= bytes.length, isTrue);
|
||||
|
||||
final stored = view.getUint32(22, Endian.little);
|
||||
final page = Uint8List.fromList(bytes.sublist(offset, pageEnd));
|
||||
ByteData.sublistView(page).setUint32(22, 0, Endian.little);
|
||||
expect(_referenceCrc(page), stored, reason: 'CRC страницы $offset');
|
||||
|
||||
pages.add(
|
||||
_Page(
|
||||
headerType: bytes[offset + 5],
|
||||
granulePos: view.getInt64(6, Endian.little),
|
||||
),
|
||||
);
|
||||
offset = pageEnd;
|
||||
}
|
||||
expect(offset, bytes.length);
|
||||
return pages;
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('crc32 совпадает с побитовой реализацией на любой длине', () {
|
||||
for (var length = 0; length <= 260; length++) {
|
||||
final data = Uint8List.fromList(
|
||||
List.generate(length, (i) => (i * 31 + length) & 0xff),
|
||||
);
|
||||
expect(
|
||||
OggPageWriter.crc32(data),
|
||||
_referenceCrc(data),
|
||||
reason: 'длина $length',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('crc32 по диапазону не зависит от окружающих байт', () {
|
||||
final payload = Uint8List.fromList(
|
||||
List.generate(1021, (i) => (i * 7) & 0xff),
|
||||
);
|
||||
final padded = Uint8List(payload.length + 9)
|
||||
..fillRange(0, 5, 0xab)
|
||||
..setRange(5, 5 + payload.length, payload)
|
||||
..fillRange(5 + payload.length, payload.length + 9, 0xcd);
|
||||
|
||||
expect(
|
||||
OggPageWriter.crc32(padded, 5, 5 + payload.length),
|
||||
_referenceCrc(payload),
|
||||
);
|
||||
});
|
||||
|
||||
test('разбирает длительность из TOC-байтов', () {
|
||||
final index = OpusOggIndex.parse(_buildStream(250))!;
|
||||
expect(
|
||||
index.duration,
|
||||
closeTo((250 * _packetSamples - _preSkip) / _sampleRate, 1e-9),
|
||||
);
|
||||
});
|
||||
|
||||
test('не разбирает мусор', () {
|
||||
expect(OpusOggIndex.parse(Uint8List(0)), isNull);
|
||||
expect(
|
||||
OpusOggIndex.parse(Uint8List.fromList(List.filled(512, 7))),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
|
||||
test('срез укорачивает поток ровно на запрошенную позицию', () {
|
||||
final index = OpusOggIndex.parse(_buildStream(500))!;
|
||||
final total = index.duration;
|
||||
|
||||
for (final seconds in [0.02, 0.5, 1.0, 3.3, 7.75]) {
|
||||
final sliced = index.sliceFrom(seconds);
|
||||
expect(sliced, isNotNull, reason: 'срез с $seconds с');
|
||||
final reparsed = OpusOggIndex.parse(sliced!)!;
|
||||
expect(
|
||||
reparsed.duration,
|
||||
closeTo(total - seconds, 1e-6),
|
||||
reason: 'длительность среза с $seconds с',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('срез остаётся валидным Ogg с EOS на последней странице', () {
|
||||
final index = OpusOggIndex.parse(_buildStream(500))!;
|
||||
final sliced = index.sliceFrom(4.0)!;
|
||||
final pages = _verifyPages(sliced);
|
||||
|
||||
expect(pages.length, greaterThan(2));
|
||||
expect(pages.first.headerType & OggPageWriter.beginningOfStream, isNot(0));
|
||||
expect(pages.first.granulePos, 0);
|
||||
expect(pages[1].granulePos, 0);
|
||||
expect(pages.last.headerType & OggPageWriter.endOfStream, isNot(0));
|
||||
|
||||
var previous = -1;
|
||||
for (final page in pages) {
|
||||
expect(page.granulePos, greaterThanOrEqualTo(previous));
|
||||
previous = page.granulePos;
|
||||
}
|
||||
});
|
||||
|
||||
test('учитывает обрезку хвоста в финальной granule', () {
|
||||
const trim = 700;
|
||||
final index = OpusOggIndex.parse(_buildStream(300, endTrim: trim))!;
|
||||
expect(
|
||||
index.duration,
|
||||
closeTo((300 * _packetSamples - _preSkip - trim) / _sampleRate, 1e-9),
|
||||
);
|
||||
|
||||
final sliced = index.sliceFrom(2.0);
|
||||
final reparsed = OpusOggIndex.parse(sliced!)!;
|
||||
expect(reparsed.duration, closeTo(index.duration - 2.0, 1e-6));
|
||||
});
|
||||
|
||||
test('срез за пределами длительности не строится', () {
|
||||
final index = OpusOggIndex.parse(_buildStream(100))!;
|
||||
expect(index.sliceFrom(0), isNull);
|
||||
expect(index.sliceFrom(-1), isNull);
|
||||
expect(index.sliceFrom(index.duration + 1), isNull);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:komet/backend/modules/messages.dart';
|
||||
import 'package:komet/frontend/widgets/attachment/bubbles/video_note_bubble.dart';
|
||||
import 'package:komet/frontend/widgets/message_bubble.dart';
|
||||
import 'package:komet/l10n/app_localizations.dart';
|
||||
import 'package:komet/models/attachment.dart';
|
||||
|
||||
const int _me = 1;
|
||||
const int _peer = 7;
|
||||
const int _longNoteMs = 45000;
|
||||
|
||||
CachedMessage _note({required int durationMs}) => CachedMessage(
|
||||
id: '1',
|
||||
accountId: _me,
|
||||
chatId: 2,
|
||||
senderId: _peer,
|
||||
time: DateTime(2026, 1, 1, 5, 46).millisecondsSinceEpoch,
|
||||
status: 'sent',
|
||||
attachments: [
|
||||
VideoAttachment(
|
||||
videoId: 4242,
|
||||
videoToken: 'synthetic-token',
|
||||
videoType: 1,
|
||||
width: 400,
|
||||
height: 400,
|
||||
duration: durationMs,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
Future<void> _pumpNote(WidgetTester tester, {required double screenWidth}) async {
|
||||
tester.view.physicalSize = Size(screenWidth * 2, 2400);
|
||||
tester.view.devicePixelRatio = 2;
|
||||
addTearDown(tester.view.reset);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
locale: const Locale('ru'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: Scaffold(
|
||||
body: Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: MessageBubble(
|
||||
message: _note(durationMs: _longNoteMs),
|
||||
isMe: false,
|
||||
myId: _me,
|
||||
chatType: 'DIALOG',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('у кружка есть длительность слева на одном Y с временем', (
|
||||
tester,
|
||||
) async {
|
||||
await _pumpNote(tester, screenWidth: 390);
|
||||
|
||||
final duration = find.text('0:45');
|
||||
final clock = find.text('05:46');
|
||||
expect(duration, findsOneWidget);
|
||||
expect(clock, findsOneWidget);
|
||||
|
||||
final durationBox = tester.getRect(duration);
|
||||
final clockBox = tester.getRect(clock);
|
||||
|
||||
expect(
|
||||
durationBox.center.dy,
|
||||
closeTo(clockBox.center.dy, 1.0),
|
||||
reason: 'длительность и время должны быть на одном Y',
|
||||
);
|
||||
expect(
|
||||
durationBox.right,
|
||||
lessThan(clockBox.left),
|
||||
reason: 'длительность должна быть слева от времени',
|
||||
);
|
||||
|
||||
final circle = tester.getRect(find.byType(VideoNoteBubble));
|
||||
expect(durationBox.left, closeTo(circle.left, 8.0));
|
||||
expect(clockBox.right, closeTo(circle.right, 12.0));
|
||||
});
|
||||
|
||||
testWidgets('свёрнутый кружок сохраняет базовый размер', (tester) async {
|
||||
await _pumpNote(tester, screenWidth: 390);
|
||||
|
||||
final size = tester.getSize(find.byType(VideoNoteBubble));
|
||||
expect(size.width, closeTo(210, 0.5));
|
||||
});
|
||||
|
||||
testWidgets('кружку хватает ширины под увеличение', (tester) async {
|
||||
const expanded = 210 * 1.7;
|
||||
final reached = <double, double>{};
|
||||
|
||||
for (final screenWidth in [360.0, 390.0, 412.0, 800.0]) {
|
||||
await _pumpNote(tester, screenWidth: screenWidth);
|
||||
expect(
|
||||
tester.takeException(),
|
||||
isNull,
|
||||
reason: 'переполнение раскладки при ширине $screenWidth',
|
||||
);
|
||||
|
||||
final box =
|
||||
tester.renderObject(find.byType(VideoNoteBubble)) as RenderBox;
|
||||
final available = box.constraints.maxWidth;
|
||||
reached[screenWidth] = (available / 210).clamp(1.0, 1.7);
|
||||
|
||||
expect(
|
||||
available,
|
||||
lessThanOrEqualTo(screenWidth),
|
||||
reason: 'кружку дали больше ширины, чем есть на экране',
|
||||
);
|
||||
}
|
||||
|
||||
expect(reached[390.0], closeTo(1.7, 0.001));
|
||||
expect(reached[412.0], closeTo(1.7, 0.001));
|
||||
expect(reached[800.0], closeTo(1.7, 0.001));
|
||||
expect(reached[360.0], closeTo((360 - 24) / 210, 0.001));
|
||||
expect(reached[360.0], greaterThan(1.55));
|
||||
expect(expanded, 357);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user