diff --git a/CHANGELOG.md b/CHANGELOG.md index 96592290..6ab27544 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Links you put up are downloaded once for everyone, plus What's new and fixes for ### Added - What's new: the update band shows what changed, and after an update the app shows the full list once (also in Settings → About) +- Sharper subtitles and picture: the video and its subtitles are drawn at your screen's resolution instead of the video's (Settings → Player → Sharp video and subtitles, on by default) ### Changed diff --git a/apps/desktop/lib/src/controls/player_settings.dart b/apps/desktop/lib/src/controls/player_settings.dart index 39ee2a43..1baefca0 100644 --- a/apps/desktop/lib/src/controls/player_settings.dart +++ b/apps/desktop/lib/src/controls/player_settings.dart @@ -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; + 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 playerSettingRows({ReadonlySignal? renderMode}) => [ const PlayerDesignSetting(), const ProgressLineSetting(), ShaderPresetSetting(renderMode: renderMode), + ScreenResSetting(renderMode: renderMode), const HwdecSetting(), ]; diff --git a/apps/desktop/lib/src/danmaku/danmaku.dart b/apps/desktop/lib/src/danmaku/danmaku.dart new file mode 100644 index 00000000..f55b4a3d --- /dev/null +++ b/apps/desktop/lib/src/danmaku/danmaku.dart @@ -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 { + 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 { + /// 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> flights = []; + final Queue<(T, double)> _waiting = Queue(); + int dropped = 0; + + int get waiting => _waiting.length; + + /// Everything flying and waiting goes; returns it. + List 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> 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 = >[]; + 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 = >{}; + 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 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 = []; + 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 _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 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 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 = []; + var _shown = >[]; + + 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> a, List> 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; +} diff --git a/apps/desktop/lib/src/danmaku/danmaku_preview.dart b/apps/desktop/lib/src/danmaku/danmaku_preview.dart new file mode 100644 index 00000000..1e96e939 --- /dev/null +++ b/apps/desktop/lib/src/danmaku/danmaku_preview.dart @@ -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> 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(List 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 createState() => _DanmakuStandInState(); +} + +class _DanmakuStandInState extends State 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 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 createState() => _DanmakuPanelState(); +} + +class _DanmakuPanelState extends State { + 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 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 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 showDanmakuPreviewPage(BuildContext context, DanmakuPreview preview) => showGeneralDialog( + 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 createState() => _DanmakuSessionPanelState(); +} + +class _DanmakuSessionPanelState extends State { + 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, + ); +} diff --git a/apps/desktop/lib/src/platform/desktop/media_kit_host.dart b/apps/desktop/lib/src/platform/desktop/media_kit_host.dart index a164b29e..99358f7f 100644 --- a/apps/desktop/lib/src/platform/desktop/media_kit_host.dart +++ b/apps/desktop/lib/src/platform/desktop/media_kit_host.dart @@ -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.broadcast(); final _throttle = LogThrottle(burst: 5); @@ -90,6 +93,16 @@ class MediaKitHost implements PlayerHost { @override ReadonlySignal 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 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 headers, bool play})? _lastOpen; bool _voRetried = false; + _MkVideo? _video; dynamic get _mpv => mk.platform; @@ -529,4 +550,53 @@ class _MkVideo implements HostedVideo { @override Future 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? _paramsSub; + Future _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 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'); + } + }); } diff --git a/apps/desktop/lib/src/platform/ports/player_host.dart b/apps/desktop/lib/src/platform/ports/player_host.dart index 74570364..7333c72c 100644 --- a/apps/desktop/lib/src/platform/ports/player_host.dart +++ b/apps/desktop/lib/src/platform/ports/player_host.dart @@ -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 get renderMode; diff --git a/apps/desktop/lib/src/screens/home/about_page.dart b/apps/desktop/lib/src/screens/home/about_page.dart index ac348da2..cb94fa8b 100644 --- a/apps/desktop/lib/src/screens/home/about_page.dart +++ b/apps/desktop/lib/src/screens/home/about_page.dart @@ -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 { diff --git a/apps/desktop/lib/src/screens/home/ui.dart b/apps/desktop/lib/src/screens/home/ui.dart index 36e2f28f..ee4112ac 100644 --- a/apps/desktop/lib/src/screens/home/ui.dart +++ b/apps/desktop/lib/src/screens/home/ui.dart @@ -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; diff --git a/apps/desktop/lib/src/shell/app_shell.dart b/apps/desktop/lib/src/shell/app_shell.dart index 70276184..8634bc4c 100644 --- a/apps/desktop/lib/src/shell/app_shell.dart +++ b/apps/desktop/lib/src/shell/app_shell.dart @@ -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 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 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 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 with SingleTickerProviderStateMixin videoBuilder: widget.videoBuilder, windowControls: widget.windowControls, controlsShown: _controlsShown, + danmaku: _danmaku, ), ), ), diff --git a/apps/desktop/lib/src/shell/session_player.dart b/apps/desktop/lib/src/shell/session_player.dart index 9beb9923..6f76ff8e 100644 --- a/apps/desktop/lib/src/shell/session_player.dart +++ b/apps/desktop/lib/src/shell/session_player.dart @@ -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? 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 { 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 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 { _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 { 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 { 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 { ); } - 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( + 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 { 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( + 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 { 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, diff --git a/apps/desktop/lib/src/shell/settings_tab.dart b/apps/desktop/lib/src/shell/settings_tab.dart index d69c5c15..25aca519 100644 --- a/apps/desktop/lib/src/shell/settings_tab.dart +++ b/apps/desktop/lib/src/shell/settings_tab.dart @@ -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 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 _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), + ]; } } diff --git a/apps/desktop/lib/src/shell/video_view.dart b/apps/desktop/lib/src/shell/video_view.dart new file mode 100644 index 00000000..de8e6ed6 --- /dev/null +++ b/apps/desktop/lib/src/shell/video_view.dart @@ -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 sharp; + final Widget child; + const VideoOutputSizer({super.key, required this.playback, required this.sharp, required this.child}); + + @override + State createState() => _VideoOutputSizerState(); +} + +class _VideoOutputSizerState extends State { + 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 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 createState() => _SubtitleLifterState(); +} + +class _SubtitleLifterState extends State 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(); + }); +} diff --git a/apps/desktop/lib/src/state/settings.dart b/apps/desktop/lib/src/state/settings.dart index 136bc267..8e1963a9 100644 --- a/apps/desktop/lib/src/state/settings.dart +++ b/apps/desktop/lib/src/state/settings.dart @@ -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); diff --git a/apps/desktop/test/danmaku_test.dart b/apps/desktop/test/danmaku_test.dart new file mode 100644 index 00000000..3e344a7f --- /dev/null +++ b/apps/desktop/test/danmaku_test.dart @@ -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 lanes, double t, {double gap = 0}) { + final byLane = >>{}; + 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> tick(DanmakuLanes lanes, double t, Map 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(); + 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(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(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(gap: 24, maxWaitSecs: 1000, maxWaiting: 10000); + final widths = {}; + 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(); + 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(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()); + }); +} diff --git a/apps/desktop/test/video_view_test.dart b/apps/desktop/test/video_view_test.dart new file mode 100644 index 00000000..4534fd22 --- /dev/null +++ b/apps/desktop/test/video_view_test.dart @@ -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); + }); +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index bba577f7..bdc1b456 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -567,6 +567,30 @@ server without the relay's header rule, or the connection reuse above) fails every seek until a fresh open, so the coordinator runs "Reload video" once per media for it (`mpvStreamLost`, `onStreamLost`). +Output size ("Sharp video and subtitles", `AppSettings.screenResVideo`, +on by default, live; Settings → Player and the player settings card): +media_kit renders mpv into a texture the size of the video and Flutter +scales it, so libass subtitles, mpv's scaler and the shaders all work on +a 720p image for a 720p file. With the setting on and the GPU renderer +(`renderMode` gpu; never in software rendering, where every pixel is CPU +work, nor before the probe knows), the session view +(`shell/video_view.dart`, `VideoOutputSizer` around the `Video`) sends its +size in physical pixels to the host (`PlayerHost.setOutputSize`): at +once the first time, then once a resize has held 180 ms +(`outputResizeDelay`: a window drag, fullscreen, full ↔ mini). The host +applies it to every live output and to each new one once it is up +(`NativeVideoController.setSize`); mpv then letterboxes inside it and +draws subtitles at that size (text subtitles may sit in the black bars, +as in mpv). media_kit's controller sets an output back to the video's +size whenever the video's parameters change (each new file), so the host +sends the size again after it, under the controller's own lock; a fresh +player's first frame or two show at the video's size. A paused player +draws no frame for the new size: the host nudges `video-pan-x` by a +millionth and back so mpv redraws. Off: each output at its video's own +size again (`setOutputSize(null)`). Measured on a GTX 1080, 1280x720 +H.264 at 2560x1080 fullscreen: no difference past the noise in the +process's GPU (`nvidia-smi pmon`, 9 to 12 % either way) or CPU. + Render mode: media_kit's Linux/Windows video output creates mpv's render context (OpenGL, else its software renderer) when the `VideoController` is created, and nothing in its API says which. The host therefore requests @@ -606,7 +630,8 @@ their own): creates players by role (`session`: the full cache, `warn`; no outputs, no cache, no log) with every pin set before the caller's first command (`pinMismatches` lists the read-backs that differ), gives a player its video output (`HostedVideo`, with the hwdec policy: -`SYNC_RELAY_HWDEC`, else the setting, `auto-copy` on Windows), runs the +`SYNC_RELAY_HWDEC`, else the setting, `auto-copy` on Windows) at the +view's size when asked (`setOutputSize`, see "Output size"), runs the render-mode probe and its diagnostics (`renderMode`, also after leaving), routes mpv's log, and owns the order players go in: `retire` (pause, let the view drop the texture: two frames and a route pop, then dispose; a @@ -2112,7 +2137,8 @@ panel) and chat messages (Copy message, and a link in it). between kinds. The mini shows its short line in its hover bar. ⚙ → Player settings (both skins) opens the player settings card (`controls/ player_settings.dart`, `showPopover`, on `AppGlass`): player design, progress line - when hidden, upscaling shader, hardware decoding (set live on mpv's + when hidden, upscaling shader, sharp video and subtitles (the output + at screen size, see "Media"), hardware decoding (set live on mpv's `hwdec`), subtitle and audio track. The rows (`playerSettingRows`) are the same widgets Settings → Player shows; all apply live. Settings → Player also has the controls' own choices (`controlsSettingRows`, see diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 745b830c..447735b8 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -75,6 +75,61 @@ Harnesses in `tool/` (own `pubspec.yaml`, `dart run tool/.dart`): `range_logger` (serve a file and log every Range request), `shaper` (bandwidth cap, latency, `--kill-after` for outage tests), `fake_viewer` and `e2e_check` (simulated viewers against a live server). +`tool/make_sub_test.sh [out.mkv] [seconds]` (ffmpeg) writes a 720p test +video for subtitle placement and sharpness (default +`~/Videos/starsync-sub-test.mkv`): an .ass track with a top sign +(`\an8`) and bottom dialogue (English and Japanese), and an SRT track. + +## Dev tools + +Test builds and runs from source (a version with `-dev`) and debug runs +(`devTools`, `screens/home/ui.dart`) get Settings → About → Developer; +releases never show it. What is there is an experiment to look at, not +a decided feature: + +- **Danmaku preview** (`lib/src/danmaku/`): chat lines and short notices + (joins, leaves, Up next adds, "starting now") flying right to left + across the upper part of the picture. `DanmakuLayer` is the reusable + widget: lanes in a band of the height (`DanmakuStyle.bandTop` / + `bandBottom`, default 10 to 45 %, under top signs, never down into the + dialogue); each line takes the topmost free lane (`DanmakuLanes`, pure: + the last line in a lane must have fully entered with a gap, and the + new one, at its own speed, cannot catch it before it leaves); at most + `maxOnScreen` fly, the rest wait in order (dropped after 15 s, 60 at + most); every line crosses in `crossSecs`, so long ones move faster; + white text with a dark outline and no box, the sender's avatar + (`StatusAvatar`: picture, else initial) and coloured name first, the + app's own notices without one, own lines in an accent frame; the + layer's own clock, so lines fly on while paused. Each line is a widget + moved by a `Flow`: per frame only paint runs. The button opens it over + the full player when one is up (under the controls; the panel top + right holds them up), else on a page with a moving stand-in picture + and a mock control bar of the skin in use. The panel (`DanmakuPanel`) + fires samples (chat, own, join, leave, Up next, starting, a burst), a + random stream (Poisson arrivals at a rate, short to long lines, CJK + and emoji; in a session half the senders are the party's real + members), this session's own chat and notice toasts, and every + `DanmakuStyle` knob live (crossing time, cap, lane band, opacity, + size, outline, avatars, own frame), plus mock subtitles (a top sign + and bottom dialogue). Tests: `danmaku_test`. +- **Lift subtitles above the chat line and controls** + (`AppSettings.liftSubtitles`, off): Enter in the full player opens a + stand-in for the planned chat line (a one-line box at the bottom in + place of the control bar; Enter sends to the chat, Esc closes). While + it is open, or the control bar is up, bottom subtitles move above it + and back down when it goes (`SubtitleLifter`, `video_view.dart`): mpv + `sub-pos`, animated over the shell's 320 ms curve in 0.1 steps, only as + far as the letterbox under the picture leaves no room + (`subtitleLiftPos`). What each mpv property does, on + `make_sub_test.sh`'s file (mpv 0.41, libmpv as the app uses it): + `sub-pos` moves bottom ASS dialogue and SRT lines up and leaves `\an8` + signs in place (with `sub-ass-override` at its default `scale` or + `yes`; `no` ignores it for ASS); `sub-margin-y` moves SRT only (ASS + only with `sub-ass-override=force`, which restyles every line, signs + too); `sub-ass-style-overrides=MarginV=...` moves top signs down as + well; `video-margin-ratio-bottom` shrinks and moves the picture + itself (with `sub-ass-force-margins` the dialogue goes into the new + black band). ## Tests @@ -109,7 +164,9 @@ Three suites, run from each package's folder: version gate, the media plan's table with the Jellyfin relay rule and the "Waiting for" status (`media_plan_test`), watch tracking and the device history (`watch_tracker_test`), what continues a file of a - local show (`queue_logic_test`)), `singletons_test` (the `.instance` count, see + local show (`queue_logic_test`), the video view's output size and + subtitle lift and the dev-tools gate (`video_view_test`), danmaku lanes + (`danmaku_test`)), `singletons_test` (the `.instance` count, see ARCHITECTURE "Composition root"; lower its baseline when a use goes) and the real-mpv `*_e2e` tests plus `coord_presence`. The whole suite takes a couple of minutes; run it only before a restructure step lands, otherwise only the files diff --git a/tool/make_sub_test.sh b/tool/make_sub_test.sh new file mode 100755 index 00000000..de281a9e --- /dev/null +++ b/tool/make_sub_test.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# A 720p test video for subtitle placement and sharpness: an .ass track +# (default) with a top-positioned sign (\an8) and bottom dialogue, and an +# SRT track with plain bottom lines. Needs ffmpeg. +# +# tool/make_sub_test.sh [out.mkv] [seconds] +# +# Default output: ~/Videos/starsync-sub-test.mkv, 60 s. +set -euo pipefail + +out="${1:-$HOME/Videos/starsync-sub-test.mkv}" +secs="${2:-60}" +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT + +clock() { # seconds -> H:MM:SS.cc (ass) or HH:MM:SS,mmm (srt) + local s=$1 fmt=$2 + local h=$((s / 3600)) m=$((s % 3600 / 60)) r=$((s % 60)) + if [[ $fmt == ass ]]; then printf '%d:%02d:%02d.00' $h $m $r; else printf '%02d:%02d:%02d,000' $h $m $r; fi +} + +lines=( + "Bottom dialogue: the controls should never cover this line." + "A second line of dialogue,\\Nsplit over two rows." + "日本語の字幕もここに表示されます。" + "Short one." + "The sign at the top must stay where it is." +) + +{ + cat <<'EOF' +[Script Info] +ScriptType: v4.00+ +PlayResX: 1280 +PlayResY: 720 +WrapStyle: 0 +ScaledBorderAndShadow: yes + +[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: Default,Noto Sans,48,&H00FFFFFF,&H000000FF,&H00000000,&H80000000,0,0,0,0,100,100,0,0,1,2.5,1,2,40,40,36,1 +Style: Sign,Noto Serif,40,&H0000E5FF,&H000000FF,&H00301000,&H00000000,1,0,0,0,100,100,1,0,1,3,0,8,40,40,30,1 + +[Events] +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +EOF + echo "Dialogue: 0,$(clock 0 ass),$(clock "$secs" ass),Sign,,0,0,0,,{\\an8}TOP SIGN (\\\\an8): stays put" + i=0 + for ((t = 0; t < secs; t += 4)); do + echo "Dialogue: 0,$(clock $t ass),$(clock $((t + 4)) ass),Default,,0,0,0,,${lines[$((i % ${#lines[@]}))]}" + i=$((i + 1)) + done +} >"$work/subs.ass" + +{ + n=1 + for ((t = 0; t < secs; t += 4)); do + echo $n + echo "$(clock $t srt) --> $(clock $((t + 4)) srt)" + echo "Plain SRT line $n at the bottom" + echo + n=$((n + 1)) + done +} >"$work/subs.srt" + +mkdir -p "$(dirname "$out")" +ffmpeg -hide_banner -loglevel error -y \ + -f lavfi -i "testsrc2=size=1280x720:rate=24:duration=$secs" \ + -f lavfi -i "sine=frequency=440:duration=$secs" \ + -i "$work/subs.ass" -i "$work/subs.srt" \ + -map 0:v -map 1:a -map 2 -map 3 \ + -c:v libx264 -preset veryfast -crf 23 -pix_fmt yuv420p -c:a aac -b:a 96k \ + -c:s:0 ass -c:s:1 srt \ + -metadata:s:s:0 title="ASS: top sign + bottom dialogue" -metadata:s:s:0 language=eng \ + -metadata:s:s:1 title="SRT: bottom lines" -metadata:s:s:1 language=eng \ + -disposition:s:0 default -disposition:s:1 0 \ + "$out" +echo "$out"