Player: sharp video and subtitles at screen size; dev tools: danmaku preview, subtitle lift with a stand-in chat line
- "Sharp video and subtitles" (Settings → Player and the player settings card, on by default): the video output renders at the view's physical size (debounced, GPU rendering only), so libass, mpv's scaler and the shaders work at screen resolution; re-sent after media_kit's own reset on new video params, redrawn while paused. - Dev builds only (Settings → About → Developer): a danmaku preview (DanmakuLayer: free lanes in an upper band, density cap with a queue, equal crossing time, outlined text, avatars, own-line frame) over the real player or a stand-in picture, with samples, a random stream, live toasts, every knob and mock subtitles; and "Lift subtitles above the chat line and controls": Enter opens a stand-in chat line, bottom subtitles move up via mpv sub-pos while it or the control bar is up. - tool/make_sub_test.sh: a test video with a top \an8 sign, bottom dialogue and an SRT track. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
638a30e16c
commit
5bc252bf02
18 files changed
+2253
-16
No files matched your search
@@ -0,0 +1,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,
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user