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
+2244 -7

No files matched your search

+1
View File
@@ -12,6 +12,7 @@ Links you put up are downloaded once for everyone, plus What's new and fixes for
### Added ### Added
- What's new: the update band shows what changed, and after an update the app shows the full list once (also in Settings → About) - What's new: the update band shows what changed, and after an update the app shows the full list once (also in Settings → About)
- Sharper subtitles and picture: the video and its subtitles are drawn at your screen's resolution instead of the video's (Settings → Player → Sharp video and subtitles, on by default)
### Changed ### Changed
@@ -199,6 +199,35 @@ class ProgressLineSetting extends StatelessWidget {
} }
} }
/// The video output at the player's size on screen ([AppSettings.screenResVideo]).
class ScreenResSetting extends StatelessWidget {
final ReadonlySignal<RenderMode>? renderMode;
const ScreenResSetting({super.key, this.renderMode});
@override
Widget build(BuildContext context) {
final s = AppScope.of(context).coordinator.settings;
return SignalBuilder(builder: (context) {
final software = switch (renderMode?.value) {
RenderMode.software || RenderMode.softwareGl => true,
_ => false,
};
return SettingRow(
label: 'Sharp video and subtitles',
help: software
? 'No effect: video uses software rendering'
: "Scale the picture and draw subtitles at your screen's resolution, not the video's. "
'Off: lighter on a weak GPU',
control: Switch(
key: const ValueKey('screen-res-video'),
value: s.screenResVideo.value,
onChanged: s.setScreenResVideo,
),
);
});
}
}
/// Saves a shader choice (also used on the next join) and puts it on the /// Saves a shader choice (also used on the next join) and puts it on the
/// running player, if any; the player announces the result. /// running player, if any; the player announces the result.
void applyShaderChoice(SessionCoordinator coord, ShaderPreset preset, ShaderQuality? quality) { void applyShaderChoice(SessionCoordinator coord, ShaderPreset preset, ShaderQuality? quality) {
@@ -403,6 +432,7 @@ List<Widget> playerSettingRows({ReadonlySignal<RenderMode>? renderMode}) => [
const PlayerDesignSetting(), const PlayerDesignSetting(),
const ProgressLineSetting(), const ProgressLineSetting(),
ShaderPresetSetting(renderMode: renderMode), ShaderPresetSetting(renderMode: renderMode),
ScreenResSetting(renderMode: renderMode),
const HwdecSetting(), const HwdecSetting(),
]; ];
+475
View File
@@ -0,0 +1,475 @@
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;
}
@@ -0,0 +1,769 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import '../controls/minimal/style.dart';
import '../controls/session_controls.dart' show controlsBottomHeight;
import '../session/notifier.dart' show ToastItem;
import '../session/session_coordinator.dart' show SessionCoordinator;
import '../shell/avatar.dart' show AvatarInfo, avatarForName, colorForName, memberAvatarInfo, selfAvatarInfo;
import '../shell/motion.dart';
import '../app/app_graph.dart' show AppScope;
import '../widgets/app_menu.dart' show AppGlass, AppMenuStyle;
import 'danmaku.dart';
// Dev builds only (Settings → About → Developer): try the danmaku look on
// the real player (when something is loaded) or on a stand-in picture,
// fed with samples, a random stream or this session's own toasts, with
// every knob of [DanmakuStyle] live and mock subtitles to judge overlap.
/// The preview's state, owned by the shell while the app runs.
class DanmakuPreview {
final controller = DanmakuController();
/// Shown over the full session player.
final active = signal(false);
final style = signal(const DanmakuStyle());
/// A random stream: lines per minute, lengths from one word to a long
/// sentence, CJK and emoji among them.
final auto = signal(false);
final autoRate = signal(40.0);
/// Mock subtitles: a top sign and bottom dialogue.
final mockTop = signal(false);
final mockBottom = signal(false);
/// The stand-in picture's control band (the real player has its own).
final controlsBand = signal(true);
/// This session's chat and notice toasts fly too (session only).
final live = signal(false);
/// In a session: the party's real avatars for the samples and the
/// session's toasts (set while the session panel is up).
SessionCoordinator? coord;
final _rng = math.Random();
Timer? _autoTimer;
late final void Function() _stopAuto;
DanmakuPreview() {
_stopAuto = effect(() {
final on = auto.value;
autoRate.value;
untracked(() {
_autoTimer?.cancel();
if (on) _scheduleAuto();
});
});
}
void dispose() {
_stopAuto();
_autoTimer?.cancel();
controller.dispose();
}
void _scheduleAuto() {
// Poisson arrivals: bursts and lulls like a real chat.
final perSec = autoRate.peek() / 60;
final wait = -math.log(1 - _rng.nextDouble()) / perSec;
_autoTimer = Timer(Duration(milliseconds: (wait * 1000).round().clamp(30, 60000)), () {
if (!auto.peek()) return;
controller.add(randomSample());
_scheduleAuto();
});
}
/// One sample line of [kind]; [own]: as if this member wrote it.
/// Senders come from the party in a session (their pictures), else
/// from [sampleNames] (initials).
DanmakuItem sample(DanmakuKind kind, {bool own = false}) {
final a = own ? selfAvatarInfo() : _sampleSender();
final who = a.name;
final color = own ? a.color : colorForName(who);
return switch (kind) {
DanmakuKind.chat =>
DanmakuItem(kind: kind, sender: who, senderColor: color, text: _pick(sampleChat), own: own, avatar: a),
DanmakuKind.join => DanmakuItem(kind: kind, sender: who, senderColor: color, text: 'joined', avatar: a),
DanmakuKind.leave => DanmakuItem(kind: kind, sender: who, senderColor: color, text: 'left', avatar: a),
DanmakuKind.upNext => DanmakuItem(
kind: kind, sender: who, senderColor: color, text: 'added ${_pick(sampleTitles)} to Up next', avatar: a),
DanmakuKind.starting => const DanmakuItem(kind: DanmakuKind.starting, text: '▶ Starting now'),
};
}
/// [text] as this member's own chat line.
DanmakuItem own(String text) {
final a = selfAvatarInfo();
return DanmakuItem(kind: DanmakuKind.chat, sender: a.name, senderColor: a.color, text: text, own: true, avatar: a);
}
AvatarInfo _sampleSender() {
final c = coord;
final r = c?.serverState.peek();
final others = [
if (c != null && r != null)
for (final m in r.members)
if (m.id != c.connection.memberId) memberAvatarInfo(m, r, c),
];
// The party's real ones half the time, so initials show up too.
if (others.isNotEmpty && _rng.nextBool()) return _pick(others);
final name = _pick(sampleNames);
return AvatarInfo(name: name, color: colorForName(name));
}
/// What the random stream sends: mostly chat, some of it yours, now and
/// then a notice.
DanmakuItem randomSample() {
final r = _rng.nextDouble();
if (r < 0.70) return sample(DanmakuKind.chat);
if (r < 0.80) return sample(DanmakuKind.chat, own: true);
if (r < 0.88) return sample(DanmakuKind.join);
if (r < 0.94) return sample(DanmakuKind.leave);
return sample(DanmakuKind.upNext);
}
/// A toast of this session as a line: chat (name, then the message),
/// joins, leaves, Up next adds; the rest (problems) stay toasts.
DanmakuItem? fromToast(ToastItem t, {String? selfName}) {
final who = t.actor;
if (who == null || who.isEmpty) {
return t.text.toLowerCase().startsWith('starting')
? DanmakuItem(kind: DanmakuKind.starting, text: '▶ ${t.text}')
: null;
}
final color = colorForName(who);
final c = coord;
final avatar = c == null ? AvatarInfo(name: who, color: color) : avatarForName(who, c, t.actorId);
if (t.chat && t.text.startsWith('$who: ')) {
return DanmakuItem(
kind: DanmakuKind.chat,
sender: who,
senderColor: color,
text: t.text.substring(who.length + 2),
own: who == selfName,
avatar: avatar);
}
final rest = t.text.startsWith('$who ') ? t.text.substring(who.length + 1) : t.text;
final kind = rest.startsWith('joined')
? DanmakuKind.join
: rest.startsWith('left')
? DanmakuKind.leave
: rest.startsWith('added')
? DanmakuKind.upNext
: null;
if (kind == null) return null;
return DanmakuItem(kind: kind, sender: who, senderColor: color, text: rest, avatar: avatar);
}
/// Follows [toasts] while [live] is on; returns the stop.
void Function() watchToasts(ReadonlySignal<List<ToastItem>> toasts, {String? selfName}) {
var seen = {for (final t in toasts.peek()) t.id};
return effect(() {
final items = toasts.value;
final on = live.value;
untracked(() {
for (final t in items) {
if (!seen.add(t.id) || !on) continue;
final line = fromToast(t, selfName: selfName);
if (line != null) controller.add(line);
}
seen = {for (final t in items) t.id};
});
});
}
T _pick<T>(List<T> from) => from[_rng.nextInt(from.length)];
static const sampleNames = ['Alice', 'Bob', 'Chiyo', '小雪', 'Kenji', 'Mara'];
static const sampleTitles = ['Frieren · 12', 'Bocchi the Rock! · 5', 'Dungeon Meshi · 20', 'Mob Psycho 100 · 3'];
static const sampleChat = [
'lol',
'www',
'草',
'8888888',
'NICE',
'it\'s so over',
'she\'s back!!',
'wait, who is that?',
'op skip pls 🙏',
'the animation in this scene is insane',
'I can\'t believe they actually adapted this arc, the manga panel looked exactly like this, chills honestly',
'brb getting snacks, don\'t wait for me (actually do wait)',
'前方高能预警!!!',
'このシーン最高すぎる',
'神回確定',
'画质好清晰啊',
'🔥🔥🔥',
'😭😭 my heart',
'🎉 finally',
'Himmel would have done the same 🥲',
];
}
/// The danmaku with this preview's style and mock subtitles over a
/// picture; lets clicks through.
class DanmakuPreviewLayers extends StatelessWidget {
final DanmakuPreview preview;
const DanmakuPreviewLayers({super.key, required this.preview});
@override
Widget build(BuildContext context) {
final accent = Theme.of(context).colorScheme.primary;
return SignalBuilder(
builder: (context) => Stack(
fit: StackFit.expand,
children: [
MockSubtitles(top: preview.mockTop.value, bottom: preview.mockBottom.value),
DanmakuLayer(controller: preview.controller, style: preview.style.value, accent: accent),
],
),
);
}
}
/// Stand-ins for subtitles where real ones sit: an ASS sign at the top
/// (`\an8`, yellow serif) and two lines of dialogue at the bottom.
class MockSubtitles extends StatelessWidget {
final bool top;
final bool bottom;
const MockSubtitles({super.key, required this.top, required this.bottom});
static Widget _outlined(String text, TextStyle style, double outline) => Stack(
children: [
Text(text,
textAlign: TextAlign.center,
style: style.copyWith(
color: null,
foreground: Paint()
..style = PaintingStyle.stroke
..strokeWidth = outline
..strokeJoin = StrokeJoin.round
..color = const Color(0xFF1A1206),
)),
Text(text, textAlign: TextAlign.center, style: style),
],
);
@override
Widget build(BuildContext context) {
if (!top && !bottom) return const SizedBox.shrink();
return IgnorePointer(
child: LayoutBuilder(builder: (context, box) {
final h = box.maxHeight;
final dialogue = (h * 0.05).clamp(12.0, 52.0);
return Stack(
children: [
if (top)
Positioned(
left: 0,
right: 0,
top: h * 0.035,
child: Center(
child: _outlined(
'TOP SIGN · 第3新東京市',
TextStyle(
fontSize: dialogue * 0.85,
fontWeight: FontWeight.w800,
color: const Color(0xFFFFE14D),
),
dialogue * 0.14,
),
),
),
if (bottom)
Positioned(
left: 0,
right: 0,
bottom: h * 0.05,
child: Center(
child: _outlined(
'I told you the controls would cover this line.\nThey never do now — or do they?',
TextStyle(fontSize: dialogue, height: 1.15, color: Colors.white, fontWeight: FontWeight.w500),
dialogue * 0.12,
),
),
),
],
);
}),
);
}
}
/// A moving picture for the preview when nothing is loaded: slow colour
/// fields drifting over a dusk gradient, so motion and contrast behind
/// the lines are realistic.
class DanmakuStandIn extends StatefulWidget {
const DanmakuStandIn({super.key});
@override
State<DanmakuStandIn> createState() => _DanmakuStandInState();
}
class _DanmakuStandInState extends State<DanmakuStandIn> with SingleTickerProviderStateMixin {
late final AnimationController _clock =
AnimationController(vsync: this, duration: const Duration(seconds: 60))..repeat();
@override
void dispose() {
_clock.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => RepaintBoundary(
child: CustomPaint(painter: _StandInPainter(_clock), child: const SizedBox.expand()),
);
}
class _StandInPainter extends CustomPainter {
final Animation<double> clock;
_StandInPainter(this.clock) : super(repaint: clock);
static const _blobs = [
(Color(0xFFFF7A59), 0.9, 0.0, 0.42),
(Color(0xFF7C5CFF), 1.3, 1.7, 0.5),
(Color(0xFF2EC5CE), 0.7, 3.1, 0.38),
(Color(0xFFFFC857), 1.1, 4.4, 0.3),
(Color(0xFFE85D9E), 0.5, 5.2, 0.45),
];
@override
void paint(Canvas canvas, Size size) {
final rect = Offset.zero & size;
canvas.drawRect(
rect,
Paint()
..shader = const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFF0B1030), Color(0xFF2A1B4A), Color(0xFF5A2F4F), Color(0xFF14141C)],
stops: [0, 0.45, 0.72, 1],
).createShader(rect),
);
final t = clock.value * 2 * math.pi;
final r = size.shortestSide;
for (final (color, speed, phase, radius) in _blobs) {
final c = Offset(
size.width * (0.5 + 0.38 * math.sin(t * speed + phase)),
size.height * (0.5 + 0.32 * math.cos(t * speed * 0.8 + phase * 1.3)),
);
canvas.drawCircle(
c,
r * radius,
Paint()
..shader = RadialGradient(colors: [color.withValues(alpha: 0.55), color.withValues(alpha: 0)])
.createShader(Rect.fromCircle(center: c, radius: r * radius)),
);
}
// A bright horizon line and some stars: hard edges behind the text.
final horizon = size.height * 0.68;
canvas.drawRect(
Rect.fromLTWH(0, horizon, size.width, size.height - horizon),
Paint()..color = const Color(0xCC0D0D14),
);
canvas.drawLine(Offset(0, horizon), Offset(size.width, horizon),
Paint()
..color = const Color(0x88FFD7A8)
..strokeWidth = 1.5);
final stars = math.Random(7);
for (var i = 0; i < 90; i++) {
final x = stars.nextDouble() * size.width;
final y = stars.nextDouble() * horizon;
final twinkle = 0.4 + 0.6 * (0.5 + 0.5 * math.sin(t * 8 + i));
canvas.drawCircle(Offset(x, y), 0.6 + stars.nextDouble() * 1.2,
Paint()..color = Colors.white.withValues(alpha: 0.5 * twinkle));
}
}
@override
bool shouldRepaint(_StandInPainter old) => false;
}
/// The preview's knobs on the app's glass: fire samples, the random
/// stream, the look, mock subtitles. [onClose] ends the preview; it
/// collapses to its title bar.
class DanmakuPanel extends StatefulWidget {
final DanmakuPreview preview;
final VoidCallback onClose;
/// In a session: offer this session's own toasts as a source.
final bool session;
const DanmakuPanel({super.key, required this.preview, required this.onClose, this.session = false});
@override
State<DanmakuPanel> createState() => _DanmakuPanelState();
}
class _DanmakuPanelState extends State<DanmakuPanel> {
bool _open = true;
DanmakuPreview get p => widget.preview;
void _style(DanmakuStyle Function(DanmakuStyle s) f) => p.style.value = f(p.style.peek());
@override
Widget build(BuildContext context) {
final muted = Colors.white.withValues(alpha: 0.6);
return ExcludeFocus(
child: AppGlass(
child: SizedBox(
width: 300,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(14, 8, 6, 4),
child: Row(
children: [
const Expanded(
child: Text('Danmaku preview', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 14)),
),
_Counts(controller: p.controller),
IconButton(
tooltip: _open ? 'Collapse' : 'Expand',
visualDensity: VisualDensity.compact,
iconSize: 18,
onPressed: () => setState(() => _open = !_open),
icon: AnimatedRotation(
turns: _open ? 0 : 0.5,
duration: easeTime,
curve: ease,
child: const Icon(Icons.expand_less),
),
),
IconButton(
tooltip: 'Close the preview',
visualDensity: VisualDensity.compact,
iconSize: 18,
onPressed: widget.onClose,
icon: const Icon(Icons.close),
),
],
),
),
AnimatedSize(
duration: easeTime,
curve: ease,
alignment: Alignment.topCenter,
child: !_open
? const SizedBox(width: double.infinity)
: ConstrainedBox(
constraints: BoxConstraints(maxHeight: MediaQuery.sizeOf(context).height * 0.7),
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(14, 0, 14, 12),
child: SignalBuilder(builder: (context) => _body(muted)),
),
),
),
],
),
),
),
);
}
Widget _body(Color muted) {
final s = p.style.value;
Widget fire(IconData icon, String label, DanmakuItem Function() item) => MinimalButton(
icon: icon,
label: label,
tooltip: '',
scale: 0.8,
onPressed: () => p.controller.add(item()),
);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_Head('Send', muted),
Wrap(
spacing: 4,
runSpacing: 4,
children: [
fire(Icons.chat_bubble_outline, 'Chat', () => p.sample(DanmakuKind.chat)),
fire(Icons.person_outline, 'Mine', () => p.sample(DanmakuKind.chat, own: true)),
fire(Icons.login, 'Join', () => p.sample(DanmakuKind.join)),
fire(Icons.logout, 'Leave', () => p.sample(DanmakuKind.leave)),
fire(Icons.playlist_add_rounded, 'Up next', () => p.sample(DanmakuKind.upNext)),
fire(Icons.play_arrow_rounded, 'Starting', () => p.sample(DanmakuKind.starting)),
fire(Icons.bolt, 'Burst ×12', () {
for (var i = 0; i < 11; i++) {
p.controller.add(p.randomSample());
}
return p.randomSample();
}),
MinimalButton(
icon: Icons.clear_all,
label: 'Clear',
tooltip: '',
scale: 0.8,
onPressed: p.controller.clear,
),
],
),
_Toggle('Random stream', p.auto.value, (v) => p.auto.value = v),
_Slider('Rate', p.autoRate.value, 5, 240, '${p.autoRate.value.round()}/min', (v) => p.autoRate.value = v,
enabled: p.auto.value),
if (widget.session) _Toggle("This session's chat and notices", p.live.value, (v) => p.live.value = v),
_Head('Look', muted),
_Slider('Crossing', s.crossSecs, 4, 16, '${s.crossSecs.toStringAsFixed(1)} s',
(v) => _style((s) => s.copyWith(crossSecs: v))),
_Slider('At most', s.maxOnScreen.toDouble(), 1, 40, '${s.maxOnScreen} lines',
(v) => _style((s) => s.copyWith(maxOnScreen: v.round()))),
_Range('Lanes', s.bandTop, s.bandBottom, '${(s.bandTop * 100).round()}–${(s.bandBottom * 100).round()} %',
(a, b) => _style((s) => s.copyWith(bandTop: a, bandBottom: b))),
_Slider('Opacity', s.opacity, 0.3, 1, '${(s.opacity * 100).round()} %',
(v) => _style((s) => s.copyWith(opacity: v))),
_Slider('Size', s.size, 0.6, 1.8, '×${s.size.toStringAsFixed(2)}', (v) => _style((s) => s.copyWith(size: v))),
_Slider('Outline', s.outline, 0, 4, '${s.outline.toStringAsFixed(1)} px',
(v) => _style((s) => s.copyWith(outline: v))),
_Toggle('Avatars', s.avatars, (v) => _style((s) => s.copyWith(avatars: v))),
_Toggle('Frame my own lines', s.highlightOwn, (v) => _style((s) => s.copyWith(highlightOwn: v))),
_Head('Subtitles (mock)', muted),
_Toggle('Top sign', p.mockTop.value, (v) => p.mockTop.value = v),
_Toggle('Bottom dialogue', p.mockBottom.value, (v) => p.mockBottom.value = v),
if (!widget.session) _Toggle('Control bar', p.controlsBand.value, (v) => p.controlsBand.value = v),
],
);
}
}
class _Counts extends StatelessWidget {
final DanmakuController controller;
const _Counts({required this.controller});
@override
Widget build(BuildContext context) => ListenableBuilder(
listenable: Listenable.merge([controller.onScreen, controller.waiting]),
builder: (context, _) => Tooltip(
message: 'Flying · waiting for a lane',
child: Text(
'${controller.onScreen.value} · ${controller.waiting.value}',
style: TextStyle(
fontSize: 12,
color: Colors.white.withValues(alpha: 0.6),
fontFeatures: const [FontFeature.tabularFigures()],
),
),
),
);
}
class _Head extends StatelessWidget {
final String text;
final Color color;
const _Head(this.text, this.color);
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.only(top: 10, bottom: 4),
child: Text(text.toUpperCase(),
style: TextStyle(fontSize: 11, letterSpacing: 0.8, fontWeight: FontWeight.w600, color: color)),
);
}
class _Toggle extends StatelessWidget {
final String label;
final bool value;
final ValueChanged<bool> onChanged;
const _Toggle(this.label, this.value, this.onChanged);
@override
Widget build(BuildContext context) => SizedBox(
height: 34,
child: Row(
children: [
Expanded(child: Text(label, style: const TextStyle(fontSize: 13))),
Transform.scale(scale: 0.75, alignment: Alignment.centerRight, child: Switch(value: value, onChanged: onChanged)),
],
),
);
}
class _Slider extends StatelessWidget {
final String label;
final double value, min, max;
final String readout;
final ValueChanged<double> onChanged;
final bool enabled;
const _Slider(this.label, this.value, this.min, this.max, this.readout, this.onChanged, {this.enabled = true});
@override
Widget build(BuildContext context) => _SliderFrame(
label: label,
readout: readout,
child: Slider(value: value.clamp(min, max), min: min, max: max, onChanged: enabled ? onChanged : null),
);
}
class _Range extends StatelessWidget {
final String label;
final double a, b;
final String readout;
final void Function(double a, double b) onChanged;
const _Range(this.label, this.a, this.b, this.readout, this.onChanged);
@override
Widget build(BuildContext context) => _SliderFrame(
label: label,
readout: readout,
child: RangeSlider(
values: RangeValues(a, b),
max: 0.9,
onChanged: (v) {
if (v.end - v.start >= 0.05) onChanged(v.start, v.end);
},
),
);
}
class _SliderFrame extends StatelessWidget {
final String label;
final String readout;
final Widget child;
const _SliderFrame({required this.label, required this.readout, required this.child});
@override
Widget build(BuildContext context) => SizedBox(
height: 34,
child: Row(
children: [
SizedBox(width: 64, child: Text(label, style: const TextStyle(fontSize: 13))),
Expanded(
child: SliderTheme(
data: SliderTheme.of(context).copyWith(
trackHeight: 3,
overlayShape: SliderComponentShape.noOverlay,
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6),
rangeThumbShape: const RoundRangeSliderThumbShape(enabledThumbRadius: 6),
),
child: child,
),
),
SizedBox(
width: 76,
child: Text(
readout,
textAlign: TextAlign.right,
style: TextStyle(
fontSize: 12,
color: Colors.white.withValues(alpha: 0.7),
fontFeatures: const [FontFeature.tabularFigures()],
),
),
),
],
),
);
}
/// The preview without a session player: the stand-in picture, a mock
/// control bar of the skin in use, the lines and the panel, over the
/// whole window (Esc or × closes it).
Future<void> showDanmakuPreviewPage(BuildContext context, DanmakuPreview preview) => showGeneralDialog<void>(
context: context,
barrierDismissible: false,
barrierLabel: 'Danmaku preview',
barrierColor: Colors.black,
transitionDuration: easeTime,
pageBuilder: (context, _, _) => DanmakuPreviewPage(preview: preview),
transitionBuilder: (context, anim, _, child) => FadeTransition(
opacity: CurvedAnimation(parent: anim, curve: ease, reverseCurve: Curves.easeIn),
child: child,
),
);
class DanmakuPreviewPage extends StatelessWidget {
final DanmakuPreview preview;
const DanmakuPreviewPage({super.key, required this.preview});
@override
Widget build(BuildContext context) {
return Material(
color: Colors.black,
child: Stack(
fit: StackFit.expand,
children: [
const DanmakuStandIn(),
DanmakuPreviewLayers(preview: preview),
SignalBuilder(builder: (context) {
if (!preview.controlsBand.value) return const SizedBox.shrink();
final s = AppScope.of(context).coordinator.settings;
final h = controlsBottomHeight(s.controlsSkin.value, s.controlsSize.value);
return Align(
alignment: Alignment.bottomCenter,
child: IgnorePointer(
child: Container(
height: h,
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0x00000000), MinimalStyle.band],
),
border: Border(top: BorderSide(color: AppMenuStyle.border, width: 0.5)),
),
alignment: Alignment.center,
child: Text('control bar', style: TextStyle(color: Colors.white.withValues(alpha: 0.35), fontSize: 12)),
),
),
);
}),
Positioned(
left: 16,
top: 14,
child: Text('Stand-in picture: load something to preview on the real player',
style: TextStyle(color: Colors.white.withValues(alpha: 0.55), fontSize: 12)),
),
Positioned(
right: 16,
top: 16,
child: DanmakuPanel(preview: preview, onClose: () => Navigator.of(context).pop()),
),
],
),
);
}
}
/// The panel over the session player; while mounted, this session's
/// toasts reach the preview when its "chat and notices" switch is on.
class DanmakuSessionPanel extends StatefulWidget {
final DanmakuPreview preview;
final SessionCoordinator coord;
const DanmakuSessionPanel({super.key, required this.preview, required this.coord});
@override
State<DanmakuSessionPanel> createState() => _DanmakuSessionPanelState();
}
class _DanmakuSessionPanelState extends State<DanmakuSessionPanel> {
late final void Function() _stop = widget.preview.watchToasts(widget.coord.toasts);
@override
void initState() {
super.initState();
widget.preview.coord = widget.coord;
_stop;
}
@override
void dispose() {
_stop();
widget.preview.coord = null;
super.dispose();
}
@override
Widget build(BuildContext context) => DanmakuPanel(
preview: widget.preview,
session: true,
onClose: () => widget.preview.active.value = false,
);
}
@@ -75,6 +75,9 @@ class MediaKitHost implements PlayerHost {
: _hwdecSetting = hwdecSetting ?? (() => AppSettings.instance.hwdec.peek()); : _hwdecSetting = hwdecSetting ?? (() => AppSettings.instance.hwdec.peek());
final _live = <_MkPlayer>{}; final _live = <_MkPlayer>{};
final _videos = <_MkVideo>{};
// The size outputs render at ([setOutputSize]); null: the video's own.
(int, int)? _outputSize;
final _renderMode = signal(RenderMode.unknown); final _renderMode = signal(RenderMode.unknown);
final _http = StreamController<String>.broadcast(); final _http = StreamController<String>.broadcast();
final _throttle = LogThrottle(burst: 5); final _throttle = LogThrottle(burst: 5);
@@ -90,6 +93,16 @@ class MediaKitHost implements PlayerHost {
@override @override
ReadonlySignal<RenderMode> get renderMode => _renderMode; ReadonlySignal<RenderMode> get renderMode => _renderMode;
@override
void setOutputSize(int? width, int? height) {
final size = width != null && height != null && width > 0 && height > 0 ? (width, height) : null;
if (size == _outputSize) return;
_outputSize = size;
for (final v in _videos.toList()) {
unawaited(v.applySize());
}
}
@override @override
Stream<String> get httpErrors => _http.stream; Stream<String> get httpErrors => _http.stream;
@@ -185,6 +198,9 @@ class MediaKitHost implements PlayerHost {
// configured), which used to override SYNC_RELAY_HWDEC. // configured), which used to override SYNC_RELAY_HWDEC.
final controller = VideoController(p.mk, configuration: VideoControllerConfiguration(hwdec: hwdec)); final controller = VideoController(p.mk, configuration: VideoControllerConfiguration(hwdec: hwdec));
final v = _MkVideo(p, controller); final v = _MkVideo(p, controller);
_videos.add(v);
p._video = v;
if (_outputSize != null) unawaited(v.applySize());
// mpv gets `vo=libmpv` before the output's render context exists; a // mpv gets `vo=libmpv` before the output's render context exists; a
// media opened in between fails its video output ("No render context // media opened in between fails its video output ("No render context
// set") and plays black. Opens wait for the output (seen on X11, where // set") and plays black. Opens wait for the output (seen on X11, where
@@ -340,6 +356,10 @@ class MediaKitHost implements PlayerHost {
final p = player as _MkPlayer; final p = player as _MkPlayer;
if (!_live.remove(p)) return; if (!_live.remove(p)) return;
p._retired = true; p._retired = true;
if (p._video case final v?) {
_videos.remove(v);
await v._paramsSub?.cancel();
}
await p._logSub?.cancel(); await p._logSub?.cancel();
try { try {
await p.mk.dispose(); await p.mk.dispose();
@@ -407,6 +427,7 @@ class _MkPlayer implements HostedPlayer {
// The last open, for one retry when mpv's video output was not up yet. // The last open, for one retry when mpv's video output was not up yet.
({String url, Map<String, String> headers, bool play})? _lastOpen; ({String url, Map<String, String> headers, bool play})? _lastOpen;
bool _voRetried = false; bool _voRetried = false;
_MkVideo? _video;
dynamic get _mpv => mk.platform; dynamic get _mpv => mk.platform;
@@ -529,4 +550,53 @@ class _MkVideo implements HostedVideo {
@override @override
Future<void> get ready => controller.platform.future; Future<void> get ready => controller.platform.future;
// What this output was last set to: null with [_sized] the video's own
// size again; nothing sent yet while [_sized] is false.
(int, int)? _sent;
bool _sized = false;
StreamSubscription<VideoParams>? _paramsSub;
Future<void> _sizeOp = Future.value();
/// Renders at the host's output size (or the video's own when it has
/// none). media_kit's controller sets the output back to the video's
/// size whenever the video's parameters change (a new file, a track
/// with another size); the fixed size is sent again after it, under
/// the controller's own lock so it lands last.
Future<void> applySize({bool again = false}) => _sizeOp = _sizeOp.then((_) async {
if (player.disposed) return;
try {
// NativeVideoController (Linux, Windows): not exported, reached
// through its public fields.
final dynamic native = await controller.platform.future;
final want = player.host._outputSize;
if (_sized && want == _sent && !again) return;
if (want == null) {
if (!_sized) return; // never resized: already the video's size
await native.setSize();
} else {
// setSize skips a size it believes is set; after media_kit's own
// reset it is not.
native.width = null;
await native.setSize(width: want.$1, height: want.$2);
}
_sent = want;
_sized = true;
// The output takes its new size with the next frame mpv draws;
// paused, none comes: a pan too small to see makes mpv redraw.
if (!player.state.playing) {
await player.setProperty('video-pan-x', '0.000001');
await player.setProperty('video-pan-x', '0');
}
_paramsSub ??= player.mk.stream.videoParams.listen((params) async {
if ((params.dw ?? 0) == 0 || _sent == null) return;
try {
await (native.lock.synchronized(() async {}) as Future);
} catch (_) {}
unawaited(applySize(again: true));
});
} catch (e) {
debugPrint('video output size: $e');
}
});
} }
@@ -148,6 +148,12 @@ abstract interface class PlayerHost {
/// start). /// start).
HostedVideo video(HostedPlayer player, {bool probe = false}); HostedVideo video(HostedPlayer player, {bool probe = false});
/// The pixel size every video output renders at: the view's, so mpv
/// scales the picture and draws subtitles at screen resolution
/// (Settings → Player, "Sharp video"). Null: each output at its video's
/// own size. Applies to live outputs and to new ones once they are up.
void setOutputSize(int? width, int? height);
/// How video renders, as last learned (kept after leaving). /// How video renders, as last learned (kept after leaving).
ReadonlySignal<RenderMode> get renderMode; ReadonlySignal<RenderMode> get renderMode;
@@ -18,6 +18,48 @@ import 'update_indicator.dart';
const uoscCredit = 'Player timeline and controls inspired by uosc by tomasklaen, LGPL-2.1 ' const uoscCredit = 'Player timeline and controls inspired by uosc by tomasklaen, LGPL-2.1 '
'(github.com/tomasklaen/uosc).'; '(github.com/tomasklaen/uosc).';
/// Dev builds only ([devTools]): previews and experiments not decided
/// yet (DEVELOPMENT.md "Dev tools").
class DeveloperPanel extends StatelessWidget {
final VoidCallback? onDanmakuPreview;
const DeveloperPanel({super.key, this.onDanmakuPreview});
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
final s = AppScope.of(context).coordinator.settings;
return SettingsPanel(
title: 'Developer',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Test builds only: try what is not decided yet.', style: t.bodySmall?.copyWith(color: Colors.grey)),
const SizedBox(height: 8),
FilledButton.tonalIcon(
key: const ValueKey('danmaku-preview'),
onPressed: onDanmakuPreview,
icon: const Icon(Icons.subtitles_outlined, size: 18),
label: const Text('Danmaku preview'),
),
const SizedBox(height: 8),
SignalBuilder(
builder: (context) => SwitchListTile(
key: const ValueKey('lift-subtitles'),
contentPadding: EdgeInsets.zero,
title: const Text('Lift subtitles above the chat line and controls'),
subtitle: const Text('Enter in the player opens a stand-in chat line at the bottom. '
'While it or the control bar is up, bottom subtitles move above it (mpv sub-pos); '
'signs at the top stay where they are.'),
value: s.liftSubtitles.value,
onChanged: s.setLiftSubtitles,
),
),
],
),
);
}
}
/// Name, version, update state, What's new in this version, the source /// Name, version, update state, What's new in this version, the source
/// link and the logs (Settings → About). /// link and the logs (Settings → About).
class AboutPanel extends StatelessWidget { class AboutPanel extends StatelessWidget {
@@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart' show kDebugMode;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:sync_relay_core/core.dart' show appVersion, isDevBuild; import 'package:sync_relay_core/core.dart' show appVersion, isDevBuild;
@@ -26,6 +27,14 @@ String displayVersion({String version = appVersion, String pubspec = _pubspecVer
/// Settings tab): "1.2.1", "1.2.1-dev.2", "dev run". /// Settings tab): "1.2.1", "1.2.1-dev.2", "dev run".
String shortVersion({String version = appVersion}) => version == '0.0.0-dev' ? 'dev run' : version; String shortVersion({String version = appVersion}) => version == '0.0.0-dev' ? 'dev run' : version;
/// Dev tools (Settings → About → Developer, the experiments they switch
/// on) only in test builds and runs from source (a `-dev` version) and
/// debug runs, never in a release.
bool devToolsFor({String version = appVersion, bool debug = kDebugMode}) => debug || version.contains('-dev');
/// [devToolsFor] this build.
final bool devTools = devToolsFor();
const kRadius = 12.0; const kRadius = 12.0;
const kGap = 16.0; const kGap = 16.0;
+20
View File
@@ -7,6 +7,7 @@ import 'package:signals/signals_flutter.dart';
import '../controls/frame.dart' show DoubleClickListener; import '../controls/frame.dart' show DoubleClickListener;
import '../controls/session_controls.dart' show controlsBottomHeight; import '../controls/session_controls.dart' show controlsBottomHeight;
import '../danmaku/danmaku_preview.dart';
import '../files/folder_store.dart'; import '../files/folder_store.dart';
import '../files/ui/files_page.dart' show showFilesFolder; import '../files/ui/files_page.dart' show showFilesFolder;
import '../jellyfin/accounts.dart'; import '../jellyfin/accounts.dart';
@@ -248,6 +249,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
for (final stop in _stops) { for (final stop in _stops) {
stop(); stop();
} }
_danmaku?.dispose();
HardwareKeyboard.instance.removeHandler(_onKey); HardwareKeyboard.instance.removeHandler(_onKey);
_whatsNewSource?.due.removeListener(_maybeWhatsNew); _whatsNewSource?.due.removeListener(_maybeWhatsNew);
_updateTimer?.cancel(); _updateTimer?.cancel();
@@ -358,6 +360,22 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
void _expand() => _show(PlayerMode.full); void _expand() => _show(PlayerMode.full);
/// Dev builds: the danmaku preview, made on first use.
DanmakuPreview? _danmaku;
/// Over the full player when a player is up, else on its own page with
/// a stand-in picture.
void _openDanmakuPreview() {
final d = _danmaku ??= DanmakuPreview();
if (_online.peek() && _graph.coordinator.playback.alive.peek()) {
setState(() => d.active.value = true);
_show(PlayerMode.full);
} else {
d.active.value = false;
showDanmakuPreviewPage(context, d);
}
}
void _collapse() { void _collapse() {
if (_fullscreen) _toggleFullscreen(); if (_fullscreen) _toggleFullscreen();
_show(PlayerMode.mini); _show(PlayerMode.mini);
@@ -765,6 +783,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
onEditProfile: () => showProfileCard(context, onOpenSettings: _openSettings, updates: widget.updates), onEditProfile: () => showProfileCard(context, onOpenSettings: _openSettings, updates: widget.updates),
updates: widget.updates, updates: widget.updates,
jellyfin: _jellyfin, jellyfin: _jellyfin,
onDanmakuPreview: _openDanmakuPreview,
), ),
}; };
final full = _shown == PlayerMode.full; final full = _shown == PlayerMode.full;
@@ -908,6 +927,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
videoBuilder: widget.videoBuilder, videoBuilder: widget.videoBuilder,
windowControls: widget.windowControls, windowControls: widget.windowControls,
controlsShown: _controlsShown, controlsShown: _controlsShown,
danmaku: _danmaku,
), ),
), ),
), ),
+161 -2
View File
@@ -13,9 +13,11 @@ import '../controls/model/controls_layout.dart';
import '../controls/model/controls_model.dart'; import '../controls/model/controls_model.dart';
import '../controls/session_controls.dart'; import '../controls/session_controls.dart';
import '../controls/volume.dart'; import '../controls/volume.dart';
import '../danmaku/danmaku_preview.dart';
import '../platform/desktop/media_kit_host.dart' show videoControllerOf; import '../platform/desktop/media_kit_host.dart' show videoControllerOf;
import '../queue/queue_ui.dart'; import '../queue/queue_ui.dart';
import '../playback/media_marks.dart' show SkipKind, SkipOffer, formatClock; import '../playback/media_marks.dart' show SkipKind, SkipOffer, formatClock;
import '../screens/home/ui.dart' show devTools;
import '../screens/session/jellyfin_widgets.dart'; import '../screens/session/jellyfin_widgets.dart';
import '../screens/session/player_controls.dart'; import '../screens/session/player_controls.dart';
import '../session/app_events.dart' show ShaderMessage; import '../session/app_events.dart' show ShaderMessage;
@@ -24,6 +26,7 @@ import '../shaders/shader_presets.dart';
import '../app/app_graph.dart'; import '../app/app_graph.dart';
import '../state/settings.dart'; import '../state/settings.dart';
import '../widgets/app_keys.dart'; import '../widgets/app_keys.dart';
import '../widgets/app_menu.dart' show AppGlass;
import '../widgets/text_focus.dart'; import '../widgets/text_focus.dart';
import '../widgets/window_buttons.dart'; import '../widgets/window_buttons.dart';
import 'avatar.dart'; import 'avatar.dart';
@@ -32,6 +35,7 @@ import 'now_playing_title.dart';
import 'player_status.dart'; import 'player_status.dart';
import 'sit_out_card.dart'; import 'sit_out_card.dart';
import 'start_banner.dart'; import 'start_banner.dart';
import 'video_view.dart';
/// Where the session's video is shown: nowhere, the floating mini player, /// Where the session's video is shown: nowhere, the floating mini player,
/// or filling the body (and, with fullscreen, the window). /// or filling the body (and, with fullscreen, the window).
@@ -69,6 +73,9 @@ class SessionPlayer extends StatefulWidget {
/// Follows whether the full player's controls are up (the shell lifts /// Follows whether the full player's controls are up (the shell lifts
/// its toasts above them); the player keeps its own when null. /// its toasts above them); the player keeps its own when null.
final ValueNotifier<bool>? controlsShown; final ValueNotifier<bool>? controlsShown;
/// Dev builds: the danmaku preview, shown over the full player while
/// it is active.
final DanmakuPreview? danmaku;
const SessionPlayer({ const SessionPlayer({
super.key, super.key,
required this.mode, required this.mode,
@@ -86,6 +93,7 @@ class SessionPlayer extends StatefulWidget {
this.videoBuilder, this.videoBuilder,
this.windowControls = true, this.windowControls = true,
this.controlsShown, this.controlsShown,
this.danmaku,
}); });
@override @override
@@ -104,6 +112,11 @@ class _SessionPlayerState extends State<SessionPlayer> {
bool _miniHover = false; bool _miniHover = false;
// Whether the controls are up (their frame reports it). // Whether the controls are up (their frame reports it).
final _ownShown = ValueNotifier(true); final _ownShown = ValueNotifier(true);
// Dev builds with "Lift subtitles": Enter opens a stand-in for the
// planned chat line at the bottom, in place of the control bar.
final _chatLine = ValueNotifier(false);
final _chatLineText = TextEditingController();
final _chatLineFocus = FocusNode();
ValueNotifier<bool> get _controlsShown => widget.controlsShown ?? _ownShown; ValueNotifier<bool> get _controlsShown => widget.controlsShown ?? _ownShown;
// The mouse on a floating element, or the Up next popover open, keeps // The mouse on a floating element, or the Up next popover open, keeps
// the controls up. // the controls up.
@@ -166,6 +179,9 @@ class _SessionPlayerState extends State<SessionPlayer> {
_stopPopoverWatch(); _stopPopoverWatch();
_noFocus.dispose(); _noFocus.dispose();
_ownShown.dispose(); _ownShown.dispose();
_chatLine.dispose();
_chatLineText.dispose();
_chatLineFocus.dispose();
_hold.dispose(); _hold.dispose();
_model.dispose(); _model.dispose();
super.dispose(); super.dispose();
@@ -197,6 +213,12 @@ class _SessionPlayerState extends State<SessionPlayer> {
return false; return false;
} }
if (textInputFocused()) return false; if (textInputFocused()) return false;
if (devTools &&
_coord.settings.liftSubtitles.peek() &&
(event.logicalKey == LogicalKeyboardKey.enter || event.logicalKey == LogicalKeyboardKey.numpadEnter)) {
_openChatLine();
return true;
}
final now = DateTime.now().toUtc(); final now = DateTime.now().toUtc();
if (_lastKeyAt != null && now.difference(_lastKeyAt!).inMilliseconds < 350) { if (_lastKeyAt != null && now.difference(_lastKeyAt!).inMilliseconds < 350) {
return false; return false;
@@ -306,11 +328,15 @@ class _SessionPlayerState extends State<SessionPlayer> {
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
video, video,
// Dev experiment: bottom subtitles above the controls.
if (devTools) _subtitleLifter(),
if (_full) ...[ if (_full) ...[
_blockedCard(), _blockedCard(),
_actionArea(), _actionArea(),
if (widget.fullscreen) ...[if (!_coord.alone.value) _pausedParty(), _unreadChip()], if (widget.fullscreen) ...[if (!_coord.alone.value) _pausedParty(), _unreadChip()],
_volumeReadout(), _volumeReadout(),
if (devTools) _chatLineOverlay(),
if (widget.danmaku case final d?) _danmakuPanel(d),
] else ] else
_miniOverlay(), _miniOverlay(),
// Sitting out: nothing is loaded; the card covers the // Sitting out: nothing is loaded; the card covers the
@@ -324,11 +350,111 @@ class _SessionPlayerState extends State<SessionPlayer> {
); );
} }
Widget _video(VideoControlsBuilder? controls) => Video( Widget _video(VideoControlsBuilder? controls) => VideoOutputSizer(
playback: _coord.playback,
sharp: _coord.settings.screenResVideo,
child: Video(
key: _videoKey, key: _videoKey,
controller: videoControllerOf(_coord.playback.video), controller: videoControllerOf(_coord.playback.video),
subtitleViewConfiguration: const SubtitleViewConfiguration(visible: false), subtitleViewConfiguration: const SubtitleViewConfiguration(visible: false),
controls: controls, controls: controls,
),
);
/// What covers the bottom of the full player, for the subtitle lift:
/// the chat line, else the control bar while it is up.
Widget _subtitleLifter() => SignalBuilder(builder: (context) {
final controls = _bottomControlsHeight();
return SubtitleLifter(
playback: _coord.playback,
changes: Listenable.merge([_controlsShown, _chatLine]),
lift: _coord.settings.liftSubtitles,
cover: () => _chatLine.value
? _chatLineHeight + _edge
: _controlsShown.value
? controls
: 0,
active: _full,
);
});
// ------------------------------------------------- chat line (stand-in)
static const _chatLineHeight = 44.0;
void _openChatLine() {
_chatLine.value = true;
WidgetsBinding.instance.addPostFrameCallback((_) => _chatLineFocus.requestFocus());
}
void _closeChatLine() {
_chatLine.value = false;
_chatLineText.clear();
_chatLineFocus.unfocus();
}
void _sendChatLine(String text) {
final t = text.trim();
if (t.isNotEmpty) {
_coord.sendChat(t);
// The preview, when it is up, shows it as your own line.
final d = widget.danmaku;
if (d != null && d.active.peek()) d.controller.add(d.own(t));
}
_closeChatLine();
}
/// A one-line chat box at the bottom of the full player (Enter opens
/// it, Enter sends, Esc closes): the planned chat line, to try the
/// subtitle lift with.
Widget _chatLineOverlay() => Positioned(
left: _edge,
right: _edge,
bottom: _edge,
height: _chatLineHeight,
child: ValueListenableBuilder<bool>(
valueListenable: _chatLine,
builder: (context, open, child) => IgnorePointer(
ignoring: !open,
child: AnimatedSlide(
offset: open ? Offset.zero : const Offset(0, 0.4),
duration: easeTime,
curve: ease,
child: AnimatedOpacity(opacity: open ? 1 : 0, duration: easeTime, curve: ease, child: child),
),
),
child: CallbackShortcuts(
bindings: {const SingleActivator(LogicalKeyboardKey.escape): _closeChatLine},
child: AppGlass.pill(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 18),
child: Row(
children: [
Icon(Icons.chat_bubble_outline, size: 18, color: Colors.white.withValues(alpha: 0.7)),
const SizedBox(width: 12),
Expanded(
child: TextField(
key: const ValueKey('chat-line'),
controller: _chatLineText,
focusNode: _chatLineFocus,
onSubmitted: _sendChatLine,
onTapOutside: (_) => _closeChatLine(),
style: const TextStyle(color: Colors.white, fontSize: 15),
cursorColor: Colors.white,
decoration: InputDecoration(
isDense: true,
border: InputBorder.none,
hintText: 'Say something · Enter sends, Esc closes',
hintStyle: TextStyle(color: Colors.white.withValues(alpha: 0.45), fontSize: 15),
),
),
),
],
),
),
),
),
),
); );
/// Fullscreen hides the strip and its window buttons: the top bar /// Fullscreen hides the strip and its window buttons: the top bar
@@ -355,13 +481,26 @@ class _SessionPlayerState extends State<SessionPlayer> {
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
const StartBanner(), const StartBanner(),
SessionControls( // Under the controls, over the picture.
if (widget.danmaku case final d?)
SignalBuilder(
builder: (context) => d.active.value ? DanmakuPreviewLayers(preview: d) : const SizedBox.shrink(),
),
// The chat line takes the control bar's place while it is open.
ValueListenableBuilder<bool>(
valueListenable: _chatLine,
builder: (context, chatLine, child) => IgnorePointer(
ignoring: chatLine,
child: AnimatedOpacity(opacity: chatLine ? 0 : 1, duration: easeTime, curve: ease, child: child),
),
child: SessionControls(
model: _model, model: _model,
shown: _controlsShown, shown: _controlsShown,
hold: _hold, hold: _hold,
onDoubleClick: widget.onToggleFullscreen, onDoubleClick: widget.onToggleFullscreen,
windowButtons: _fullscreenWindowButtons(), windowButtons: _fullscreenWindowButtons(),
), ),
),
], ],
); );
} }
@@ -378,6 +517,26 @@ class _SessionPlayerState extends State<SessionPlayer> {
static double get _topBarHeight => controlsTopHeight(AppSettings.instance.controlsSkin.peek()); static double get _topBarHeight => controlsTopHeight(AppSettings.instance.controlsSkin.peek());
/// The danmaku preview's panel, top right under the top bar; the mouse
/// on it keeps the controls up.
Widget _danmakuPanel(DanmakuPreview d) => Positioned(
right: _edge,
top: _topBarHeight + 8,
child: SignalBuilder(
builder: (context) => fadeThrough(
expand: false,
alignment: Alignment.topRight,
d.active.value
? ControlsHoldRegion(
key: const ValueKey('danmaku-panel'),
hold: _hold,
child: DanmakuSessionPanel(preview: d, coord: _coord),
)
: const SizedBox.shrink(key: ValueKey('danmaku-none')),
),
),
);
/// The volume flash after a wheel or key step: a pill under the top bar. /// The volume flash after a wheel or key step: a pill under the top bar.
Widget _volumeReadout() => Positioned( Widget _volumeReadout() => Positioned(
left: 0, left: 0,
+8 -2
View File
@@ -13,7 +13,7 @@ import '../jellyfin/accounts_panel.dart';
import '../schedule/animeflow.dart'; import '../schedule/animeflow.dart';
import '../screens/home/about_page.dart'; import '../screens/home/about_page.dart';
import '../screens/home/settings_page.dart'; import '../screens/home/settings_page.dart';
import '../screens/home/ui.dart' show appName, displayVersion; import '../screens/home/ui.dart' show appName, devTools, displayVersion;
import '../session/auto_sit_out.dart' show AutoSitOutTimes; import '../session/auto_sit_out.dart' show AutoSitOutTimes;
import '../sfx/sfx.dart'; import '../sfx/sfx.dart';
import '../sfx/sound_bank.dart'; import '../sfx/sound_bank.dart';
@@ -52,6 +52,8 @@ class SettingsTab extends StatelessWidget {
final JellyfinAccounts? jellyfin; final JellyfinAccounts? jellyfin;
/// AnimeFlow's `/me` for the integrations page (tests pass a fake). /// AnimeFlow's `/me` for the integrations page (tests pass a fake).
final Future<AnimeFlowMe> Function()? animeFlowMe; final Future<AnimeFlowMe> Function()? animeFlowMe;
/// Dev builds: About → Developer → Danmaku preview.
final VoidCallback? onDanmakuPreview;
const SettingsTab({ const SettingsTab({
super.key, super.key,
required this.section, required this.section,
@@ -60,6 +62,7 @@ class SettingsTab extends StatelessWidget {
this.updates, this.updates,
this.jellyfin, this.jellyfin,
this.animeFlowMe, this.animeFlowMe,
this.onDanmakuPreview,
}); });
List<Widget> _page(BuildContext context) { List<Widget> _page(BuildContext context) {
@@ -162,7 +165,10 @@ class SettingsTab extends StatelessWidget {
case SettingsSection.shortcuts: case SettingsSection.shortcuts:
return const [SettingsPanel(title: 'Keyboard shortcuts', child: ShortcutList())]; return const [SettingsPanel(title: 'Keyboard shortcuts', child: ShortcutList())];
case SettingsSection.about: case SettingsSection.about:
return [AboutPanel(updates: updates)]; return [
AboutPanel(updates: updates),
if (devTools) DeveloperPanel(onDanmakuPreview: onDanmakuPreview),
];
} }
} }
+235
View File
@@ -0,0 +1,235 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/widgets.dart';
import 'package:signals/signals_flutter.dart';
import '../playback/playback_controller.dart';
import 'motion.dart';
// What the session's video view tells mpv about where it is shown: the
// size its output renders at ("Sharp video and subtitles") and, as a dev
// experiment, how far bottom subtitles move up while the controls are up.
/// The video output's size for a view of [logical] size at [dpr]: its
/// physical pixels when [on] and video renders on the GPU, else null (the
/// video's own size). Software rendering keeps the video's size: every
/// pixel there is CPU work.
(int, int)? videoOutputSize(Size logical, double dpr, {required bool on, required RenderMode mode}) {
if (!on || mode != RenderMode.gpu) return null;
final w = (logical.width * dpr).round(), h = (logical.height * dpr).round();
if (w < 16 || h < 16) return null;
return (w, h);
}
/// How long a view size must hold before the output follows it (a
/// window drag resizes many times a second; each resize reallocates the
/// output's texture).
const outputResizeDelay = Duration(milliseconds: 180);
/// Sends the size of the view around [child] to the player host (see
/// [videoOutputSize]): at once the first time, then once a resize has
/// settled for [outputResizeDelay]. Follows the setting and the render
/// mode live.
class VideoOutputSizer extends StatefulWidget {
final PlaybackController playback;
/// The setting ([AppSettings.screenResVideo]).
final ReadonlySignal<bool> sharp;
final Widget child;
const VideoOutputSizer({super.key, required this.playback, required this.sharp, required this.child});
@override
State<VideoOutputSizer> createState() => _VideoOutputSizerState();
}
class _VideoOutputSizerState extends State<VideoOutputSizer> {
Size? _view;
double _dpr = 1;
Timer? _debounce;
late final void Function() _stop;
var _first = true;
@override
void initState() {
super.initState();
_stop = effect(() {
widget.sharp.value;
widget.playback.renderMode.value;
untracked(_send);
});
}
@override
void dispose() {
_debounce?.cancel();
_stop();
super.dispose();
}
void _send() {
final view = _view;
if (view == null) return;
final size = videoOutputSize(view, _dpr,
on: widget.sharp.peek(), mode: widget.playback.renderMode.peek());
widget.playback.host.setOutputSize(size?.$1, size?.$2);
}
void _resized(Size view, double dpr) {
if (view == _view && dpr == _dpr) return;
_view = view;
_dpr = dpr;
_debounce?.cancel();
if (_first) {
_first = false;
_send();
return;
}
_debounce = Timer(outputResizeDelay, _send);
}
@override
Widget build(BuildContext context) {
final dpr = MediaQuery.devicePixelRatioOf(context);
return LayoutBuilder(builder: (context, box) {
_resized(box.biggest, dpr);
return widget.child;
});
}
}
/// mpv `sub-pos` (100 = where the file puts them) that lifts bottom
/// subtitles clear of what covers the bottom of the view ([controls]
/// logical px tall: the control bar or the chat line, plus [gap]) in a [view] showing a [videoW] x [videoH] video fitted
/// inside it: only as far as the letterbox under the picture does not
/// already make room. `sub-pos` moves bottom-aligned lines by a share of
/// the picture's height; top-aligned ASS signs stay put.
double subtitleLiftPos({required Size view, int? videoW, int? videoH, required double controls, double gap = 8}) {
if (view.height <= 0 || view.width <= 0) return 100;
var picH = view.height;
if (videoW != null && videoH != null && videoW > 0 && videoH > 0) {
picH = math.min(view.height, view.width * videoH / videoW);
}
final bar = (view.height - picH) / 2;
final shift = math.max(0.0, controls + gap - bar);
return (100 - shift / picH * 100).clamp(50.0, 100.0);
}
/// Dev experiment ([AppSettings.liftSubtitles]): while something covers
/// the bottom of the full player ([cover]: its height, 0 for nothing; the
/// chat line, else the control bar while it is up), bottom subtitles of the
/// current player move above it, and back down when it goes, animated over
/// the shell's [easeTime] in mpv `sub-pos` steps. Top-aligned signs stay
/// put. Draws nothing.
class SubtitleLifter extends StatefulWidget {
final PlaybackController playback;
/// Fires when [cover] may have changed.
final Listenable changes;
final double Function() cover;
/// The dev setting ([AppSettings.liftSubtitles]).
final ReadonlySignal<bool> lift;
/// Only the full player lifts.
final bool active;
const SubtitleLifter({
super.key,
required this.playback,
required this.changes,
required this.cover,
required this.lift,
required this.active,
});
@override
State<SubtitleLifter> createState() => _SubtitleLifterState();
}
class _SubtitleLifterState extends State<SubtitleLifter> with SingleTickerProviderStateMixin {
late final AnimationController _anim = AnimationController(vsync: this, duration: easeTime)
..addListener(_tick);
double _from = 100, _to = 100;
double _sent = 100;
HostedPlayer? _player;
Size _view = Size.zero;
late final void Function() _stop;
@override
void initState() {
super.initState();
widget.changes.addListener(_retarget);
_stop = effect(() {
widget.lift.value;
widget.playback.generation.value;
untracked(_retarget);
});
}
@override
void didUpdateWidget(SubtitleLifter old) {
super.didUpdateWidget(old);
if (old.changes != widget.changes) {
old.changes.removeListener(_retarget);
widget.changes.addListener(_retarget);
}
_retarget();
}
@override
void dispose() {
widget.changes.removeListener(_retarget);
_stop();
_anim.dispose();
// Leave the player as the file wants it.
if (_sent != 100) _send(100);
super.dispose();
}
double get _target {
final cover = widget.cover();
if (!widget.active || !widget.lift.peek() || cover <= 0) return 100;
final st = widget.playback.current?.state;
return subtitleLiftPos(view: _view, videoW: st?.width, videoH: st?.height, controls: cover);
}
void _retarget() {
if (!mounted) return;
final p = widget.playback.current;
if (!identical(p, _player)) {
// A new player starts where the file wants its subtitles.
_player = p;
_sent = 100;
_from = _to = 100;
_anim.value = 1;
}
final t = _target;
if ((t - _to).abs() < 0.1) return;
_from = _sent;
_to = t;
_anim.forward(from: 0);
}
void _tick() {
final v = _from + (_to - _from) * ease.transform(_anim.value);
// mpv redraws on every change: whole tenths are enough.
final r = (v * 10).round() / 10;
if (r != _sent) _send(r);
}
void _send(double pos) {
_sent = pos;
final p = widget.playback.current;
if (p == null || p.disposed) return;
unawaited(p.setProperty('sub-pos', pos.toStringAsFixed(1)).catchError((Object _) {}));
}
@override
Widget build(BuildContext context) => LayoutBuilder(builder: (context, box) {
if (box.biggest != _view) {
_view = box.biggest;
WidgetsBinding.instance.addPostFrameCallback((_) => _retarget());
}
return const SizedBox.shrink();
});
}
+24
View File
@@ -46,6 +46,8 @@ const _keyNotifyPeople = 'notifyPeople';
const _keyShareDiagnostics = 'shareDiagnostics'; const _keyShareDiagnostics = 'shareDiagnostics';
const _keyWhatsNewSeen = 'whatsNewSeen'; const _keyWhatsNewSeen = 'whatsNewSeen';
const _keyWhatsNewNotes = 'whatsNewNotes'; const _keyWhatsNewNotes = 'whatsNewNotes';
const _keyScreenResVideo = 'screenResVideo';
const _keyLiftSubtitles = 'liftSubtitles';
enum HwdecMode { auto, off, custom } enum HwdecMode { auto, off, custom }
@@ -89,6 +91,14 @@ class AppSettings {
/// Send this app's log lines to the server's session log while in a /// Send this app's log lines to the server's session log while in a
/// session (never chat). Off: nothing is sent but a note that it is off. /// session (never chat). Off: nothing is sent but a note that it is off.
final shareDiagnostics = signal(true); final shareDiagnostics = signal(true);
/// The video output renders at the player's size on screen, so mpv
/// scales the picture (and runs shaders) and draws subtitles at screen
/// resolution; off: at the video's own size, scaled by Flutter. Applies
/// live (GPU rendering only).
final screenResVideo = signal(true);
/// Dev builds only, an experiment: bottom subtitles move up while the
/// controls are up (mpv `sub-pos`). Applies live.
final liftSubtitles = signal(false);
/// Chat message toasts (default: all). Applies live. /// Chat message toasts (default: all). Applies live.
final chatPopups = signal(ChatPopups.all); final chatPopups = signal(ChatPopups.all);
/// Joins and leaves also notify while sitting out or with the window in /// Joins and leaves also notify while sitting out or with the window in
@@ -175,6 +185,10 @@ class AppSettings {
if (hideRe is bool) hideReReleases.value = hideRe; if (hideRe is bool) hideReReleases.value = hideRe;
final line = box.get(_keyProgressLine); final line = box.get(_keyProgressLine);
if (line is bool) progressLine.value = line; if (line is bool) progressLine.value = line;
final screenRes = box.get(_keyScreenResVideo);
if (screenRes is bool) screenResVideo.value = screenRes;
final lift = box.get(_keyLiftSubtitles);
if (lift is bool) liftSubtitles.value = lift;
final share = box.get(_keyShareDiagnostics); final share = box.get(_keyShareDiagnostics);
if (share is bool) shareDiagnostics.value = share; if (share is bool) shareDiagnostics.value = share;
final popups = box.get(_keyChatPopups); final popups = box.get(_keyChatPopups);
@@ -279,6 +293,16 @@ class AppSettings {
_put(_keyProgressLine, on); _put(_keyProgressLine, on);
} }
void setScreenResVideo(bool on) {
screenResVideo.value = on;
_put(_keyScreenResVideo, on);
}
void setLiftSubtitles(bool on) {
liftSubtitles.value = on;
_put(_keyLiftSubtitles, on);
}
void setShareDiagnostics(bool on) { void setShareDiagnostics(bool on) {
shareDiagnostics.value = on; shareDiagnostics.value = on;
_put(_keyShareDiagnostics, on); _put(_keyShareDiagnostics, on);
+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);
});
}
+28 -2
View File
@@ -567,6 +567,30 @@ server without the relay's header rule, or the connection reuse above)
fails every seek until a fresh open, so the coordinator runs "Reload fails every seek until a fresh open, so the coordinator runs "Reload
video" once per media for it (`mpvStreamLost`, `onStreamLost`). video" once per media for it (`mpvStreamLost`, `onStreamLost`).
Output size ("Sharp video and subtitles", `AppSettings.screenResVideo`,
on by default, live; Settings → Player and the player settings card):
media_kit renders mpv into a texture the size of the video and Flutter
scales it, so libass subtitles, mpv's scaler and the shaders all work on
a 720p image for a 720p file. With the setting on and the GPU renderer
(`renderMode` gpu; never in software rendering, where every pixel is CPU
work, nor before the probe knows), the session view
(`shell/video_view.dart`, `VideoOutputSizer` around the `Video`) sends its
size in physical pixels to the host (`PlayerHost.setOutputSize`): at
once the first time, then once a resize has held 180 ms
(`outputResizeDelay`: a window drag, fullscreen, full ↔ mini). The host
applies it to every live output and to each new one once it is up
(`NativeVideoController.setSize`); mpv then letterboxes inside it and
draws subtitles at that size (text subtitles may sit in the black bars,
as in mpv). media_kit's controller sets an output back to the video's
size whenever the video's parameters change (each new file), so the host
sends the size again after it, under the controller's own lock; a fresh
player's first frame or two show at the video's size. A paused player
draws no frame for the new size: the host nudges `video-pan-x` by a
millionth and back so mpv redraws. Off: each output at its video's own
size again (`setOutputSize(null)`). Measured on a GTX 1080, 1280x720
H.264 at 2560x1080 fullscreen: no difference past the noise in the
process's GPU (`nvidia-smi pmon`, 9 to 12 % either way) or CPU.
Render mode: media_kit's Linux/Windows video output creates mpv's render Render mode: media_kit's Linux/Windows video output creates mpv's render
context (OpenGL, else its software renderer) when the `VideoController` is context (OpenGL, else its software renderer) when the `VideoController` is
created, and nothing in its API says which. The host therefore requests created, and nothing in its API says which. The host therefore requests
@@ -606,7 +630,8 @@ their own): creates players by role (`session`: the full cache, `warn`;
no outputs, no cache, no log) with every pin set before the caller's no outputs, no cache, no log) with every pin set before the caller's
first command (`pinMismatches` lists the read-backs that differ), gives first command (`pinMismatches` lists the read-backs that differ), gives
a player its video output (`HostedVideo`, with the hwdec policy: a player its video output (`HostedVideo`, with the hwdec policy:
`SYNC_RELAY_HWDEC`, else the setting, `auto-copy` on Windows), runs the `SYNC_RELAY_HWDEC`, else the setting, `auto-copy` on Windows) at the
view's size when asked (`setOutputSize`, see "Output size"), runs the
render-mode probe and its diagnostics (`renderMode`, also after leaving), render-mode probe and its diagnostics (`renderMode`, also after leaving),
routes mpv's log, and owns the order players go in: `retire` (pause, let routes mpv's log, and owns the order players go in: `retire` (pause, let
the view drop the texture: two frames and a route pop, then dispose; a the view drop the texture: two frames and a route pop, then dispose; a
@@ -2112,7 +2137,8 @@ panel) and chat messages (Copy message, and a link in it).
between kinds. The mini shows its short line in its hover bar. ⚙ → between kinds. The mini shows its short line in its hover bar. ⚙ →
Player settings (both skins) opens the player settings card (`controls/ Player settings (both skins) opens the player settings card (`controls/
player_settings.dart`, `showPopover`, on `AppGlass`): player design, progress line player_settings.dart`, `showPopover`, on `AppGlass`): player design, progress line
when hidden, upscaling shader, hardware decoding (set live on mpv's when hidden, upscaling shader, sharp video and subtitles (the output
at screen size, see "Media"), hardware decoding (set live on mpv's
`hwdec`), subtitle and audio track. The rows (`playerSettingRows`) are `hwdec`), subtitle and audio track. The rows (`playerSettingRows`) are
the same widgets Settings → Player shows; all apply live. Settings → the same widgets Settings → Player shows; all apply live. Settings →
Player also has the controls' own choices (`controlsSettingRows`, see Player also has the controls' own choices (`controlsSettingRows`, see
+58 -1
View File
@@ -75,6 +75,61 @@ Harnesses in `tool/` (own `pubspec.yaml`, `dart run tool/<name>.dart`):
`range_logger` (serve a file and log every Range request), `shaper` `range_logger` (serve a file and log every Range request), `shaper`
(bandwidth cap, latency, `--kill-after` for outage tests), `fake_viewer` (bandwidth cap, latency, `--kill-after` for outage tests), `fake_viewer`
and `e2e_check` (simulated viewers against a live server). and `e2e_check` (simulated viewers against a live server).
`tool/make_sub_test.sh [out.mkv] [seconds]` (ffmpeg) writes a 720p test
video for subtitle placement and sharpness (default
`~/Videos/starsync-sub-test.mkv`): an .ass track with a top sign
(`\an8`) and bottom dialogue (English and Japanese), and an SRT track.
## Dev tools
Test builds and runs from source (a version with `-dev`) and debug runs
(`devTools`, `screens/home/ui.dart`) get Settings → About → Developer;
releases never show it. What is there is an experiment to look at, not
a decided feature:
- **Danmaku preview** (`lib/src/danmaku/`): chat lines and short notices
(joins, leaves, Up next adds, "starting now") flying right to left
across the upper part of the picture. `DanmakuLayer` is the reusable
widget: lanes in a band of the height (`DanmakuStyle.bandTop` /
`bandBottom`, default 10 to 45 %, under top signs, never down into the
dialogue); each line takes the topmost free lane (`DanmakuLanes`, pure:
the last line in a lane must have fully entered with a gap, and the
new one, at its own speed, cannot catch it before it leaves); at most
`maxOnScreen` fly, the rest wait in order (dropped after 15 s, 60 at
most); every line crosses in `crossSecs`, so long ones move faster;
white text with a dark outline and no box, the sender's avatar
(`StatusAvatar`: picture, else initial) and coloured name first, the
app's own notices without one, own lines in an accent frame; the
layer's own clock, so lines fly on while paused. Each line is a widget
moved by a `Flow`: per frame only paint runs. The button opens it over
the full player when one is up (under the controls; the panel top
right holds them up), else on a page with a moving stand-in picture
and a mock control bar of the skin in use. The panel (`DanmakuPanel`)
fires samples (chat, own, join, leave, Up next, starting, a burst), a
random stream (Poisson arrivals at a rate, short to long lines, CJK
and emoji; in a session half the senders are the party's real
members), this session's own chat and notice toasts, and every
`DanmakuStyle` knob live (crossing time, cap, lane band, opacity,
size, outline, avatars, own frame), plus mock subtitles (a top sign
and bottom dialogue). Tests: `danmaku_test`.
- **Lift subtitles above the chat line and controls**
(`AppSettings.liftSubtitles`, off): Enter in the full player opens a
stand-in for the planned chat line (a one-line box at the bottom in
place of the control bar; Enter sends to the chat, Esc closes). While
it is open, or the control bar is up, bottom subtitles move above it
and back down when it goes (`SubtitleLifter`, `video_view.dart`): mpv
`sub-pos`, animated over the shell's 320 ms curve in 0.1 steps, only as
far as the letterbox under the picture leaves no room
(`subtitleLiftPos`). What each mpv property does, on
`make_sub_test.sh`'s file (mpv 0.41, libmpv as the app uses it):
`sub-pos` moves bottom ASS dialogue and SRT lines up and leaves `\an8`
signs in place (with `sub-ass-override` at its default `scale` or
`yes`; `no` ignores it for ASS); `sub-margin-y` moves SRT only (ASS
only with `sub-ass-override=force`, which restyles every line, signs
too); `sub-ass-style-overrides=MarginV=...` moves top signs down as
well; `video-margin-ratio-bottom` shrinks and moves the picture
itself (with `sub-ass-force-margins` the dialogue goes into the new
black band).
## Tests ## Tests
@@ -109,7 +164,9 @@ Three suites, run from each package's folder:
version gate, the media plan's table with the Jellyfin relay rule and version gate, the media plan's table with the Jellyfin relay rule and
the "Waiting for" status (`media_plan_test`), watch tracking and the the "Waiting for" status (`media_plan_test`), watch tracking and the
device history (`watch_tracker_test`), what continues a file of a device history (`watch_tracker_test`), what continues a file of a
local show (`queue_logic_test`)), `singletons_test` (the `.instance` count, see local show (`queue_logic_test`), the video view's output size and
subtitle lift and the dev-tools gate (`video_view_test`), danmaku lanes
(`danmaku_test`)), `singletons_test` (the `.instance` count, see
ARCHITECTURE "Composition root"; lower its baseline when a use goes) and the real-mpv `*_e2e` ARCHITECTURE "Composition root"; lower its baseline when a use goes) and the real-mpv `*_e2e`
tests plus `coord_presence`. The whole suite takes a couple of minutes; tests plus `coord_presence`. The whole suite takes a couple of minutes;
run it only before a restructure step lands, otherwise only the files run it only before a restructure step lands, otherwise only the files
+78
View File
@@ -0,0 +1,78 @@
#!/usr/bin/env bash
# A 720p test video for subtitle placement and sharpness: an .ass track
# (default) with a top-positioned sign (\an8) and bottom dialogue, and an
# SRT track with plain bottom lines. Needs ffmpeg.
#
# tool/make_sub_test.sh [out.mkv] [seconds]
#
# Default output: ~/Videos/starsync-sub-test.mkv, 60 s.
set -euo pipefail
out="${1:-$HOME/Videos/starsync-sub-test.mkv}"
secs="${2:-60}"
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
clock() { # seconds -> H:MM:SS.cc (ass) or HH:MM:SS,mmm (srt)
local s=$1 fmt=$2
local h=$((s / 3600)) m=$((s % 3600 / 60)) r=$((s % 60))
if [[ $fmt == ass ]]; then printf '%d:%02d:%02d.00' $h $m $r; else printf '%02d:%02d:%02d,000' $h $m $r; fi
}
lines=(
"Bottom dialogue: the controls should never cover this line."
"A second line of dialogue,\\Nsplit over two rows."
"日本語の字幕もここに表示されます。"
"Short one."
"The sign at the top must stay where it is."
)
{
cat <<'EOF'
[Script Info]
ScriptType: v4.00+
PlayResX: 1280
PlayResY: 720
WrapStyle: 0
ScaledBorderAndShadow: yes
[V4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
Style: Default,Noto Sans,48,&H00FFFFFF,&H000000FF,&H00000000,&H80000000,0,0,0,0,100,100,0,0,1,2.5,1,2,40,40,36,1
Style: Sign,Noto Serif,40,&H0000E5FF,&H000000FF,&H00301000,&H00000000,1,0,0,0,100,100,1,0,1,3,0,8,40,40,30,1
[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
EOF
echo "Dialogue: 0,$(clock 0 ass),$(clock "$secs" ass),Sign,,0,0,0,,{\\an8}TOP SIGN (\\\\an8): stays put"
i=0
for ((t = 0; t < secs; t += 4)); do
echo "Dialogue: 0,$(clock $t ass),$(clock $((t + 4)) ass),Default,,0,0,0,,${lines[$((i % ${#lines[@]}))]}"
i=$((i + 1))
done
} >"$work/subs.ass"
{
n=1
for ((t = 0; t < secs; t += 4)); do
echo $n
echo "$(clock $t srt) --> $(clock $((t + 4)) srt)"
echo "Plain SRT line $n at the bottom"
echo
n=$((n + 1))
done
} >"$work/subs.srt"
mkdir -p "$(dirname "$out")"
ffmpeg -hide_banner -loglevel error -y \
-f lavfi -i "testsrc2=size=1280x720:rate=24:duration=$secs" \
-f lavfi -i "sine=frequency=440:duration=$secs" \
-i "$work/subs.ass" -i "$work/subs.srt" \
-map 0:v -map 1:a -map 2 -map 3 \
-c:v libx264 -preset veryfast -crf 23 -pix_fmt yuv420p -c:a aac -b:a 96k \
-c:s:0 ass -c:s:1 srt \
-metadata:s:s:0 title="ASS: top sign + bottom dialogue" -metadata:s:s:0 language=eng \
-metadata:s:s:1 title="SRT: bottom lines" -metadata:s:s:1 language=eng \
-disposition:s:0 default -disposition:s:1 0 \
"$out"
echo "$out"