- "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>
476 lines
15 KiB
Dart
476 lines
15 KiB
Dart
import 'dart:collection';
|
|
import 'dart:math' as math;
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/scheduler.dart';
|
|
|
|
import '../shell/avatar.dart' show AvatarInfo, StatusAvatar;
|
|
|
|
// Danmaku (Bilibili / niconico style): short lines that fly right to left
|
|
// across the upper part of the picture. Each line takes a free lane (no
|
|
// two ever touch), at most [DanmakuStyle.maxOnScreen] fly at once and the
|
|
// rest wait their turn; every line crosses in the same time, so a long
|
|
// one moves faster. The clock is the layer's own: lines keep flying while
|
|
// the video is paused. Dev preview for now (DEVELOPMENT.md "Dev tools").
|
|
|
|
/// What a line is: a chat message or a short notice.
|
|
enum DanmakuKind { chat, join, leave, upNext, starting }
|
|
|
|
/// One line: [sender] (a coloured prefix, [senderColor]) and [text];
|
|
/// [own] lines (this member's) get a hairline frame. [avatar]: the
|
|
/// sender's picture (or initial) before it; the app's own notices have
|
|
/// none.
|
|
@immutable
|
|
class DanmakuItem {
|
|
final DanmakuKind kind;
|
|
final String? sender;
|
|
final Color? senderColor;
|
|
final String text;
|
|
final bool own;
|
|
final AvatarInfo? avatar;
|
|
const DanmakuItem(
|
|
{required this.kind, required this.text, this.sender, this.senderColor, this.own = false, this.avatar});
|
|
}
|
|
|
|
/// How lines look and move.
|
|
@immutable
|
|
class DanmakuStyle {
|
|
/// Seconds a line takes from the right edge until it has left on the
|
|
/// left, whatever its length.
|
|
final double crossSecs;
|
|
|
|
/// Lines on screen at once; more wait.
|
|
final int maxOnScreen;
|
|
|
|
/// The band the lanes fill, as fractions of the height from the top:
|
|
/// below where top signs sit, never down into the dialogue.
|
|
final double bandTop;
|
|
final double bandBottom;
|
|
final double opacity;
|
|
|
|
/// Text size relative to the picture's height ([fontSizeFor]).
|
|
final double size;
|
|
|
|
/// Width of the dark outline round the white text, logical px.
|
|
final double outline;
|
|
final bool highlightOwn;
|
|
|
|
/// The sender's avatar before the line.
|
|
final bool avatars;
|
|
|
|
const DanmakuStyle({
|
|
this.crossSecs = 8,
|
|
this.maxOnScreen = 12,
|
|
this.bandTop = 0.10,
|
|
this.bandBottom = 0.45,
|
|
this.opacity = 0.92,
|
|
this.size = 1,
|
|
this.outline = 1.4,
|
|
this.highlightOwn = true,
|
|
this.avatars = true,
|
|
});
|
|
|
|
DanmakuStyle copyWith({
|
|
double? crossSecs,
|
|
int? maxOnScreen,
|
|
double? bandTop,
|
|
double? bandBottom,
|
|
double? opacity,
|
|
double? size,
|
|
double? outline,
|
|
bool? highlightOwn,
|
|
bool? avatars,
|
|
}) =>
|
|
DanmakuStyle(
|
|
crossSecs: crossSecs ?? this.crossSecs,
|
|
maxOnScreen: maxOnScreen ?? this.maxOnScreen,
|
|
bandTop: bandTop ?? this.bandTop,
|
|
bandBottom: bandBottom ?? this.bandBottom,
|
|
opacity: opacity ?? this.opacity,
|
|
size: size ?? this.size,
|
|
outline: outline ?? this.outline,
|
|
highlightOwn: highlightOwn ?? this.highlightOwn,
|
|
avatars: avatars ?? this.avatars,
|
|
);
|
|
|
|
/// The text size in a picture [height] px tall: 4.2 % of it (about 30 px
|
|
/// on a 720 px player), 13 to 44 px, times [size].
|
|
double fontSizeFor(double height) => (height * 0.042).clamp(13.0, 44.0) * size;
|
|
|
|
/// A lane: the line's height plus a little air.
|
|
double laneHeightFor(double height) => fontSizeFor(height) * 1.5;
|
|
|
|
/// Lanes in the band of a picture [height] px tall (at least one).
|
|
int lanesFor(double height) {
|
|
final band = (bandBottom - bandTop).clamp(0.0, 1.0) * height;
|
|
return math.max(1, (band / laneHeightFor(height)).floor());
|
|
}
|
|
}
|
|
|
|
/// One line in flight in [lane], from [start] (seconds on the layer's
|
|
/// clock), [width] px wide, crossing in [crossSecs].
|
|
class DanmakuFlight<T> {
|
|
final T item;
|
|
final int lane;
|
|
final double start;
|
|
final double width;
|
|
final double crossSecs;
|
|
DanmakuFlight({required this.item, required this.lane, required this.start, required this.width, required this.crossSecs});
|
|
|
|
/// Its left edge at [t] in a picture [w] px wide: [w] at the start,
|
|
/// -[width] at the end.
|
|
double x(double t, double w) => w - (w + width) * ((t - start) / crossSecs);
|
|
double end() => start + crossSecs;
|
|
bool doneAt(double t) => t >= end();
|
|
}
|
|
|
|
/// Lane assignment, pure (no widgets, a clock passed in): lines queue in
|
|
/// order of arrival; [tick] lets the waiting ones in while a lane is free
|
|
/// and fewer than the cap fly. A lane is free for a new line when the last
|
|
/// line in it has fully entered with [gap] behind it and the new one, at
|
|
/// its own speed, cannot catch up with it before it has left.
|
|
class DanmakuLanes<T> {
|
|
/// Space kept between two lines in a lane, px.
|
|
final double gap;
|
|
|
|
/// A line waiting longer than this is dropped (a burst on a crowded
|
|
/// picture would otherwise fly long after it was said).
|
|
final double maxWaitSecs;
|
|
|
|
/// Lines kept waiting at most; older ones go first when it is full.
|
|
final int maxWaiting;
|
|
|
|
DanmakuLanes({this.gap = 24, this.maxWaitSecs = 15, this.maxWaiting = 60});
|
|
|
|
final List<DanmakuFlight<T>> flights = [];
|
|
final Queue<(T, double)> _waiting = Queue();
|
|
int dropped = 0;
|
|
|
|
int get waiting => _waiting.length;
|
|
|
|
/// Everything flying and waiting goes; returns it.
|
|
List<T> clear() {
|
|
final all = [for (final f in flights) f.item, for (final w in _waiting) w.$1];
|
|
flights.clear();
|
|
_waiting.clear();
|
|
return all;
|
|
}
|
|
|
|
/// [item] arrived at [t]; it flies on the next [tick] that has room.
|
|
void add(T item, double t) {
|
|
_waiting.add((item, t));
|
|
while (_waiting.length > maxWaiting) {
|
|
_waiting.removeFirst();
|
|
dropped++;
|
|
}
|
|
}
|
|
|
|
/// Advances to [t] in a picture [w] px wide with [lanes] lanes: drops
|
|
/// what has left and what waited too long, then places waiting lines in
|
|
/// order (the topmost free lane each) until the cap or no lane is free.
|
|
/// [measure] gives a line's width. Returns the lines placed.
|
|
List<DanmakuFlight<T>> tick(double t,
|
|
{required double w, required int lanes, required int cap, required double crossSecs, required double Function(T) measure}) {
|
|
flights.removeWhere((f) => f.doneAt(t));
|
|
while (_waiting.isNotEmpty && t - _waiting.first.$2 > maxWaitSecs) {
|
|
_waiting.removeFirst();
|
|
dropped++;
|
|
}
|
|
final placed = <DanmakuFlight<T>>[];
|
|
while (_waiting.isNotEmpty && flights.length < cap) {
|
|
final item = _waiting.first.$1;
|
|
final width = measure(item);
|
|
final lane = freeLane(t, w: w, lanes: lanes, width: width, crossSecs: crossSecs);
|
|
if (lane == null) break;
|
|
_waiting.removeFirst();
|
|
final f = DanmakuFlight(item: item, lane: lane, start: t, width: width, crossSecs: crossSecs);
|
|
flights.add(f);
|
|
placed.add(f);
|
|
}
|
|
return placed;
|
|
}
|
|
|
|
/// The topmost lane a line [width] px wide can start in at [t], or null.
|
|
int? freeLane(double t, {required double w, required int lanes, required double width, required double crossSecs}) {
|
|
final last = <int, DanmakuFlight<T>>{};
|
|
for (final f in flights) {
|
|
final l = last[f.lane];
|
|
if (l == null || f.start > l.start) last[f.lane] = f;
|
|
}
|
|
for (var lane = 0; lane < lanes; lane++) {
|
|
final prev = last[lane];
|
|
if (prev == null || fits(prev, t, w: w, width: width, crossSecs: crossSecs, gap: gap)) return lane;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// A new line ([width], [crossSecs]) may follow [prev] in its lane at [t].
|
|
/// Both move linearly, so checking now and the moment [prev] has left is
|
|
/// enough.
|
|
static bool fits(DanmakuFlight<Object?> prev, double t,
|
|
{required double w, required double width, required double crossSecs, required double gap}) {
|
|
// Fully in, with the gap behind it.
|
|
if (prev.x(t, w) + prev.width + gap > w) return false;
|
|
final leaves = prev.end();
|
|
if (leaves <= t) return true;
|
|
// Where the new line's head is when prev's tail reaches the left edge.
|
|
final head = w - (w + width) * ((leaves - t) / crossSecs);
|
|
return head >= gap;
|
|
}
|
|
}
|
|
|
|
/// Feeds a [DanmakuLayer]; [onScreen] / [waiting] follow what the layer
|
|
/// does with the lines (for a status readout).
|
|
class DanmakuController extends ChangeNotifier {
|
|
final _inbox = <DanmakuItem>[];
|
|
final onScreen = ValueNotifier(0);
|
|
final waiting = ValueNotifier(0);
|
|
final dropped = ValueNotifier(0);
|
|
|
|
void add(DanmakuItem item) {
|
|
_inbox.add(item);
|
|
notifyListeners();
|
|
}
|
|
|
|
/// Everything in flight and waiting goes.
|
|
void clear() {
|
|
_clear = true;
|
|
_inbox.clear();
|
|
notifyListeners();
|
|
}
|
|
|
|
bool _clear = false;
|
|
|
|
List<DanmakuItem> _take() {
|
|
final items = List.of(_inbox);
|
|
_inbox.clear();
|
|
return items;
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
onScreen.dispose();
|
|
waiting.dispose();
|
|
dropped.dispose();
|
|
super.dispose();
|
|
}
|
|
}
|
|
|
|
/// The lines over a picture: fills its box, lets every click through.
|
|
/// Each line is a widget (avatar, outlined text, own frame) moved by a
|
|
/// [Flow] on every frame: only paint runs per frame, a build only when a
|
|
/// line comes or goes.
|
|
class DanmakuLayer extends StatefulWidget {
|
|
final DanmakuController controller;
|
|
final DanmakuStyle style;
|
|
|
|
/// The accent of [DanmakuItem.own] lines' frame and of "starting now".
|
|
final Color accent;
|
|
const DanmakuLayer({super.key, required this.controller, this.style = const DanmakuStyle(), required this.accent});
|
|
|
|
@override
|
|
State<DanmakuLayer> createState() => _DanmakuLayerState();
|
|
}
|
|
|
|
/// A line measured once (its width decides its speed and its lane) and
|
|
/// its widget, built once.
|
|
class _Line {
|
|
final DanmakuItem item;
|
|
final double width;
|
|
final double height;
|
|
final Widget widget;
|
|
_Line(this.item, this.width, this.height, this.widget);
|
|
}
|
|
|
|
class _DanmakuLayerState extends State<DanmakuLayer> with SingleTickerProviderStateMixin {
|
|
late final Ticker _ticker = createTicker(_onTick);
|
|
final _lanes = DanmakuLanes<_Line>();
|
|
final _repaint = ValueNotifier(0);
|
|
Duration _elapsed = Duration.zero;
|
|
Size _size = Size.zero;
|
|
var _pending = <DanmakuItem>[];
|
|
var _shown = <DanmakuFlight<_Line>>[];
|
|
|
|
double get _t => _elapsed.inMicroseconds / 1e6;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
widget.controller.addListener(_onAdd);
|
|
_ticker.start();
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(DanmakuLayer old) {
|
|
super.didUpdateWidget(old);
|
|
if (old.controller != widget.controller) {
|
|
old.controller.removeListener(_onAdd);
|
|
widget.controller.addListener(_onAdd);
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
widget.controller.removeListener(_onAdd);
|
|
_ticker.dispose();
|
|
_repaint.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _onAdd() {
|
|
final c = widget.controller;
|
|
if (c._clear) {
|
|
c._clear = false;
|
|
_lanes.clear();
|
|
_pending = [];
|
|
}
|
|
_pending.addAll(c._take());
|
|
}
|
|
|
|
_Line _build(DanmakuItem item) {
|
|
final s = widget.style;
|
|
final size = s.fontSizeFor(_size.height);
|
|
final base = TextStyle(
|
|
fontSize: size,
|
|
height: 1.2,
|
|
fontWeight: FontWeight.w600,
|
|
color: Colors.white,
|
|
letterSpacing: 0.2,
|
|
shadows: const [],
|
|
);
|
|
InlineSpan span({Paint? stroke}) {
|
|
final name = item.sender;
|
|
final accent = item.kind == DanmakuKind.starting && stroke == null ? widget.accent : null;
|
|
return TextSpan(style: stroke == null ? base : base.copyWith(foreground: stroke, color: null), children: [
|
|
if (name != null)
|
|
TextSpan(
|
|
text: item.kind == DanmakuKind.chat ? '$name ' : '$name ',
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.w700,
|
|
color: stroke == null ? (item.senderColor ?? Colors.white) : null,
|
|
),
|
|
),
|
|
TextSpan(text: item.text, style: accent == null ? null : TextStyle(color: accent)),
|
|
]);
|
|
}
|
|
|
|
final measure = TextPainter(text: span(), textDirection: TextDirection.ltr, maxLines: 1)..layout();
|
|
final textW = measure.width, textH = measure.height;
|
|
measure.dispose();
|
|
final avatar = s.avatars ? item.avatar : null;
|
|
final avatarSize = size * 1.15;
|
|
final avatarGap = size * 0.3;
|
|
final frame = item.own && s.highlightOwn;
|
|
final padX = frame ? size * 0.4 : 0.0;
|
|
final width = textW + padX * 2 + (avatar == null ? 0 : avatarSize + avatarGap);
|
|
Widget text = Text.rich(span(), maxLines: 1, softWrap: false);
|
|
if (s.outline > 0) {
|
|
final stroke = Paint()
|
|
..style = PaintingStyle.stroke
|
|
..strokeWidth = s.outline * 2
|
|
..strokeJoin = StrokeJoin.round
|
|
..color = const Color(0xE6000000);
|
|
text = Stack(children: [Text.rich(span(stroke: stroke), maxLines: 1, softWrap: false), text]);
|
|
}
|
|
Widget line = Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
if (avatar != null) ...[
|
|
StatusAvatar(info: avatar, size: avatarSize, badges: false, ring: false),
|
|
SizedBox(width: avatarGap),
|
|
],
|
|
text,
|
|
],
|
|
);
|
|
if (frame) {
|
|
line = DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
color: widget.accent.withValues(alpha: 0.14),
|
|
borderRadius: BorderRadius.circular(999),
|
|
border: Border.all(color: widget.accent.withValues(alpha: 0.85), width: 1.2),
|
|
),
|
|
child: Padding(padding: EdgeInsets.symmetric(horizontal: padX), child: line),
|
|
);
|
|
}
|
|
final height = math.max(textH, avatar == null ? 0.0 : avatarSize);
|
|
return _Line(item, width, height, SizedBox(width: width, height: height, child: Center(child: line)));
|
|
}
|
|
|
|
void _onTick(Duration elapsed) {
|
|
_elapsed = elapsed;
|
|
if (_size.isEmpty) return;
|
|
final t = _t;
|
|
for (final item in _pending) {
|
|
_lanes.add(_build(item), t);
|
|
}
|
|
_pending = [];
|
|
final s = widget.style;
|
|
_lanes.tick(t,
|
|
w: _size.width,
|
|
lanes: s.lanesFor(_size.height),
|
|
cap: s.maxOnScreen,
|
|
crossSecs: s.crossSecs,
|
|
measure: (l) => l.width);
|
|
final c = widget.controller;
|
|
c.onScreen.value = _lanes.flights.length;
|
|
c.waiting.value = _lanes.waiting;
|
|
c.dropped.value = _lanes.dropped;
|
|
final now = List.of(_lanes.flights);
|
|
if (now.length != _shown.length || !_sameFlights(now, _shown)) {
|
|
setState(() => _shown = now);
|
|
} else if (now.isNotEmpty) {
|
|
_repaint.value++;
|
|
}
|
|
}
|
|
|
|
static bool _sameFlights(List<DanmakuFlight<_Line>> a, List<DanmakuFlight<_Line>> b) {
|
|
for (var i = 0; i < a.length; i++) {
|
|
if (!identical(a[i], b[i])) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return IgnorePointer(
|
|
child: LayoutBuilder(builder: (context, box) {
|
|
_size = box.biggest;
|
|
return Opacity(
|
|
opacity: widget.style.opacity.clamp(0.0, 1.0),
|
|
child: Flow(
|
|
delegate: _DanmakuFlow(this, _repaint),
|
|
children: [for (final f in _shown) KeyedSubtree(key: ObjectKey(f), child: f.item.widget)],
|
|
),
|
|
);
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _DanmakuFlow extends FlowDelegate {
|
|
final _DanmakuLayerState state;
|
|
_DanmakuFlow(this.state, Listenable repaint) : super(repaint: repaint);
|
|
|
|
@override
|
|
BoxConstraints getConstraintsForChild(int i, BoxConstraints constraints) => const BoxConstraints();
|
|
|
|
@override
|
|
void paintChildren(FlowPaintingContext context) {
|
|
final s = state.widget.style;
|
|
final size = context.size;
|
|
final t = state._t;
|
|
final top = s.bandTop * size.height;
|
|
final lane = s.laneHeightFor(size.height);
|
|
final flights = state._shown;
|
|
for (var i = 0; i < flights.length && i < context.childCount; i++) {
|
|
final f = flights[i];
|
|
final x = f.x(t, size.width);
|
|
final y = top + f.lane * lane + (lane - f.item.height) / 2;
|
|
context.paintChild(i, transform: Matrix4.translationValues(x, y, 0));
|
|
}
|
|
}
|
|
|
|
@override
|
|
bool shouldRepaint(_DanmakuFlow old) => true;
|
|
}
|