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:
foxyzandClaude Opus 5.5 committed 2026-10-18 02:19:40 +03:00
1 parent 638a30e16c
commit 5bc252bf02
18 files changed
+2253 -16

No files matched your search

+176
View File
@@ -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());
});
}
+54
View File
@@ -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);
});
}