Player: sharp video and subtitles at screen size; dev tools: danmaku preview, subtitle lift with a stand-in chat line
- "Sharp video and subtitles" (Settings → Player and the player settings card, on by default): the video output renders at the view's physical size (debounced, GPU rendering only), so libass, mpv's scaler and the shaders work at screen resolution; re-sent after media_kit's own reset on new video params, redrawn while paused. - Dev builds only (Settings → About → Developer): a danmaku preview (DanmakuLayer: free lanes in an upper band, density cap with a queue, equal crossing time, outlined text, avatars, own-line frame) over the real player or a stand-in picture, with samples, a random stream, live toasts, every knob and mock subtitles; and "Lift subtitles above the chat line and controls": Enter opens a stand-in chat line, bottom subtitles move up via mpv sub-pos while it or the control bar is up. - tool/make_sub_test.sh: a test video with a top \an8 sign, bottom dialogue and an SRT track. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
638a30e16c
commit
5bc252bf02
18 files changed
+2253
-16
No files matched your search
@@ -0,0 +1,176 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:starsync/src/danmaku/danmaku.dart';
|
||||
import 'package:starsync/src/shell/avatar.dart' show AvatarInfo;
|
||||
|
||||
// Danmaku lanes: free lanes only, no two lines in a lane ever touch, the
|
||||
// density cap with a queue in arrival order, and the layer over a real
|
||||
// widget clock.
|
||||
|
||||
const w = 1000.0;
|
||||
|
||||
/// Lines in one lane never overlap (gap kept) at any sampled time.
|
||||
void expectNoOverlap(DanmakuLanes<int> lanes, double t, {double gap = 0}) {
|
||||
final byLane = <int, List<DanmakuFlight<int>>>{};
|
||||
for (final f in lanes.flights) {
|
||||
byLane.putIfAbsent(f.lane, () => []).add(f);
|
||||
}
|
||||
for (final fs in byLane.values) {
|
||||
fs.sort((a, b) => a.start.compareTo(b.start));
|
||||
for (var i = 1; i < fs.length; i++) {
|
||||
final ahead = fs[i - 1], behind = fs[i];
|
||||
final tail = ahead.x(t, w) + ahead.width;
|
||||
// Only while both are on screen.
|
||||
if (tail <= 0 || behind.x(t, w) >= w) continue;
|
||||
expect(behind.x(t, w), greaterThanOrEqualTo(tail + gap - 1e-6),
|
||||
reason: 'lane ${behind.lane} at t=$t: #${behind.item} reaches #${ahead.item}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
List<DanmakuFlight<int>> tick(DanmakuLanes<int> lanes, double t, Map<int, double> widths,
|
||||
{int lanesN = 5, int cap = 100, double cross = 8}) =>
|
||||
lanes.tick(t, w: w, lanes: lanesN, cap: cap, crossSecs: cross, measure: (i) => widths[i]!);
|
||||
|
||||
void main() {
|
||||
group('DanmakuLanes', () {
|
||||
test('lines at the same moment take the lanes top down', () {
|
||||
final lanes = DanmakuLanes<int>();
|
||||
final widths = {for (var i = 0; i < 3; i++) i: 200.0};
|
||||
for (var i = 0; i < 3; i++) {
|
||||
lanes.add(i, 0);
|
||||
}
|
||||
final placed = tick(lanes, 0, widths);
|
||||
expect([for (final f in placed) f.lane], [0, 1, 2]);
|
||||
});
|
||||
|
||||
test('a lane is reused once the line in it has fully entered', () {
|
||||
final lanes = DanmakuLanes<int>(gap: 20);
|
||||
final widths = {0: 200.0, 1: 200.0};
|
||||
lanes.add(0, 0);
|
||||
tick(lanes, 0, widths, lanesN: 1);
|
||||
lanes.add(1, 0.1);
|
||||
// 200 px + gap at 150 px/s: not yet in.
|
||||
expect(tick(lanes, 0.1, widths, lanesN: 1), isEmpty);
|
||||
expect(lanes.waiting, 1);
|
||||
// Same length, same speed: it may follow once 220 px have entered.
|
||||
final placed = tick(lanes, 1.6, widths, lanesN: 1);
|
||||
expect(placed.single.lane, 0);
|
||||
});
|
||||
|
||||
test('a long (faster) line never catches a short one ahead in its lane', () {
|
||||
final lanes = DanmakuLanes<int>(gap: 20);
|
||||
final widths = {0: 60.0, 1: 900.0};
|
||||
lanes.add(0, 0);
|
||||
tick(lanes, 0, widths, lanesN: 1);
|
||||
lanes.add(1, 0.5);
|
||||
// The short one is in, but the long one would run into it: wait.
|
||||
for (var t = 0.5; t < 8; t += 0.1) {
|
||||
final placed = tick(lanes, t, widths, lanesN: 1);
|
||||
if (placed.isNotEmpty) {
|
||||
// Placed: from then on they never touch.
|
||||
for (var s = t; s < t + 8; s += 0.05) {
|
||||
expectNoOverlap(lanes, s, gap: 20);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
fail('the long line never got the lane');
|
||||
});
|
||||
|
||||
test('a random stream of mixed lengths never overlaps', () {
|
||||
final rng = math.Random(3);
|
||||
final lanes = DanmakuLanes<int>(gap: 24, maxWaitSecs: 1000, maxWaiting: 10000);
|
||||
final widths = <int, double>{};
|
||||
var n = 0;
|
||||
for (var t = 0.0; t < 120; t += 0.05) {
|
||||
if (rng.nextDouble() < 0.15) {
|
||||
widths[n] = 20 + rng.nextDouble() * 900;
|
||||
lanes.add(n++, t);
|
||||
}
|
||||
tick(lanes, t, widths, lanesN: 6, cap: 14, cross: 6 + rng.nextDouble() * 0.0);
|
||||
expectNoOverlap(lanes, t, gap: 24);
|
||||
expect(lanes.flights.length, lessThanOrEqualTo(14));
|
||||
}
|
||||
expect(n, greaterThan(300));
|
||||
});
|
||||
|
||||
test('the density cap holds the rest in arrival order', () {
|
||||
final lanes = DanmakuLanes<int>();
|
||||
final widths = {for (var i = 0; i < 10; i++) i: 100.0};
|
||||
for (var i = 0; i < 10; i++) {
|
||||
lanes.add(i, 0);
|
||||
}
|
||||
final first = tick(lanes, 0, widths, lanesN: 20, cap: 4);
|
||||
expect([for (final f in first) f.item], [0, 1, 2, 3]);
|
||||
expect(lanes.waiting, 6);
|
||||
// Nothing leaves before the crossing time: still capped.
|
||||
expect(tick(lanes, 7.9, widths, lanesN: 20, cap: 4), isEmpty);
|
||||
final next = tick(lanes, 8, widths, lanesN: 20, cap: 4);
|
||||
expect([for (final f in next) f.item], [4, 5, 6, 7]);
|
||||
});
|
||||
|
||||
test('a line that waited too long is dropped; the queue is bounded', () {
|
||||
final lanes = DanmakuLanes<int>(maxWaitSecs: 5, maxWaiting: 3);
|
||||
final widths = {for (var i = 0; i < 6; i++) i: 100.0};
|
||||
for (var i = 0; i < 5; i++) {
|
||||
lanes.add(i, 0);
|
||||
}
|
||||
expect(lanes.waiting, 3);
|
||||
expect(lanes.dropped, 2);
|
||||
tick(lanes, 6, widths, cap: 0);
|
||||
expect(lanes.waiting, 0);
|
||||
expect(lanes.dropped, 5);
|
||||
});
|
||||
});
|
||||
|
||||
group('DanmakuStyle', () {
|
||||
test('lanes fill the band at the text size', () {
|
||||
const s = DanmakuStyle(bandTop: 0.1, bandBottom: 0.45);
|
||||
// 720 px: 30.24 px text, 45.36 px lanes in a 252 px band.
|
||||
expect(s.lanesFor(720), 5);
|
||||
expect(const DanmakuStyle(bandTop: 0.4, bandBottom: 0.41).lanesFor(720), 1);
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('the layer flies lines under the cap and lets the rest wait', (tester) async {
|
||||
final c = DanmakuController();
|
||||
addTearDown(c.dispose);
|
||||
await tester.pumpWidget(Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: SizedBox(
|
||||
width: 800,
|
||||
height: 450,
|
||||
child: DanmakuLayer(
|
||||
controller: c,
|
||||
accent: Colors.purple,
|
||||
style: const DanmakuStyle(maxOnScreen: 3, crossSecs: 4),
|
||||
),
|
||||
),
|
||||
));
|
||||
for (var i = 0; i < 7; i++) {
|
||||
c.add(DanmakuItem(
|
||||
kind: DanmakuKind.chat,
|
||||
sender: 'Bob',
|
||||
text: 'line $i',
|
||||
own: i == 0,
|
||||
avatar: i.isEven ? const AvatarInfo(name: 'Bob', color: Colors.teal) : null));
|
||||
}
|
||||
await tester.pump(const Duration(milliseconds: 16));
|
||||
await tester.pump(const Duration(milliseconds: 16));
|
||||
expect(c.onScreen.value, 3);
|
||||
expect(c.waiting.value, 4);
|
||||
// After a crossing the next ones fly.
|
||||
await tester.pump(const Duration(seconds: 4));
|
||||
await tester.pump(const Duration(milliseconds: 16));
|
||||
expect(c.onScreen.value, 3);
|
||||
expect(c.waiting.value, 1);
|
||||
c.clear();
|
||||
await tester.pump(const Duration(milliseconds: 16));
|
||||
expect(c.onScreen.value, 0);
|
||||
expect(c.waiting.value, 0);
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:starsync/src/platform/ports/player_host.dart' show RenderMode;
|
||||
import 'package:starsync/src/screens/home/ui.dart' show devToolsFor;
|
||||
import 'package:starsync/src/shell/video_view.dart';
|
||||
|
||||
// The video view's asks of mpv: the output size ("Sharp video and
|
||||
// subtitles") and the dev experiment's subtitle lift.
|
||||
|
||||
void main() {
|
||||
group('videoOutputSize', () {
|
||||
test('the view in physical pixels on the GPU', () {
|
||||
expect(videoOutputSize(const Size(1280, 720), 1.5, on: true, mode: RenderMode.gpu), (1920, 1080));
|
||||
expect(videoOutputSize(const Size(384, 216), 2, on: true, mode: RenderMode.gpu), (768, 432));
|
||||
});
|
||||
|
||||
test("the video's own size when off, not on the GPU yet, or in software", () {
|
||||
expect(videoOutputSize(const Size(1280, 720), 1, on: false, mode: RenderMode.gpu), isNull);
|
||||
for (final m in [RenderMode.unknown, RenderMode.software, RenderMode.softwareGl]) {
|
||||
expect(videoOutputSize(const Size(1280, 720), 1, on: true, mode: m), isNull, reason: m.name);
|
||||
}
|
||||
expect(videoOutputSize(const Size(4, 4), 1, on: true, mode: RenderMode.gpu), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('subtitleLiftPos', () {
|
||||
test('a picture filling the view lifts by the controls', () {
|
||||
// 108 px of controls + 8 px gap in a 1080 px picture: 10.74 %.
|
||||
final pos = subtitleLiftPos(view: const Size(1920, 1080), videoW: 1920, videoH: 1080, controls: 108);
|
||||
expect(pos, closeTo(100 - 116 / 1080 * 100, 1e-9));
|
||||
});
|
||||
|
||||
test('a letterbox under the picture counts toward the room', () {
|
||||
// 2.39:1 in 16:9: 803 px picture, 138 px bar below it.
|
||||
final none = subtitleLiftPos(view: const Size(1920, 1080), videoW: 1920, videoH: 803, controls: 100);
|
||||
expect(none, 100);
|
||||
final some = subtitleLiftPos(view: const Size(1920, 1080), videoW: 1920, videoH: 803, controls: 200);
|
||||
expect(some, lessThan(100));
|
||||
});
|
||||
|
||||
test('unknown video size: as if it filled the view; never past half', () {
|
||||
expect(subtitleLiftPos(view: const Size(800, 450), controls: 90), closeTo(100 - 98 / 450 * 100, 1e-9));
|
||||
expect(subtitleLiftPos(view: const Size(800, 100), controls: 500), 50);
|
||||
expect(subtitleLiftPos(view: Size.zero, controls: 90), 100);
|
||||
});
|
||||
});
|
||||
|
||||
test('dev tools: dev versions and debug runs only', () {
|
||||
expect(devToolsFor(version: '1.3.3-dev.2', debug: false), isTrue);
|
||||
expect(devToolsFor(version: '0.0.0-dev', debug: false), isTrue);
|
||||
expect(devToolsFor(version: '1.3.2', debug: true), isTrue);
|
||||
expect(devToolsFor(version: '1.3.2', debug: false), isFalse);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user