forked from Foxyz/sync_replay_project
Player: sharp video and subtitles at screen size; dev tools: danmaku preview, subtitle lift with a stand-in chat line
- "Sharp video and subtitles" (Settings → Player and the player settings card, on by default): the video output renders at the view's physical size (debounced, GPU rendering only), so libass, mpv's scaler and the shaders work at screen resolution; re-sent after media_kit's own reset on new video params, redrawn while paused. - Dev builds only (Settings → About → Developer): a danmaku preview (DanmakuLayer: free lanes in an upper band, density cap with a queue, equal crossing time, outlined text, avatars, own-line frame) over the real player or a stand-in picture, with samples, a random stream, live toasts, every knob and mock subtitles; and "Lift subtitles above the chat line and controls": Enter opens a stand-in chat line, bottom subtitles move up via mpv sub-pos while it or the control bar is up. - tool/make_sub_test.sh: a test video with a top \an8 sign, bottom dialogue and an SRT track. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
638a30e16c
commit
5bc252bf02
18 files changed
+2253
-16
No files matched your search
@@ -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
|
||||
/// running player, if any; the player announces the result.
|
||||
void applyShaderChoice(SessionCoordinator coord, ShaderPreset preset, ShaderQuality? quality) {
|
||||
@@ -403,6 +432,7 @@ List<Widget> playerSettingRows({ReadonlySignal<RenderMode>? renderMode}) => [
|
||||
const PlayerDesignSetting(),
|
||||
const ProgressLineSetting(),
|
||||
ShaderPresetSetting(renderMode: renderMode),
|
||||
ScreenResSetting(renderMode: renderMode),
|
||||
const HwdecSetting(),
|
||||
];
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
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 _http = StreamController<String>.broadcast();
|
||||
final _throttle = LogThrottle(burst: 5);
|
||||
@@ -90,6 +93,16 @@ class MediaKitHost implements PlayerHost {
|
||||
@override
|
||||
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
|
||||
Stream<String> get httpErrors => _http.stream;
|
||||
|
||||
@@ -185,6 +198,9 @@ class MediaKitHost implements PlayerHost {
|
||||
// configured), which used to override SYNC_RELAY_HWDEC.
|
||||
final controller = VideoController(p.mk, configuration: VideoControllerConfiguration(hwdec: hwdec));
|
||||
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
|
||||
// media opened in between fails its video output ("No render context
|
||||
// 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;
|
||||
if (!_live.remove(p)) return;
|
||||
p._retired = true;
|
||||
if (p._video case final v?) {
|
||||
_videos.remove(v);
|
||||
await v._paramsSub?.cancel();
|
||||
}
|
||||
await p._logSub?.cancel();
|
||||
try {
|
||||
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.
|
||||
({String url, Map<String, String> headers, bool play})? _lastOpen;
|
||||
bool _voRetried = false;
|
||||
_MkVideo? _video;
|
||||
|
||||
dynamic get _mpv => mk.platform;
|
||||
|
||||
@@ -529,4 +550,53 @@ class _MkVideo implements HostedVideo {
|
||||
|
||||
@override
|
||||
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).
|
||||
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).
|
||||
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 '
|
||||
'(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
|
||||
/// link and the logs (Settings → About).
|
||||
class AboutPanel extends StatelessWidget {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:flutter/foundation.dart' show kDebugMode;
|
||||
import 'package:flutter/material.dart';
|
||||
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".
|
||||
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 kGap = 16.0;
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:signals/signals_flutter.dart';
|
||||
|
||||
import '../controls/frame.dart' show DoubleClickListener;
|
||||
import '../controls/session_controls.dart' show controlsBottomHeight;
|
||||
import '../danmaku/danmaku_preview.dart';
|
||||
import '../files/folder_store.dart';
|
||||
import '../files/ui/files_page.dart' show showFilesFolder;
|
||||
import '../jellyfin/accounts.dart';
|
||||
@@ -248,6 +249,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
for (final stop in _stops) {
|
||||
stop();
|
||||
}
|
||||
_danmaku?.dispose();
|
||||
HardwareKeyboard.instance.removeHandler(_onKey);
|
||||
_whatsNewSource?.due.removeListener(_maybeWhatsNew);
|
||||
_updateTimer?.cancel();
|
||||
@@ -358,6 +360,22 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
|
||||
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() {
|
||||
if (_fullscreen) _toggleFullscreen();
|
||||
_show(PlayerMode.mini);
|
||||
@@ -765,6 +783,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
onEditProfile: () => showProfileCard(context, onOpenSettings: _openSettings, updates: widget.updates),
|
||||
updates: widget.updates,
|
||||
jellyfin: _jellyfin,
|
||||
onDanmakuPreview: _openDanmakuPreview,
|
||||
),
|
||||
};
|
||||
final full = _shown == PlayerMode.full;
|
||||
@@ -908,6 +927,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
videoBuilder: widget.videoBuilder,
|
||||
windowControls: widget.windowControls,
|
||||
controlsShown: _controlsShown,
|
||||
danmaku: _danmaku,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -13,9 +13,11 @@ import '../controls/model/controls_layout.dart';
|
||||
import '../controls/model/controls_model.dart';
|
||||
import '../controls/session_controls.dart';
|
||||
import '../controls/volume.dart';
|
||||
import '../danmaku/danmaku_preview.dart';
|
||||
import '../platform/desktop/media_kit_host.dart' show videoControllerOf;
|
||||
import '../queue/queue_ui.dart';
|
||||
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/player_controls.dart';
|
||||
import '../session/app_events.dart' show ShaderMessage;
|
||||
@@ -24,6 +26,7 @@ import '../shaders/shader_presets.dart';
|
||||
import '../app/app_graph.dart';
|
||||
import '../state/settings.dart';
|
||||
import '../widgets/app_keys.dart';
|
||||
import '../widgets/app_menu.dart' show AppGlass;
|
||||
import '../widgets/text_focus.dart';
|
||||
import '../widgets/window_buttons.dart';
|
||||
import 'avatar.dart';
|
||||
@@ -32,6 +35,7 @@ import 'now_playing_title.dart';
|
||||
import 'player_status.dart';
|
||||
import 'sit_out_card.dart';
|
||||
import 'start_banner.dart';
|
||||
import 'video_view.dart';
|
||||
|
||||
/// Where the session's video is shown: nowhere, the floating mini player,
|
||||
/// 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
|
||||
/// its toasts above them); the player keeps its own when null.
|
||||
final ValueNotifier<bool>? controlsShown;
|
||||
/// Dev builds: the danmaku preview, shown over the full player while
|
||||
/// it is active.
|
||||
final DanmakuPreview? danmaku;
|
||||
const SessionPlayer({
|
||||
super.key,
|
||||
required this.mode,
|
||||
@@ -86,6 +93,7 @@ class SessionPlayer extends StatefulWidget {
|
||||
this.videoBuilder,
|
||||
this.windowControls = true,
|
||||
this.controlsShown,
|
||||
this.danmaku,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -104,6 +112,11 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
bool _miniHover = false;
|
||||
// Whether the controls are up (their frame reports it).
|
||||
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;
|
||||
// The mouse on a floating element, or the Up next popover open, keeps
|
||||
// the controls up.
|
||||
@@ -166,6 +179,9 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
_stopPopoverWatch();
|
||||
_noFocus.dispose();
|
||||
_ownShown.dispose();
|
||||
_chatLine.dispose();
|
||||
_chatLineText.dispose();
|
||||
_chatLineFocus.dispose();
|
||||
_hold.dispose();
|
||||
_model.dispose();
|
||||
super.dispose();
|
||||
@@ -197,6 +213,12 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
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();
|
||||
if (_lastKeyAt != null && now.difference(_lastKeyAt!).inMilliseconds < 350) {
|
||||
return false;
|
||||
@@ -306,11 +328,15 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
video,
|
||||
// Dev experiment: bottom subtitles above the controls.
|
||||
if (devTools) _subtitleLifter(),
|
||||
if (_full) ...[
|
||||
_blockedCard(),
|
||||
_actionArea(),
|
||||
if (widget.fullscreen) ...[if (!_coord.alone.value) _pausedParty(), _unreadChip()],
|
||||
_volumeReadout(),
|
||||
if (devTools) _chatLineOverlay(),
|
||||
if (widget.danmaku case final d?) _danmakuPanel(d),
|
||||
] else
|
||||
_miniOverlay(),
|
||||
// Sitting out: nothing is loaded; the card covers the
|
||||
@@ -324,11 +350,111 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _video(VideoControlsBuilder? controls) => Video(
|
||||
key: _videoKey,
|
||||
controller: videoControllerOf(_coord.playback.video),
|
||||
subtitleViewConfiguration: const SubtitleViewConfiguration(visible: false),
|
||||
controls: controls,
|
||||
Widget _video(VideoControlsBuilder? controls) => VideoOutputSizer(
|
||||
playback: _coord.playback,
|
||||
sharp: _coord.settings.screenResVideo,
|
||||
child: Video(
|
||||
key: _videoKey,
|
||||
controller: videoControllerOf(_coord.playback.video),
|
||||
subtitleViewConfiguration: const SubtitleViewConfiguration(visible: false),
|
||||
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
|
||||
@@ -355,12 +481,25 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
const StartBanner(),
|
||||
SessionControls(
|
||||
model: _model,
|
||||
shown: _controlsShown,
|
||||
hold: _hold,
|
||||
onDoubleClick: widget.onToggleFullscreen,
|
||||
windowButtons: _fullscreenWindowButtons(),
|
||||
// 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,
|
||||
shown: _controlsShown,
|
||||
hold: _hold,
|
||||
onDoubleClick: widget.onToggleFullscreen,
|
||||
windowButtons: _fullscreenWindowButtons(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -378,6 +517,26 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
|
||||
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.
|
||||
Widget _volumeReadout() => Positioned(
|
||||
left: 0,
|
||||
|
||||
@@ -13,7 +13,7 @@ import '../jellyfin/accounts_panel.dart';
|
||||
import '../schedule/animeflow.dart';
|
||||
import '../screens/home/about_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 '../sfx/sfx.dart';
|
||||
import '../sfx/sound_bank.dart';
|
||||
@@ -52,6 +52,8 @@ class SettingsTab extends StatelessWidget {
|
||||
final JellyfinAccounts? jellyfin;
|
||||
/// AnimeFlow's `/me` for the integrations page (tests pass a fake).
|
||||
final Future<AnimeFlowMe> Function()? animeFlowMe;
|
||||
/// Dev builds: About → Developer → Danmaku preview.
|
||||
final VoidCallback? onDanmakuPreview;
|
||||
const SettingsTab({
|
||||
super.key,
|
||||
required this.section,
|
||||
@@ -60,6 +62,7 @@ class SettingsTab extends StatelessWidget {
|
||||
this.updates,
|
||||
this.jellyfin,
|
||||
this.animeFlowMe,
|
||||
this.onDanmakuPreview,
|
||||
});
|
||||
|
||||
List<Widget> _page(BuildContext context) {
|
||||
@@ -162,7 +165,10 @@ class SettingsTab extends StatelessWidget {
|
||||
case SettingsSection.shortcuts:
|
||||
return const [SettingsPanel(title: 'Keyboard shortcuts', child: ShortcutList())];
|
||||
case SettingsSection.about:
|
||||
return [AboutPanel(updates: updates)];
|
||||
return [
|
||||
AboutPanel(updates: updates),
|
||||
if (devTools) DeveloperPanel(onDanmakuPreview: onDanmakuPreview),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -46,6 +46,8 @@ const _keyNotifyPeople = 'notifyPeople';
|
||||
const _keyShareDiagnostics = 'shareDiagnostics';
|
||||
const _keyWhatsNewSeen = 'whatsNewSeen';
|
||||
const _keyWhatsNewNotes = 'whatsNewNotes';
|
||||
const _keyScreenResVideo = 'screenResVideo';
|
||||
const _keyLiftSubtitles = 'liftSubtitles';
|
||||
|
||||
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
|
||||
/// session (never chat). Off: nothing is sent but a note that it is off.
|
||||
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.
|
||||
final chatPopups = signal(ChatPopups.all);
|
||||
/// 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;
|
||||
final line = box.get(_keyProgressLine);
|
||||
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);
|
||||
if (share is bool) shareDiagnostics.value = share;
|
||||
final popups = box.get(_keyChatPopups);
|
||||
@@ -279,6 +293,16 @@ class AppSettings {
|
||||
_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) {
|
||||
shareDiagnostics.value = on;
|
||||
_put(_keyShareDiagnostics, on);
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:starsync/src/danmaku/danmaku.dart';
|
||||
import 'package:starsync/src/shell/avatar.dart' show AvatarInfo;
|
||||
|
||||
// Danmaku lanes: free lanes only, no two lines in a lane ever touch, the
|
||||
// density cap with a queue in arrival order, and the layer over a real
|
||||
// widget clock.
|
||||
|
||||
const w = 1000.0;
|
||||
|
||||
/// Lines in one lane never overlap (gap kept) at any sampled time.
|
||||
void expectNoOverlap(DanmakuLanes<int> lanes, double t, {double gap = 0}) {
|
||||
final byLane = <int, List<DanmakuFlight<int>>>{};
|
||||
for (final f in lanes.flights) {
|
||||
byLane.putIfAbsent(f.lane, () => []).add(f);
|
||||
}
|
||||
for (final fs in byLane.values) {
|
||||
fs.sort((a, b) => a.start.compareTo(b.start));
|
||||
for (var i = 1; i < fs.length; i++) {
|
||||
final ahead = fs[i - 1], behind = fs[i];
|
||||
final tail = ahead.x(t, w) + ahead.width;
|
||||
// Only while both are on screen.
|
||||
if (tail <= 0 || behind.x(t, w) >= w) continue;
|
||||
expect(behind.x(t, w), greaterThanOrEqualTo(tail + gap - 1e-6),
|
||||
reason: 'lane ${behind.lane} at t=$t: #${behind.item} reaches #${ahead.item}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
List<DanmakuFlight<int>> tick(DanmakuLanes<int> lanes, double t, Map<int, double> widths,
|
||||
{int lanesN = 5, int cap = 100, double cross = 8}) =>
|
||||
lanes.tick(t, w: w, lanes: lanesN, cap: cap, crossSecs: cross, measure: (i) => widths[i]!);
|
||||
|
||||
void main() {
|
||||
group('DanmakuLanes', () {
|
||||
test('lines at the same moment take the lanes top down', () {
|
||||
final lanes = DanmakuLanes<int>();
|
||||
final widths = {for (var i = 0; i < 3; i++) i: 200.0};
|
||||
for (var i = 0; i < 3; i++) {
|
||||
lanes.add(i, 0);
|
||||
}
|
||||
final placed = tick(lanes, 0, widths);
|
||||
expect([for (final f in placed) f.lane], [0, 1, 2]);
|
||||
});
|
||||
|
||||
test('a lane is reused once the line in it has fully entered', () {
|
||||
final lanes = DanmakuLanes<int>(gap: 20);
|
||||
final widths = {0: 200.0, 1: 200.0};
|
||||
lanes.add(0, 0);
|
||||
tick(lanes, 0, widths, lanesN: 1);
|
||||
lanes.add(1, 0.1);
|
||||
// 200 px + gap at 150 px/s: not yet in.
|
||||
expect(tick(lanes, 0.1, widths, lanesN: 1), isEmpty);
|
||||
expect(lanes.waiting, 1);
|
||||
// Same length, same speed: it may follow once 220 px have entered.
|
||||
final placed = tick(lanes, 1.6, widths, lanesN: 1);
|
||||
expect(placed.single.lane, 0);
|
||||
});
|
||||
|
||||
test('a long (faster) line never catches a short one ahead in its lane', () {
|
||||
final lanes = DanmakuLanes<int>(gap: 20);
|
||||
final widths = {0: 60.0, 1: 900.0};
|
||||
lanes.add(0, 0);
|
||||
tick(lanes, 0, widths, lanesN: 1);
|
||||
lanes.add(1, 0.5);
|
||||
// The short one is in, but the long one would run into it: wait.
|
||||
for (var t = 0.5; t < 8; t += 0.1) {
|
||||
final placed = tick(lanes, t, widths, lanesN: 1);
|
||||
if (placed.isNotEmpty) {
|
||||
// Placed: from then on they never touch.
|
||||
for (var s = t; s < t + 8; s += 0.05) {
|
||||
expectNoOverlap(lanes, s, gap: 20);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
fail('the long line never got the lane');
|
||||
});
|
||||
|
||||
test('a random stream of mixed lengths never overlaps', () {
|
||||
final rng = math.Random(3);
|
||||
final lanes = DanmakuLanes<int>(gap: 24, maxWaitSecs: 1000, maxWaiting: 10000);
|
||||
final widths = <int, double>{};
|
||||
var n = 0;
|
||||
for (var t = 0.0; t < 120; t += 0.05) {
|
||||
if (rng.nextDouble() < 0.15) {
|
||||
widths[n] = 20 + rng.nextDouble() * 900;
|
||||
lanes.add(n++, t);
|
||||
}
|
||||
tick(lanes, t, widths, lanesN: 6, cap: 14, cross: 6 + rng.nextDouble() * 0.0);
|
||||
expectNoOverlap(lanes, t, gap: 24);
|
||||
expect(lanes.flights.length, lessThanOrEqualTo(14));
|
||||
}
|
||||
expect(n, greaterThan(300));
|
||||
});
|
||||
|
||||
test('the density cap holds the rest in arrival order', () {
|
||||
final lanes = DanmakuLanes<int>();
|
||||
final widths = {for (var i = 0; i < 10; i++) i: 100.0};
|
||||
for (var i = 0; i < 10; i++) {
|
||||
lanes.add(i, 0);
|
||||
}
|
||||
final first = tick(lanes, 0, widths, lanesN: 20, cap: 4);
|
||||
expect([for (final f in first) f.item], [0, 1, 2, 3]);
|
||||
expect(lanes.waiting, 6);
|
||||
// Nothing leaves before the crossing time: still capped.
|
||||
expect(tick(lanes, 7.9, widths, lanesN: 20, cap: 4), isEmpty);
|
||||
final next = tick(lanes, 8, widths, lanesN: 20, cap: 4);
|
||||
expect([for (final f in next) f.item], [4, 5, 6, 7]);
|
||||
});
|
||||
|
||||
test('a line that waited too long is dropped; the queue is bounded', () {
|
||||
final lanes = DanmakuLanes<int>(maxWaitSecs: 5, maxWaiting: 3);
|
||||
final widths = {for (var i = 0; i < 6; i++) i: 100.0};
|
||||
for (var i = 0; i < 5; i++) {
|
||||
lanes.add(i, 0);
|
||||
}
|
||||
expect(lanes.waiting, 3);
|
||||
expect(lanes.dropped, 2);
|
||||
tick(lanes, 6, widths, cap: 0);
|
||||
expect(lanes.waiting, 0);
|
||||
expect(lanes.dropped, 5);
|
||||
});
|
||||
});
|
||||
|
||||
group('DanmakuStyle', () {
|
||||
test('lanes fill the band at the text size', () {
|
||||
const s = DanmakuStyle(bandTop: 0.1, bandBottom: 0.45);
|
||||
// 720 px: 30.24 px text, 45.36 px lanes in a 252 px band.
|
||||
expect(s.lanesFor(720), 5);
|
||||
expect(const DanmakuStyle(bandTop: 0.4, bandBottom: 0.41).lanesFor(720), 1);
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('the layer flies lines under the cap and lets the rest wait', (tester) async {
|
||||
final c = DanmakuController();
|
||||
addTearDown(c.dispose);
|
||||
await tester.pumpWidget(Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: SizedBox(
|
||||
width: 800,
|
||||
height: 450,
|
||||
child: DanmakuLayer(
|
||||
controller: c,
|
||||
accent: Colors.purple,
|
||||
style: const DanmakuStyle(maxOnScreen: 3, crossSecs: 4),
|
||||
),
|
||||
),
|
||||
));
|
||||
for (var i = 0; i < 7; i++) {
|
||||
c.add(DanmakuItem(
|
||||
kind: DanmakuKind.chat,
|
||||
sender: 'Bob',
|
||||
text: 'line $i',
|
||||
own: i == 0,
|
||||
avatar: i.isEven ? const AvatarInfo(name: 'Bob', color: Colors.teal) : null));
|
||||
}
|
||||
await tester.pump(const Duration(milliseconds: 16));
|
||||
await tester.pump(const Duration(milliseconds: 16));
|
||||
expect(c.onScreen.value, 3);
|
||||
expect(c.waiting.value, 4);
|
||||
// After a crossing the next ones fly.
|
||||
await tester.pump(const Duration(seconds: 4));
|
||||
await tester.pump(const Duration(milliseconds: 16));
|
||||
expect(c.onScreen.value, 3);
|
||||
expect(c.waiting.value, 1);
|
||||
c.clear();
|
||||
await tester.pump(const Duration(milliseconds: 16));
|
||||
expect(c.onScreen.value, 0);
|
||||
expect(c.waiting.value, 0);
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:starsync/src/platform/ports/player_host.dart' show RenderMode;
|
||||
import 'package:starsync/src/screens/home/ui.dart' show devToolsFor;
|
||||
import 'package:starsync/src/shell/video_view.dart';
|
||||
|
||||
// The video view's asks of mpv: the output size ("Sharp video and
|
||||
// subtitles") and the dev experiment's subtitle lift.
|
||||
|
||||
void main() {
|
||||
group('videoOutputSize', () {
|
||||
test('the view in physical pixels on the GPU', () {
|
||||
expect(videoOutputSize(const Size(1280, 720), 1.5, on: true, mode: RenderMode.gpu), (1920, 1080));
|
||||
expect(videoOutputSize(const Size(384, 216), 2, on: true, mode: RenderMode.gpu), (768, 432));
|
||||
});
|
||||
|
||||
test("the video's own size when off, not on the GPU yet, or in software", () {
|
||||
expect(videoOutputSize(const Size(1280, 720), 1, on: false, mode: RenderMode.gpu), isNull);
|
||||
for (final m in [RenderMode.unknown, RenderMode.software, RenderMode.softwareGl]) {
|
||||
expect(videoOutputSize(const Size(1280, 720), 1, on: true, mode: m), isNull, reason: m.name);
|
||||
}
|
||||
expect(videoOutputSize(const Size(4, 4), 1, on: true, mode: RenderMode.gpu), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('subtitleLiftPos', () {
|
||||
test('a picture filling the view lifts by the controls', () {
|
||||
// 108 px of controls + 8 px gap in a 1080 px picture: 10.74 %.
|
||||
final pos = subtitleLiftPos(view: const Size(1920, 1080), videoW: 1920, videoH: 1080, controls: 108);
|
||||
expect(pos, closeTo(100 - 116 / 1080 * 100, 1e-9));
|
||||
});
|
||||
|
||||
test('a letterbox under the picture counts toward the room', () {
|
||||
// 2.39:1 in 16:9: 803 px picture, 138 px bar below it.
|
||||
final none = subtitleLiftPos(view: const Size(1920, 1080), videoW: 1920, videoH: 803, controls: 100);
|
||||
expect(none, 100);
|
||||
final some = subtitleLiftPos(view: const Size(1920, 1080), videoW: 1920, videoH: 803, controls: 200);
|
||||
expect(some, lessThan(100));
|
||||
});
|
||||
|
||||
test('unknown video size: as if it filled the view; never past half', () {
|
||||
expect(subtitleLiftPos(view: const Size(800, 450), controls: 90), closeTo(100 - 98 / 450 * 100, 1e-9));
|
||||
expect(subtitleLiftPos(view: const Size(800, 100), controls: 500), 50);
|
||||
expect(subtitleLiftPos(view: Size.zero, controls: 90), 100);
|
||||
});
|
||||
});
|
||||
|
||||
test('dev tools: dev versions and debug runs only', () {
|
||||
expect(devToolsFor(version: '1.3.3-dev.2', debug: false), isTrue);
|
||||
expect(devToolsFor(version: '0.0.0-dev', debug: false), isTrue);
|
||||
expect(devToolsFor(version: '1.3.2', debug: true), isTrue);
|
||||
expect(devToolsFor(version: '1.3.2', debug: false), isFalse);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user